current state

This commit is contained in:
echo 2026-05-28 15:20:02 +02:00
parent 42b58b02bf
commit 152ef17610
115 changed files with 2922 additions and 704 deletions

88
AGENTS.md Normal file
View File

@ -0,0 +1,88 @@
# comic-odin / comicsroll
Dual-language repo: **React/TypeScript frontend** (`src/`) + **Odin native desktop app** (`odin/`).
## Build & Test — Odin (`odin/`)
```bash
# Build debug binary
./odin/build.sh # builds C deps + odin src/app
# Or directly:
odin build odin/src/app -target:linux_amd64 -collection:clay=$(pwd)/odin/vendor/clay/bindings/odin/clay-odin
# Run all tests
odin test odin/tests -target:linux_amd64 -collection:clay=$(pwd)/odin/vendor/clay/bindings/odin/clay-odin
# Run single test
odin test odin/tests -target:linux_amd64 -collection:clay=$(pwd)/odin/vendor/clay/bindings/odin/clay-odin -define:ODIN_TEST_NAMES=tests.<test_proc_name>
# Run binary
./bin/comic_odin gui # Raylib GUI
./bin/comic_odin tui # Terminal TUI
```
## Build & Test — React Frontend (`src/`)
```bash
npm run dev # vite dev server
npm run build # tsc -b && vite build
npm run lint # eslint . (strictTypeChecked, no any)
npm run typecheck # tsc --noEmit
npm run test # vitest run (jsdom, globals)
npm run test:ui # vitest --ui
```
## Architecture
### React (Vite 8, React 19, TypeScript 6, Tailwind 3, Zustand 5)
- **No URL routing** — manual `useState` + zustand `workflow.currentStep` drives component switching in `App.tsx`.
- **Single zustand store** (`src/store/comicStore.ts`) with `persist` middleware.
- **Two API services**: DeepSeek (via OpenAI SDK for script gen) and fal.ai (via `@fal-ai/client` for images).
- **`@` path alias** → `./src`.
- **`verbatimModuleSyntax: true`** — type-only imports need `import type`.
- **`noUnusedLocals`, `noUnusedParameters` both `true`**.
- `framer-motion`, `fabric`, `react-router-dom` installed but unused.
- No test files exist yet (vitest configured and ready).
### Odin (Raylib + Clay layout engine + osdialog)
- **Single binary**, entry: `odin/src/app/main.odin``run_cli_from_process_args()``.Gui``gui.run_gui_app()`
- **8 source packages**: `app` (entry), `core` (domain), `shared` (config/errors), `adapters` (DeepSeek/fal/export/storage), `ui` (controller/screens/jobs), `gui` (Raylib+Clay UI), `osdialog` (file dialogs).
- Clay imported as `import clay "clay:."` via collection `-collection:clay=vendor/clay/bindings/odin/clay-odin`.
- **ols.json** defines the `clay` collection path.
### Workflow state machine
```
Story_Input → Generating_Script → Script_Review → Character_Setup →
Generating_Panels → Layout → Speech_Bubbles → Complete
```
Defined in `core/workflow.odin` (`can_transition()`). In the React app, zustand's `workflow.currentStep` mirrors this.
## Clay Layout Gotchas (Critical)
- **`SizingPercent()` expects 01, not 0100.** `SizingPercent(55)` = 5500% (off-screen). Use `SizingPercent(0.55)`.
- **`SizingFit({})` cards + `SizingGrow({})` children = invisible.** A Fit-height card computes its height from children's *initial* CloseElement sizes. Grow children have initial height 0, so the card stays tiny. If a card contains a Grow-height child (scroll list, wireframe), override the card height to `SizingGrow({})`.
- **`backgroundColor` on image elements generates a covering RECTANGLE.** Remove `backgroundColor` from image elements to let the IMAGE command be visible.
- **Texture pointers** (`rawptr(tex_ptr)`) require `reserve(&app.panel_textures, N)` after map creation to avoid pointer invalidation on reallocation.
- **Clay v0.14** is vendored at `vendor/clay/`. The Odin binding is at `vendor/clay/bindings/odin/clay-odin/clay.odin`.
## Raygui Integration
- `rl.GuiTextBox` overlays on top of Clay layout. After Clay renders, get element bounds via `clay.GetElementData(id)`.
- `GuiTextBox` returns `true` on click — NOT on text change. Compare buffer length after call to detect edits.
- `rl.EndScissorMode()` must be called before `rl.GuiTextBox` and after to clear stale scissor state.
## Memory
- **Odin**: `persistent_pool` (256KB) in `gui/runtime.odin` for strings that must survive temp allocator resets.
- **Odin**: All `delete()`'d string defaults in `new_initial_state()` must use `strings.clone()` to avoid `free(): invalid pointer` crashes.
- **React**: Zustand store persisted to localStorage (`comic-creator-storage`). `partialize` excludes ephemeral state.
## API Keys (.env)
```
DEEPSEEK_API_KEY=sk-...
FAL_API_KEY=...
VITE_DEEPSEEK_API_KEY=sk-...
VITE_FAL_KEY=...
```

BIN
app Executable file

Binary file not shown.

BIN
odin/app Executable file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

View File

@ -1 +1 @@
screen=Story workflow=Story_Input next=generate script local dirty=no autosave=yes(20s) content=pages:0,panels:0,layouts:0,chars:0 paths=P:yes,E:yes project=./gui_project.comic.json export=./gui_export.pdf log=0,newest uptime=38.2s screen=Characters workflow=Complete next=export pdf dirty=no autosave=yes(20s) content=pages:2,panels:12,layouts:2,chars:2 paths=P:yes,E:yes project=./gui_project.comic.json export=comic.pdf log=6,newest uptime=77.6s

Binary file not shown.

View File

@ -9,40 +9,533 @@
"last_modified_iso": "" "last_modified_iso": ""
}, },
"user_mode": 0, "user_mode": 0,
"story_idea": "two balls roli", "story_idea": "two balls roling doen the street",
"story_genre": "action", "story_genre": "mystery",
"target_audience": "general", "target_audience": "general",
"art_style": "manga", "art_style": "sketch",
"script": { "script": {
"title": "", "title": "The Rolling Enigma",
"synopsis": "", "synopsis": "Two ordinary balls roll down a deserted street at dusk, their path revealing clues to a mysterious event that has unsettled the town.",
"characters": [ "characters": [
{
"id": "char_001",
"name": "Red Ball",
"role": 0,
"description": "A bright red, slightly scuffed rubber ball, about the size of a baseball, with a faint smiley face drawn on it in black marker. It appears worn but determined.",
"prompt_template": {
"age": "",
"gender": "",
"hair_color": "",
"hair_style": "",
"skin_tone": "",
"eye_color": "",
"body_type": "",
"outfit": "",
"accessories": "",
"distinguishing_features": ""
},
"reference_image_url": "",
"character_sheet_urls": [
],
"seed": 780351378,
"color_palette": {
"hair": "",
"eyes": "",
"skin": "",
"outfit": ""
},
"appearance_count": 0,
"first_appearance_panel": "panel_001"
},
{
"id": "char_002",
"name": "Blue Ball",
"role": 0,
"description": "A blue, slightly larger ball with a star pattern and a small dent. It seems to follow the red ball with a sense of purpose.",
"prompt_template": {
"age": "",
"gender": "",
"hair_color": "",
"hair_style": "",
"skin_tone": "",
"eye_color": "",
"body_type": "",
"outfit": "",
"accessories": "",
"distinguishing_features": ""
},
"reference_image_url": "",
"character_sheet_urls": [
],
"seed": 401047035,
"color_palette": {
"hair": "",
"eyes": "",
"skin": "",
"outfit": ""
},
"appearance_count": 0,
"first_appearance_panel": "panel_001"
}
], ],
"pages": [ "pages": [
{
"page_number": 1,
"layout_type": 1,
"panels": [
{
"panel_id": "panel_001",
"panel_number": 1,
"shot_type": 0,
"description": "Sketchy, ink-wash style. A long, empty street at dusk, with old-fashioned street lamps casting pools of light. Two balls, one red and one blue, sit at the top of a gentle slope. The atmosphere is quiet and slightly eerie, with long shadows stretching across the pavement.",
"characters_present": [
"char_001",
"char_002"
],
"dialogue": [
],
"caption": "At the edge of town, where the streetlights flicker and the asphalt cracks, two balls begin to roll.",
"sound_effects": [
],
"transition_from_previous": 0
},
{
"panel_id": "panel_002",
"panel_number": 2,
"shot_type": 1,
"description": "Sketchy, cross-hatched shading. The balls roll side by side, picking up speed. The red ball leads slightly, its smiley face now visible. The blue ball follows, its star pattern catching the light. The street is empty except for a parked car in the background.",
"characters_present": [
"char_001",
"char_002"
],
"dialogue": [
],
"caption": "",
"sound_effects": [
"tumble... tumble..."
],
"transition_from_previous": 1
},
{
"panel_id": "panel_003",
"panel_number": 3,
"shot_type": 3,
"description": "Sketchy, dynamic lines. The red ball's smiley face is focused, almost anthropomorphic. The ball bounces over a crack, leaving a faint trail of dust. The lighting is low, with the streetlamp creating a strong contrast.",
"characters_present": [
"char_001"
],
"dialogue": [
],
"caption": "They have no eyes, but they seem to see. No legs, yet they run.",
"sound_effects": [
"bounce... creak..."
],
"transition_from_previous": 4
},
{
"panel_id": "panel_004",
"panel_number": 4,
"shot_type": 6,
"description": "Sketchy, loose perspective from above. The balls roll around a corner, their shadows elongated. A street sign points 'Elm St.' but is slightly askew. The neighborhood looks abandoned, with boarded-up windows.",
"characters_present": [
"char_001",
"char_002"
],
"dialogue": [
],
"caption": "Following a path only they know.",
"sound_effects": [
],
"transition_from_previous": 3
},
{
"panel_id": "panel_005",
"panel_number": 5,
"shot_type": 5,
"description": "Sketchy, rough lines. Over the red ball's shoulder, the blue ball is seen rolling past a broken streetlamp that flickers. The light casts an eerie glow on the blue ball's dent.",
"characters_present": [
"char_001",
"char_002"
],
"dialogue": [
],
"caption": "",
"sound_effects": [
"flicker... hum..."
],
"transition_from_previous": 1
}
]
},
{
"page_number": 2,
"layout_type": 3,
"panels": [
{
"panel_id": "panel_006",
"panel_number": 1,
"shot_type": 1,
"description": "Sketchy, dramatic lighting. The balls stop at a storm drain. The red ball teeters on the edge, while the blue ball nudges it gently. The drain is dark and deep, with a faint metallic smell suggested by the sketchy lines.",
"characters_present": [
"char_001",
"char_002"
],
"dialogue": [
],
"caption": "",
"sound_effects": [
],
"transition_from_previous": 4
},
{
"panel_id": "panel_007",
"panel_number": 2,
"shot_type": 3,
"description": "Sketchy, intense close-up of the red ball's smiley face. The marker lines seem to twist into a worried expression. A single tear-like smudge appears on the ball. The background is dark, with only a sliver of light.",
"characters_present": [
"char_001"
],
"dialogue": [
],
"caption": "Remembering.",
"sound_effects": [
],
"transition_from_previous": 1
},
{
"panel_id": "panel_008",
"panel_number": 3,
"shot_type": 4,
"description": "Sketchy, almost abstract. Extreme close-up of the blue ball's dent, revealing it to be a tiny, stylized 'X' mark. The surface texture is rough, with visible graphite strokes.",
"characters_present": [
"char_002"
],
"dialogue": [
],
"caption": "Marked.",
"sound_effects": [
],
"transition_from_previous": 3
},
{
"panel_id": "panel_009",
"panel_number": 4,
"shot_type": 6,
"description": "Sketchy, bird's-eye view. The red ball rolls into the storm drain, followed by the blue. The drain grate is slightly ajar. The street is empty, with the flickering streetlamp as the only light source.",
"characters_present": [
"char_001",
"char_002"
],
"dialogue": [
],
"caption": "Into the dark.",
"sound_effects": [
"clink... splash..."
],
"transition_from_previous": 4
},
{
"panel_id": "panel_010",
"panel_number": 5,
"shot_type": 0,
"description": "Sketchy, panoramic view. A wider shot of the street, now silent and still. The storm drain is closed. A single red balloon floats away in the distance. The mood is melancholic and unresolved.",
"characters_present": [
],
"dialogue": [
],
"caption": "Some mysteries are never solved. They just keep rolling.",
"sound_effects": [
],
"transition_from_previous": 1
}
]
}
] ]
}, },
"characters": [ "characters": [
{
"id": "char_001",
"name": "Red Ball",
"role": 0,
"description": "A bright red, slightly scuffed rubber ball, about the size of a baseball, with a faint smiley face drawn on it in black marker. It appears worn but determined.",
"prompt_template": {
"age": "",
"gender": "male",
"hair_color": "black",
"hair_style": "",
"skin_tone": "light",
"eye_color": "black",
"body_type": "",
"outfit": "",
"accessories": "",
"distinguishing_features": ""
},
"reference_image_url": "https://v3b.fal.media/files/b/0a9c0614/7cRK7_UN5SIMLxMFawwy3.jpg",
"character_sheet_urls": [
],
"seed": 780351378,
"color_palette": {
"hair": "black",
"eyes": "black",
"skin": "light",
"outfit": ""
},
"appearance_count": 0,
"first_appearance_panel": "panel_001"
},
{
"id": "char_002",
"name": "Blue Ball",
"role": 0,
"description": "A blue, slightly larger ball with a star pattern and a small dent. It seems to follow the red ball with a sense of purpose.",
"prompt_template": {
"age": "",
"gender": "male",
"hair_color": "red",
"hair_style": "",
"skin_tone": "light",
"eye_color": "blue",
"body_type": "large",
"outfit": "",
"accessories": "",
"distinguishing_features": ""
},
"reference_image_url": "https://v3b.fal.media/files/b/0a9c0614/UfXqbVx2qVOKOuY13ZbeN.jpg",
"character_sheet_urls": [
],
"seed": 401047035,
"color_palette": {
"hair": "red",
"eyes": "blue",
"skin": "light",
"outfit": ""
},
"appearance_count": 0,
"first_appearance_panel": "panel_001"
}
], ],
"panel_images": { "panel_images": {
"panel_001": {
"url": "https://v3b.fal.media/files/b/0a9c0615/3Jl1TM8UmRdrHnD8orOBY.jpg",
"width": 1344,
"height": 768,
"seed": 1843769822,
"prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, ink-wash style. A long, empty street at dusk, with old-fashioned street lamps casting pools of light. Two balls, one red and one blue, sit at the top of a gentle slope. The atmosphere is quiet and slightly eerie, with long shadows stretching across the pavement.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Establishing shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece"
},
"panel_007": {
"url": "https://v3b.fal.media/files/b/0a9c0617/IK_zAvkpLPuuwYh4jEtAu.jpg",
"width": 896,
"height": 1152,
"seed": 654246121,
"prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, intense close-up of the red ball's smiley face. The marker lines seem to twist into a worried expression. A single tear-like smudge appears on the ball. The background is dark, with only a sliver of light.. Characters: male with black hair, black eyes, light skin, build, wearing . Close_Up shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece"
},
"panel_006": {
"url": "https://v3b.fal.media/files/b/0a9c0617/x4mR37Mj9XHaED4Wc25IL.jpg",
"width": 1344,
"height": 768,
"seed": 1843769817,
"prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, dramatic lighting. The balls stop at a storm drain. The red ball teeters on the edge, while the blue ball nudges it gently. The drain is dark and deep, with a faint metallic smell suggested by the sketchy lines.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Wide shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece"
},
"panel_003": {
"url": "https://v3b.fal.media/files/b/0a9c0616/8WnwlSeWALOOZlUUXtB35.jpg",
"width": 896,
"height": 1152,
"seed": 1142705242,
"prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, dynamic lines. The red ball's smiley face is focused, almost anthropomorphic. The ball bounces over a crack, leaving a faint trail of dust. The lighting is low, with the streetlamp creating a strong contrast.. Characters: male with black hair, black eyes, light skin, build, wearing . Close_Up shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece"
},
"panel_009": {
"url": "https://v3b.fal.media/files/b/0a9c0617/YccGhV3Yt1kgNCIfTjDFt.jpg",
"width": 1344,
"height": 768,
"seed": 1355310701,
"prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, bird's-eye view. The red ball rolls into the storm drain, followed by the blue. The drain grate is slightly ajar. The street is empty, with the flickering streetlamp as the only light source.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Aerial shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece"
},
"panel_002": {
"url": "https://v3b.fal.media/files/b/0a9c0616/lLXSWYxVFUhmvOcxfuVoI.jpg",
"width": 1344,
"height": 768,
"seed": 654246116,
"prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, cross-hatched shading. The balls roll side by side, picking up speed. The red ball leads slightly, its smiley face now visible. The blue ball follows, its star pattern catching the light. The street is empty except for a parked car in the background.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Wide shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece"
},
"panel_010": {
"url": "https://v3b.fal.media/files/b/0a9c0618/XWr3AayLIwUr-stSPFDZr.jpg",
"width": 1344,
"height": 768,
"seed": 441640636,
"prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, panoramic view. A wider shot of the street, now silent and still. The storm drain is closed. A single red balloon floats away in the distance. The mood is melancholic and unresolved.. Characters: . Establishing shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece"
},
"panel_008": {
"url": "https://v3b.fal.media/files/b/0a9c0617/pfwQSWsEGG22ezSjEor1S.jpg",
"width": 1024,
"height": 1024,
"seed": 1142705237,
"prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, almost abstract. Extreme close-up of the blue ball's dent, revealing it to be a tiny, stylized 'X' mark. The surface texture is rough, with visible graphite strokes.. Characters: male with red hair, blue eyes, light skin, large build, wearing . Extreme_Close_Up shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece"
},
"panel_005": {
"url": "https://v3b.fal.media/files/b/0a9c0616/ky3eRf3sxaOBGUNWTSZDx.jpg",
"width": 1152,
"height": 896,
"seed": 441640662,
"prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, rough lines. Over the red ball's shoulder, the blue ball is seen rolling past a broken streetlamp that flickers. The light casts an eerie glow on the blue ball's dent.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Over_Shoulder shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece"
},
"panel_004": {
"url": "https://v3b.fal.media/files/b/0a9c0616/46l-wNnIP46lkFUR3SEtp.jpg",
"width": 1344,
"height": 768,
"seed": 1355310696,
"prompt": "pencil sketch style, rough linework, cross-hatching, construction lines, draft aesthetic, monochrome comic panel. Sketchy, loose perspective from above. The balls roll around a corner, their shadows elongated. A street sign points 'Elm St.' but is slightly askew. The neighborhood looks abandoned, with boarded-up windows.. Characters: male with black hair, black eyes, light skin, build, wearing male with red hair, blue eyes, light skin, large build, wearing . Aerial shot. mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist. . high quality, detailed, clean lines, vibrant colors, professional illustration, best quality, masterpiece"
}
}, },
"panel_errors": { "panel_errors": {
}, },
"page_layouts": [ "page_layouts": [
{
"page_number": 1,
"pattern_id": "grid-2x2",
"panels": [
{
"panel_id": "panel_001",
"panel_number": 1,
"layout_cell": {
"x": 0.02000000,
"y": 0.02000000,
"w": 0.47000000,
"h": 0.47000000
}
},
{
"panel_id": "panel_002",
"panel_number": 2,
"layout_cell": {
"x": 0.50999999,
"y": 0.02000000,
"w": 0.47000000,
"h": 0.47000000
}
},
{
"panel_id": "panel_003",
"panel_number": 3,
"layout_cell": {
"x": 0.02000000,
"y": 0.50999999,
"w": 0.47000000,
"h": 0.47000000
}
},
{
"panel_id": "panel_004",
"panel_number": 4,
"layout_cell": {
"x": 0.50999999,
"y": 0.50999999,
"w": 0.47000000,
"h": 0.47000000
}
}
],
"width": 800,
"height": 1280
},
{
"page_number": 2,
"pattern_id": "manga-3-tier",
"panels": [
{
"panel_id": "panel_005",
"panel_number": 5,
"layout_cell": {
"x": 0.02000000,
"y": 0.02000000,
"w": 0.30000001,
"h": 0.30000001
}
},
{
"panel_id": "panel_006",
"panel_number": 1,
"layout_cell": {
"x": 0.34999999,
"y": 0.02000000,
"w": 0.30000001,
"h": 0.30000001
}
},
{
"panel_id": "panel_007",
"panel_number": 2,
"layout_cell": {
"x": 0.68000001,
"y": 0.02000000,
"w": 0.30000001,
"h": 0.30000001
}
},
{
"panel_id": "panel_008",
"panel_number": 3,
"layout_cell": {
"x": 0.02000000,
"y": 0.34999999,
"w": 0.47000000,
"h": 0.30000001
}
},
{
"panel_id": "panel_009",
"panel_number": 4,
"layout_cell": {
"x": 0.50999999,
"y": 0.34999999,
"w": 0.47000000,
"h": 0.30000001
}
},
{
"panel_id": "panel_010",
"panel_number": 5,
"layout_cell": {
"x": 0.02000000,
"y": 0.68000001,
"w": 0.95999998,
"h": 0.30000001
}
}
],
"width": 800,
"height": 1280
}
], ],
"speech_bubbles": { "speech_bubbles": {
}, },
"export_format": 0, "export_format": 0,
"page_size": 0, "page_size": 3,
"color_profile": 0, "color_profile": 0,
"workflow": { "workflow": {
"current_step": 0, "current_step": 5,
"completed_steps": [ "completed_steps": [
], ],

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -281,9 +281,62 @@ deepseek_json_escape :: proc(s: string) -> string {
return string(out[:]) return string(out[:])
} }
SCRIPT_SYSTEM_PROMPT :: `You are an expert comic book writer and storyboard artist.
Your task is to take a user's story idea and transform it into a detailed comic book script optimized for AI image generation.
OUTPUT FORMAT: Return ONLY valid JSON in this exact structure:
{
"title": "Comic Title",
"synopsis": "One-paragraph summary",
"characters": [
{
"id": "char_001",
"name": "Character Name",
"role": "protagonist|antagonist|supporting",
"description": "Detailed visual description for image generation: age, gender, hair color/style, eye color, skin tone, body type, clothing, distinguishing features. Be extremely specific.",
"firstAppearancePanel": "panel_001"
}
],
"pages": [
{
"pageNumber": 1,
"layoutType": "grid|manga|western|action|dialogue|splash",
"panels": [
{
"panelId": "panel_001",
"panelNumber": 1,
"shotType": "establishing|wide|medium|close-up|extreme-close-up|over-shoulder|aerial",
"description": "Detailed scene description for image generation. Include: setting, lighting, mood, character positions, action, camera angle. 2-3 sentences.",
"charactersPresent": ["char_001"],
"dialogue": [
{
"speakerId": "char_001",
"text": "Dialogue text",
"bubbleType": "normal|thought|shout|whisper",
"emotion": "happy|sad|angry|surprised|neutral|determined"
}
],
"caption": "Optional narration caption",
"soundEffects": ["BANG!", "CRASH!"],
"transitionFromPrevious": "none|fade|wipe|dissolve|action-lines"
}
]
}
]
}
RULES:
- 4-8 panels per page for standard pages, 1 panel for splash pages
- Vary shot types across panels for visual interest
- Include at least one establishing shot per scene
- Dialogue should be concise (1-3 lines per bubble)
- Description fields must be IMAGE-GENERATION-READY: vivid, specific, include art style keywords
- Character descriptions must be EXTREMELY detailed and consistent for all panels
- Include mood and lighting keywords in every panel description`
build_deepseek_request_json :: proc(opts: Generate_Script_Options) -> string { build_deepseek_request_json :: proc(opts: Generate_Script_Options) -> string {
user_content := fmt.aprintf( user_content := fmt.aprintf(
"Create a %d-page comic script. Idea: %s. Genre: %s. Art Style: %s. Audience: %s. Return valid JSON.", "Create a %d-page comic script based on this idea: \"%s\".\nGenre: %s\nTarget Art Style: %s (incorporate style keywords into panel descriptions)\nTarget Audience: %s",
opts.num_pages, opts.num_pages,
opts.story_idea, opts.story_idea,
opts.genre, opts.genre,
@ -292,26 +345,15 @@ build_deepseek_request_json :: proc(opts: Generate_Script_Options) -> string {
) )
defer delete(user_content) defer delete(user_content)
messages := [2]Deepseek_Request_Message{ escaped_user := deepseek_json_escape(user_content)
{role = "system", content = "You are an expert comic writer. Return JSON only."}, defer delete(escaped_user)
{role = "user", content = user_content}, escaped_system := deepseek_json_escape(SCRIPT_SYSTEM_PROMPT)
} defer delete(escaped_system)
body := Deepseek_Request_Body{
model = "deepseek-chat",
messages = messages[:],
response_format = Deepseek_Request_Response_Format{type = "json_object"},
temperature = 0.8,
}
request_json, merr := json.marshal(body, {}, context.allocator)
if merr == nil {
return string(request_json)
}
escaped := deepseek_json_escape(user_content)
defer delete(escaped)
return fmt.aprintf( return fmt.aprintf(
"{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an expert comic writer. Return JSON only.\"},{\"role\":\"user\",\"content\":\"%s\"}],\"response_format\":{\"type\":\"json_object\"},\"temperature\":0.8}", "{{\"model\":\"deepseek-chat\",\"messages\":[{{\"role\":\"system\",\"content\":\"%s\"}},{{\"role\":\"user\",\"content\":\"%s\"}}],\"response_format\":{{\"type\":\"json_object\"}},\"temperature\":0.8}",
escaped, escaped_system,
escaped_user,
) )
} }
@ -803,7 +845,7 @@ stream_comic_script :: proc(client: Deepseek_Client, cfg: shared.Config, opts: G
auth := fmt.aprintf("Authorization: Bearer %s", cfg.deepseek_api_key) auth := fmt.aprintf("Authorization: Bearer %s", cfg.deepseek_api_key)
user_content := fmt.aprintf( user_content := fmt.aprintf(
"Create a %d-page comic script. Idea: %s. Genre: %s. Art Style: %s. Audience: %s. Return valid JSON.", "Create a %d-page comic script based on this idea: \"%s\".\nGenre: %s\nTarget Art Style: %s (incorporate style keywords into panel descriptions)\nTarget Audience: %s",
opts.num_pages, opts.num_pages,
opts.story_idea, opts.story_idea,
opts.genre, opts.genre,
@ -812,9 +854,15 @@ stream_comic_script :: proc(client: Deepseek_Client, cfg: shared.Config, opts: G
) )
defer delete(user_content) defer delete(user_content)
escaped_user := deepseek_json_escape(user_content)
defer delete(escaped_user)
escaped_system := deepseek_json_escape(SCRIPT_SYSTEM_PROMPT)
defer delete(escaped_system)
body_json := fmt.aprintf( body_json := fmt.aprintf(
"{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an expert comic writer. Return JSON only.\"},{\"role\":\"user\",\"content\":\"%s\"}],\"response_format\":{\"type\":\"json_object\"},\"temperature\":0.8,\"stream\":true}", "{{\"model\":\"deepseek-chat\",\"messages\":[{{\"role\":\"system\",\"content\":\"%s\"}},{{\"role\":\"user\",\"content\":\"%s\"}}],\"response_format\":{{\"type\":\"json_object\"}},\"temperature\":0.8,\"stream\":true}",
deepseek_json_escape(user_content), escaped_system,
escaped_user,
) )
defer delete(body_json) defer delete(body_json)

View File

@ -138,12 +138,18 @@ render_page_to_image :: proc(
continue continue
} }
// Download/copy panel to temp // Resolve source path: use file:// URLs directly, copy HTTP URLs
ext := file_ext_from_url(img.url) ext := file_ext_from_url(img.url)
panel_tmp := fmt.aprintf("%s/panel_%s%s", temp_dir, p.panel_id, ext) panel_src: string
defer delete(panel_tmp) if strings.has_prefix(img.url, "file://") {
if derr := download_or_copy_panel(img.url, panel_tmp); !shared.is_ok(derr) { panel_src = strings.clone(img.url[7:])
return "", derr } else {
panel_tmp := fmt.aprintf("%s/panel_%s%s", temp_dir, p.panel_id, ext)
defer delete(panel_tmp)
if derr := download_or_copy_panel(img.url, panel_tmp); !shared.is_ok(derr) {
return "", derr
}
panel_src = strings.clone(panel_tmp)
} }
// Calculate pixel position from layout cell fractions // Calculate pixel position from layout cell fractions
@ -160,21 +166,23 @@ render_page_to_image :: proc(
// Resize panel to fit cell // Resize panel to fit cell
resized := fmt.aprintf("%s/panel_%s_resized.png", temp_dir, p.panel_id) resized := fmt.aprintf("%s/panel_%s_resized.png", temp_dir, p.panel_id)
defer delete(resized) defer delete(resized)
resize_cmd: [dynamic]string
append(&resize_cmd, "magick") // Write resize command to a temp shell script to avoid string lifetime issues
append(&resize_cmd, panel_tmp) script_path := fmt.aprintf("%s/resize_%s.sh", temp_dir, p.panel_id)
append(&resize_cmd, "-resize") defer delete(script_path)
append(&resize_cmd, fmt.aprintf("%dx%d!", pw, ph)) size_str := fmt.aprintf("%dx%d!", pw, ph)
append(&resize_cmd, resized) defer delete(size_str)
defer delete(resize_cmd) // Build script content manually
if rerr := run_command(resize_cmd[:]); !shared.is_ok(rerr) { script_lines := strings.concatenate([]string{"#!/bin/sh\nset -e\nmagick \"", panel_src, "\" -resize \"", size_str, "\" \"", resized, "\"\n"})
_ = os.write_entire_file(script_path, transmute([]byte)script_lines)
sh_cmd := []string{"sh", script_path}
if rerr := run_command(sh_cmd[:]); !shared.is_ok(rerr) {
msg := fmt.aprintf("failed to resize panel %s: %s", p.panel_id, rerr.message) msg := fmt.aprintf("failed to resize panel %s: %s", p.panel_id, rerr.message)
err_out := shared.new_error(.Export, msg, true) return "", shared.new_error(.Export, msg, true)
delete(msg)
return "", err_out
} }
append(&entries, Panel_Render_Entry{path = resized, px = px, py = py, pw = pw, ph = ph}) append(&entries, Panel_Render_Entry{path = strings.clone(resized), px = px, py = py, pw = pw, ph = ph})
} }
if len(entries) == 0 { if len(entries) == 0 {
@ -188,9 +196,7 @@ render_page_to_image :: proc(
defer delete(blank_cmd) defer delete(blank_cmd)
if err := run_command(blank_cmd[:]); !shared.is_ok(err) { if err := run_command(blank_cmd[:]); !shared.is_ok(err) {
msg := fmt.aprintf("failed to create blank page: %s", err.message) msg := fmt.aprintf("failed to create blank page: %s", err.message)
err_out := shared.new_error(.Export, msg, true) return "", shared.new_error(.Export, msg, true)
delete(msg)
return "", err_out
} }
result := strings.clone(out_path) result := strings.clone(out_path)
return result, shared.ok() return result, shared.ok()
@ -213,17 +219,29 @@ render_page_to_image :: proc(
append(&composite_cmd, out_path) append(&composite_cmd, out_path)
if err := run_command(composite_cmd[:]); !shared.is_ok(err) { // Write composite command to a temp shell script
comp_script_path := fmt.aprintf("%s/composite_%03d.sh", temp_dir, page_idx)
defer delete(comp_script_path)
comp_script: strings.Builder
strings.write_string(&comp_script, "#!/bin/sh\nset -e\n")
for arg, j in composite_cmd {
if j > 0 { strings.write_string(&comp_script, " ") }
strings.write_string(&comp_script, "\"")
strings.write_string(&comp_script, arg)
strings.write_string(&comp_script, "\"")
}
strings.write_string(&comp_script, "\n")
_ = os.write_entire_file(comp_script_path, transmute([]byte)strings.to_string(comp_script))
sh_comp_cmd := []string{"sh", comp_script_path}
if err := run_command(sh_comp_cmd[:]); !shared.is_ok(err) {
msg := fmt.aprintf("failed to render page %d: %s", page_idx+1, err.message) msg := fmt.aprintf("failed to render page %d: %s", page_idx+1, err.message)
err_out := shared.new_error(.Export, msg, true) return "", shared.new_error(.Export, msg, true)
delete(msg)
return "", err_out
} }
result := strings.clone(out_path) result := strings.clone(out_path)
return result, shared.ok() return result, shared.ok()
} }
stage_panel_images :: proc(temp_dir: string, ordered: []Ordered_Panel, panel_images: map[string]core.Panel_Image) -> shared.App_Error { stage_panel_images :: proc(temp_dir: string, ordered: []Ordered_Panel, panel_images: map[string]core.Panel_Image) -> shared.App_Error {
staged_count := 0 staged_count := 0
for p, idx in ordered { for p, idx in ordered {

View File

@ -7,6 +7,29 @@ import "core:strings"
import "../core" import "../core"
import "../shared" import "../shared"
// Persistent String Pool
@(private)
fal_pool: [64 * 1024]u8
@(private)
fal_pool_offset: int
@(private)
fal_clone :: proc(s: string) -> string {
if len(s) == 0 { return "" }
total := len(s) + 1
if fal_pool_offset + total > len(fal_pool) {
// Pool full fall back to strings.clone (heap)
return strings.clone(s)
}
start := fal_pool_offset
for c, i in s {
fal_pool[start + i] = u8(c)
}
fal_pool[start + len(s)] = 0
fal_pool_offset += total
return string(fal_pool[start:start+len(s)])
}
Fal_Transport :: #type proc(cfg: shared.Config, endpoint: string, prompt: string, negative_prompt: string, seed: i64, image_size: string, reference_images: []string, reference_strength: f32) -> (image_url: string, status_code: int, err: shared.App_Error) Fal_Transport :: #type proc(cfg: shared.Config, endpoint: string, prompt: string, negative_prompt: string, seed: i64, image_size: string, reference_images: []string, reference_strength: f32) -> (image_url: string, status_code: int, err: shared.App_Error)
Fal_Generation_Queue :: struct { Fal_Generation_Queue :: struct {
@ -112,6 +135,27 @@ fal_json_escape :: proc(s: string) -> string {
case '\t': case '\t':
append(&out, '\\') append(&out, '\\')
append(&out, 't') append(&out, 't')
case '\b':
append(&out, '\\')
append(&out, 'b')
case '\f':
append(&out, '\\')
append(&out, 'f')
case '\v':
append(&out, '\\')
append(&out, 'u')
append(&out, '0')
append(&out, '0')
append(&out, '0')
append(&out, 'b')
case 0 ..= 8, 0x0E ..= 0x1F:
append(&out, '\\')
append(&out, 'u')
append(&out, '0')
append(&out, '0')
hex := "0123456789abcdef"
append(&out, hex[u8(c) >> 4])
append(&out, hex[u8(c) & 0x0f])
case: case:
append(&out, u8(c)) append(&out, u8(c))
} }
@ -133,7 +177,7 @@ default_fal_transport :: proc(cfg: shared.Config, endpoint, prompt, negative_pro
append(&ref_items, ',') append(&ref_items, ',')
} }
escaped := fal_json_escape(reference_images[i]) escaped := fal_json_escape(reference_images[i])
item_str := fmt.aprintf("{\"url\":\"%s\"}", escaped) item_str := fmt.aprintf("{{\"url\":\"%s\"}}", escaped)
for b in item_str { for b in item_str {
append(&ref_items, u8(b)) append(&ref_items, u8(b))
} }
@ -154,7 +198,7 @@ default_fal_transport :: proc(cfg: shared.Config, endpoint, prompt, negative_pro
size_json = fmt.aprintf(",\"image_size\":\"%s\"", fal_json_escape(image_size)) size_json = fmt.aprintf(",\"image_size\":\"%s\"", fal_json_escape(image_size))
} }
payload := fmt.aprintf("{\"prompt\":\"%s\"%s%s%s,\"seed\":%d}", fal_json_escape(prompt), neg_json, size_json, ref_json, seed) payload := fmt.aprintf("{{\"prompt\":\"%s\"%s%s%s,\"seed\":%d}}", fal_json_escape(prompt), neg_json, size_json, ref_json, seed)
cmd := [13]string{ cmd := [13]string{
"curl", "-sS", "-X", "POST", url, "curl", "-sS", "-X", "POST", url,
@ -178,7 +222,7 @@ default_fal_transport :: proc(cfg: shared.Config, endpoint, prompt, negative_pro
} }
if status_code >= 400 { if status_code >= 400 {
return "", status_code, shared.ok() return "", status_code, shared.validation_error(fmt.aprintf("fal request failed (%d): %s", status_code, body))
} }
resp, parse_err := fal_parse_response_body(body) resp, parse_err := fal_parse_response_body(body)
@ -218,7 +262,7 @@ fal_backoff_ms :: proc(initial_ms, attempt: int) -> int {
return initial_ms * mul return initial_ms * mul
} }
generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c: core.Character, art_style: string) -> (string, shared.App_Error) { generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c: core.Character, art_style, genre, audience: string) -> (string, shared.App_Error) {
if len(cfg.fal_api_key) == 0 { if len(cfg.fal_api_key) == 0 {
return "", shared.config_error("FAL_API_KEY is missing") return "", shared.config_error("FAL_API_KEY is missing")
} }
@ -232,7 +276,9 @@ generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c:
style_key := core.parse_art_style_key(art_style) style_key := core.parse_art_style_key(art_style)
style_keywords := core.get_style_keywords(style_key) style_keywords := core.get_style_keywords(style_key)
prompt := fmt.aprintf("%s. %s, standing in neutral pose facing camera, character portrait, clean background, studio lighting, white background, front-facing portrait, centered composition. %s", style_keywords, core.build_character_prompt(c, "", "", "", ""), core.QUALITY_MODIFIER) genre_kw := core.get_genre_keywords(genre)
audience_kw := core.get_audience_keywords(audience)
prompt := fmt.aprintf("%s. %s, standing in neutral pose facing camera, character portrait, clean background, studio lighting, white background, front-facing portrait, centered composition. %s. %s. %s", style_keywords, core.build_character_prompt_with_style(c, "", "", "", art_style), genre_kw, audience_kw, core.QUALITY_MODIFIER)
attempts := client.max_retries attempts := client.max_retries
if attempts < 1 { if attempts < 1 {
@ -249,7 +295,7 @@ generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c:
} else if len(url) == 0 { } else if len(url) == 0 {
last_err = shared.generation_error("fal returned empty image url") last_err = shared.generation_error("fal returned empty image url")
} else { } else {
return url, shared.ok() return fal_clone(url), shared.ok()
} }
if attempt < attempts && shared.should_retry(last_err) { if attempt < attempts && shared.should_retry(last_err) {
@ -262,7 +308,7 @@ generate_character_reference :: proc(client: Fal_Client, cfg: shared.Config, c:
return "", last_err return "", last_err
} }
generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id: string) -> (core.Panel_Image, shared.App_Error) { generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id, genre, audience: string) -> (core.Panel_Image, shared.App_Error) {
if len(cfg.fal_api_key) == 0 { if len(cfg.fal_api_key) == 0 {
return core.Panel_Image{}, shared.config_error("FAL_API_KEY is missing") return core.Panel_Image{}, shared.config_error("FAL_API_KEY is missing")
} }
@ -279,6 +325,8 @@ generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core
// Build prompt with art style keywords // Build prompt with art style keywords
style_key := core.parse_art_style_key(art_style) style_key := core.parse_art_style_key(art_style)
style_keywords := core.get_style_keywords(style_key) style_keywords := core.get_style_keywords(style_key)
genre_kw := core.get_genre_keywords(genre)
audience_kw := core.get_audience_keywords(audience)
// Build character descriptions // Build character descriptions
char_desc: [dynamic]u8 char_desc: [dynamic]u8
@ -316,7 +364,7 @@ generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core
} }
defer delete(ref_images) defer delete(ref_images)
prompt := fmt.aprintf("%s comic panel. %s. Characters: %s. %s shot. %s", style_keywords, panel.description, char_str, panel.shot_type, core.QUALITY_MODIFIER) prompt := fmt.aprintf("%s comic panel. %s. Characters: %s. %s shot. %s. %s. %s", style_keywords, panel.description, char_str, panel.shot_type, genre_kw, audience_kw, core.QUALITY_MODIFIER)
attempts := client.max_retries attempts := client.max_retries
if attempts < 1 { if attempts < 1 {
@ -335,7 +383,7 @@ generate_panel_image :: proc(client: Fal_Client, cfg: shared.Config, panel: core
} else { } else {
// Return dimensions from response (we'll use the image_size preset to estimate) // Return dimensions from response (we'll use the image_size preset to estimate)
w, h := dimensions_from_sdxl_size(image_size) w, h := dimensions_from_sdxl_size(image_size)
return core.Panel_Image{url = url, width = w, height = h, seed = seed, prompt = prompt}, shared.ok() return core.Panel_Image{url = fal_clone(url), width = w, height = h, seed = seed, prompt = prompt}, shared.ok()
} }
if attempt < attempts && shared.should_retry(last_err) { if attempt < attempts && shared.should_retry(last_err) {
@ -359,12 +407,12 @@ dimensions_from_sdxl_size :: proc(image_size: string) -> (int, int) {
return 1024, 1024 return 1024, 1024
} }
generate_all_panels_batched :: proc(client: Fal_Client, cfg: shared.Config, panels: []core.Panel, characters: []core.Character, art_style, project_id: string, progress_callback: #type proc(current, total: int) -> ()) -> (map[string]core.Panel_Image, shared.App_Error) { generate_all_panels_batched :: proc(client: Fal_Client, cfg: shared.Config, panels: []core.Panel, characters: []core.Character, art_style, project_id, genre, audience: string, progress_callback: #type proc(current, total: int) -> ()) -> (map[string]core.Panel_Image, shared.App_Error) {
results := make(map[string]core.Panel_Image) results := make(map[string]core.Panel_Image)
total := len(panels) total := len(panels)
for p, idx in panels { for p, idx in panels {
img, err := generate_panel_image(client, cfg, p, characters, art_style, project_id) img, err := generate_panel_image(client, cfg, p, characters, art_style, project_id, genre, audience)
if !shared.is_ok(err) { if !shared.is_ok(err) {
return results, err return results, err
} }
@ -377,10 +425,10 @@ generate_all_panels_batched :: proc(client: Fal_Client, cfg: shared.Config, pane
return results, shared.ok() return results, shared.ok()
} }
generate_character_reference_stub :: proc(cfg: shared.Config, c: core.Character, art_style: string) -> (string, shared.App_Error) { generate_character_reference_stub :: proc(cfg: shared.Config, c: core.Character, art_style, genre, audience: string) -> (string, shared.App_Error) {
q := new_fal_queue(2) q := new_fal_queue(2)
client := new_fal_client(&q) client := new_fal_client(&q)
return generate_character_reference(client, cfg, c, art_style) return generate_character_reference(client, cfg, c, art_style, genre, audience)
} }
Character_Sheet_Pose :: struct { Character_Sheet_Pose :: struct {
@ -395,7 +443,7 @@ CHARACTER_SHEET_POSES :: [4]Character_Sheet_Pose{
Character_Sheet_Pose{name = "back", prompt_suffix = "back view showing hair and outfit from behind"}, Character_Sheet_Pose{name = "back", prompt_suffix = "back view showing hair and outfit from behind"},
} }
generate_character_sheet :: proc(client: Fal_Client, cfg: shared.Config, c: core.Character, art_style: string) -> ([]string, shared.App_Error) { generate_character_sheet :: proc(client: Fal_Client, cfg: shared.Config, c: core.Character, art_style, genre, audience: string) -> ([]string, shared.App_Error) {
if len(cfg.fal_api_key) == 0 { if len(cfg.fal_api_key) == 0 {
return nil, shared.config_error("FAL_API_KEY is missing") return nil, shared.config_error("FAL_API_KEY is missing")
} }
@ -405,6 +453,8 @@ generate_character_sheet :: proc(client: Fal_Client, cfg: shared.Config, c: core
style_key := core.parse_art_style_key(art_style) style_key := core.parse_art_style_key(art_style)
style_keywords := core.get_style_keywords(style_key) style_keywords := core.get_style_keywords(style_key)
genre_kw := core.get_genre_keywords(genre)
audience_kw := core.get_audience_keywords(audience)
sheet_urls: [dynamic]string sheet_urls: [dynamic]string
all_failed := true all_failed := true
@ -416,9 +466,8 @@ generate_character_sheet :: proc(client: Fal_Client, cfg: shared.Config, c: core
pose_seed := c.seed + i64(i) pose_seed := c.seed + i64(i)
// Build prompt for this pose base_desc := core.build_character_prompt_with_style(c, "", "", "", art_style)
base_desc := core.build_character_prompt(c, "", "", "", "") prompt := fmt.aprintf("%s. %s, %s, character sheet, clean background, studio lighting, white background, centered composition. %s. %s. %s", style_keywords, base_desc, pose.prompt_suffix, genre_kw, audience_kw, core.QUALITY_MODIFIER)
prompt := fmt.aprintf("%s. %s, %s, character sheet, clean background, studio lighting, white background, centered composition. %s", style_keywords, base_desc, pose.prompt_suffix, core.QUALITY_MODIFIER)
// Use first image as reference for subsequent poses // Use first image as reference for subsequent poses
ref_images: [dynamic]string ref_images: [dynamic]string
@ -447,14 +496,14 @@ generate_character_sheet :: proc(client: Fal_Client, cfg: shared.Config, c: core
return sheet_urls[:], shared.ok() return sheet_urls[:], shared.ok()
} }
generate_character_sheet_stub :: proc(cfg: shared.Config, c: core.Character, art_style: string) -> ([]string, shared.App_Error) { generate_character_sheet_stub :: proc(cfg: shared.Config, c: core.Character, art_style, genre, audience: string) -> ([]string, shared.App_Error) {
q := new_fal_queue(2) q := new_fal_queue(2)
client := new_fal_client(&q) client := new_fal_client(&q)
return generate_character_sheet(client, cfg, c, art_style) return generate_character_sheet(client, cfg, c, art_style, genre, audience)
} }
generate_panel_image_stub :: proc(cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id: string) -> (core.Panel_Image, shared.App_Error) { generate_panel_image_stub :: proc(cfg: shared.Config, panel: core.Panel, characters: []core.Character, art_style, project_id, genre, audience: string) -> (core.Panel_Image, shared.App_Error) {
q := new_fal_queue(2) q := new_fal_queue(2)
client := new_fal_client(&q) client := new_fal_client(&q)
return generate_panel_image(client, cfg, panel, characters, art_style, project_id) return generate_panel_image(client, cfg, panel, characters, art_style, project_id, genre, audience)
} }

View File

@ -303,12 +303,11 @@ build_local_panel_images :: proc(panels: []core.Panel) -> (map[string]core.Panel
if cerr != nil || !state.exited || state.exit_code != 0 { if cerr != nil || !state.exited || state.exit_code != 0 {
delete(out_path) delete(out_path)
msg := fmt.aprintf("failed to create panel image: %s", string(stderr)) msg := fmt.aprintf("failed to create panel image: %s", string(stderr))
defer delete(msg)
return nil, shared.new_error(.Generation, msg, true) return nil, shared.new_error(.Generation, msg, true)
} }
url := fmt.aprintf("file://%s", out_path) url := strings.clone(fmt.aprintf("file://%s", out_path))
prompt := fmt.aprintf("local panel %d", idx+1) prompt := strings.clone(fmt.aprintf("local panel %d", idx+1))
images[p.panel_id] = core.Panel_Image{url = url, width = gen_w, height = gen_h, seed = i64(idx + 1), prompt = prompt} images[p.panel_id] = core.Panel_Image{url = url, width = gen_w, height = gen_h, seed = i64(idx + 1), prompt = prompt}
delete(out_path) delete(out_path)
} }
@ -889,7 +888,7 @@ run_tui_command :: proc(controller: ^ui.App_Controller, input: string, last_job_
q := adapters.new_fal_queue(2) q := adapters.new_fal_queue(2)
client := adapters.new_fal_client(&q) client := adapters.new_fal_client(&q)
images, gerr := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, controller.state.art_style, controller.state.project.project_id, nil) images, gerr := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, controller.state.art_style, controller.state.project.project_id, controller.state.story_genre, controller.state.target_audience, nil)
if !shared.is_ok(gerr) { if !shared.is_ok(gerr) {
controller.state.workflow.is_generating = false controller.state.workflow.is_generating = false
controller.state.workflow.error_message = gerr.message controller.state.workflow.error_message = gerr.message

View File

@ -206,6 +206,30 @@ extract_color_palette :: proc(description: string) -> Color_Palette {
} }
} }
update_character_from_description :: proc(c: Character) -> Character {
updated := c
template := parse_description_to_template(c.description)
color_palette := extract_color_palette(c.description)
updated.prompt_template = template
updated.color_palette = color_palette
if updated.seed == 0 {
updated.seed = derive_seed_from_string(c.name)
}
return updated
}
update_all_characters_from_descriptions :: proc(characters: []Character) -> []Character {
if len(characters) == 0 {
return characters
}
updated: [dynamic]Character
for c in characters {
append(&updated, update_character_from_description(c))
}
return updated[:]
}
template_to_string :: proc(t: Character_Prompt_Template) -> string { template_to_string :: proc(t: Character_Prompt_Template) -> string {
parts: [dynamic]string parts: [dynamic]string

View File

@ -8,7 +8,7 @@ DEFAULT_PROMPT_BODY :: "average"
DEFAULT_PROMPT_OUTFIT :: "casual clothing" DEFAULT_PROMPT_OUTFIT :: "casual clothing"
DEFAULT_PROMPT_ACCESSORIES :: "no accessories" DEFAULT_PROMPT_ACCESSORIES :: "no accessories"
build_character_prompt :: proc(c: Character, action, setting, lighting, art_style: string) -> string { build_character_prompt :: proc(c: Character, action, setting, lighting: string) -> string {
t := c.prompt_template t := c.prompt_template
age := t.age age := t.age
@ -23,8 +23,7 @@ build_character_prompt :: proc(c: Character, action, setting, lighting, art_styl
if len(accessories) == 0 { accessories = DEFAULT_PROMPT_ACCESSORIES } if len(accessories) == 0 { accessories = DEFAULT_PROMPT_ACCESSORIES }
return fmt.aprintf( return fmt.aprintf(
"%s style. %s-year-old %s, %s %s hair, %s eyes, %s skin, %s build, wearing %s, %s, %s, %s, %s, %s", "%syear-old %s, %s %s hair, %s eyes, %s skin, %s build, wearing %s, %s, %s, %s, %s, %s",
art_style,
age, age,
gender, gender,
t.hair_color, t.hair_color,
@ -41,6 +40,14 @@ build_character_prompt :: proc(c: Character, action, setting, lighting, art_styl
) )
} }
build_character_prompt_with_style :: proc(c: Character, action, setting, lighting, art_style: string) -> string {
base := build_character_prompt(c, action, setting, lighting)
if len(art_style) == 0 {
return base
}
return fmt.aprintf("%s style. %s", art_style, base)
}
derive_seed_from_string :: proc(s: string) -> i64 { derive_seed_from_string :: proc(s: string) -> i64 {
hash: i32 = 0 hash: i32 = 0
for r in s { for r in s {

View File

@ -24,6 +24,63 @@ get_style_keywords :: proc(key: Art_Style_Key) -> string {
return keywords[int(key)] return keywords[int(key)]
} }
Genre_Keyword_Entry :: struct {
key: string,
keywords: string,
}
GENRE_KEYWORDS :: [14]Genre_Keyword_Entry{
{"action", "action-packed, dynamic compositions, explosive motion, intense combat, heroic moments, dramatic clashes"},
{"adventure", "epic adventure, sprawling landscapes, exploration, treasure maps, ancient ruins, journey narrative"},
{"romance", "romantic atmosphere, tender moments, soft lighting, emotional glances, intimate compositions, warm palette"},
{"comedy", "comedic, exaggerated expressions, slapstick poses, funny situations, dynamic sight gags, lively energy"},
{"horror", "horror atmosphere, dark shadows, eerie lighting, unsettling composition, creepy textures, menacing ambiance"},
{"sci-fi", "science fiction, futuristic technology, space environments, advanced gadgets, alien architecture, high-tech aesthetics"},
{"fantasy", "fantasy world, magical effects, mythical creatures, enchanted environments, arcane symbols, ethereal lighting"},
{"mystery", "mystery and intrigue, moody shadows, noir lighting, suspenseful composition, hidden details, fog and mist"},
{"slice of life", "slice of life, everyday warmth, cozy interiors, natural lighting, relatable moments, gentle pacing"},
{"drama", "dramatic tension, emotional intensity, cinematic lighting, powerful expressions, charged atmosphere"},
{"superhero", "superhero, heroic poses, dynamic action, bold colors, iconic costumes, epic scale, power effects"},
{"mecha", "mecha, giant robots, mechanical details, cockpit interiors, metallic surfaces, transformable machines"},
{"thriller", "thriller, tense atmosphere, paranoid compositions, sharp shadows, edge-of-seat framing, cliffhanger moments"},
{"historical", "historical, period-accurate details, classical architecture, period costumes, aged textures, antiqued palette"},
}
get_genre_keywords :: proc(genre: string) -> string {
if len(genre) == 0 { return "" }
lower := strings.to_lower(strings.trim_space(genre))
for entry in GENRE_KEYWORDS {
if strings.contains(lower, entry.key) {
return entry.keywords
}
}
return ""
}
Audience_Keyword_Entry :: struct {
key: string,
keywords: string,
}
AUDIENCE_KEYWORDS :: [5]Audience_Keyword_Entry{
{"children", "child-friendly, bright colors, simple shapes, round features, safe content, age-appropriate, whimsical"},
{"teens", "teen-oriented, expressive characters, relatable themes, modern style, vibrant energy, coming-of-age"},
{"young adult", "young adult, sophisticated storytelling, complex characters, stylish art, mature themes, contemporary feel"},
{"adults", "mature audience, sophisticated composition, nuanced storytelling, detailed artwork, complex themes"},
{"all ages", "all-ages appeal, clean art, universal themes, accessible design, family-friendly, broad appeal"},
}
get_audience_keywords :: proc(audience: string) -> string {
if len(audience) == 0 { return "" }
lower := strings.to_lower(strings.trim_space(audience))
for entry in AUDIENCE_KEYWORDS {
if strings.contains(lower, entry.key) {
return entry.keywords
}
}
return ""
}
parse_art_style_key :: proc(raw: string) -> Art_Style_Key { parse_art_style_key :: proc(raw: string) -> Art_Style_Key {
lower := strings.to_lower(raw) lower := strings.to_lower(raw)
if strings.contains(lower, "manga") { return .Manga } if strings.contains(lower, "manga") { return .Manga }

View File

@ -1,20 +1,22 @@
package core package core
import "core:strings"
new_initial_state :: proc() -> Comic_State { new_initial_state :: proc() -> Comic_State {
iso := "" iso := ""
return Comic_State{ return Comic_State{
project = Project_Metadata{ project = Project_Metadata{
project_id = "proj_todo", project_id = strings.clone("proj_todo"),
project_name = "Untitled Comic", project_name = strings.clone("Untitled Comic"),
created_at_iso = iso, created_at_iso = iso,
last_modified_iso = iso, last_modified_iso = iso,
}, },
user_mode = .Casual, user_mode = .Casual,
story_idea = "", story_idea = "",
story_genre = "action", story_genre = strings.clone("action"),
target_audience = "general", target_audience = strings.clone("general"),
art_style = "manga", art_style = strings.clone("manga"),
export_format = .PDF, export_format = .PDF,
page_size = .A4, page_size = .A4,
color_profile = .RGB, color_profile = .RGB,

View File

@ -27,15 +27,73 @@ action_generate_deepseek_script :: proc(controller: ^ui.App_Controller, pages: i
} }
core.dispose_script(&controller.state.script) core.dispose_script(&controller.state.script)
controller.state.script = script controller.state.script = script
controller.state.characters = controller.state.script.characters
// Auto-parse character descriptions into structured prompt templates
parsed_chars := core.update_all_characters_from_descriptions(script.characters)
controller.state.characters = parsed_chars
controller.active_screen = .Script controller.active_screen = .Script
controller.state.workflow.current_step = .Script_Review controller.state.workflow.current_step = .Script_Review
return "Generated DeepSeek script" return fmt.aprintf("Generated DeepSeek script with %d characters, %d pages", len(parsed_chars), len(script.pages))
}
action_parse_character_descriptions :: proc(controller: ^ui.App_Controller) -> string {
if len(controller.state.characters) == 0 {
return "No characters to parse"
}
updated := core.update_all_characters_from_descriptions(controller.state.characters)
// Dispose old characters array and replace
delete(controller.state.characters)
controller.state.characters = updated
return fmt.aprintf("Parsed %d character descriptions", len(updated))
} }
action_regenerate_panel :: proc(controller: ^ui.App_Controller, panel_id: string) -> string { action_regenerate_panel :: proc(controller: ^ui.App_Controller, panel_id: string) -> string {
_ = controller; _ = panel_id cfg := shared.load_config()
return "Single panel regen not supported; regenerate all panels via FAL" if len(cfg.fal_api_key) == 0 {
return "FAL_API_KEY is missing"
}
target_panel: core.Panel
found := false
for page in controller.state.script.pages {
for p in page.panels {
if p.panel_id == panel_id {
target_panel = p
found = true
break
}
}
if found { break }
}
if !found { return "Panel not found" }
img, err := adapters.generate_panel_image_stub(cfg, target_panel, controller.state.characters, controller.state.art_style, "gui-project", controller.state.story_genre, controller.state.target_audience)
if !shared.is_ok(err) {
return fmt.aprintf("FAL fail: %s", err.message)
}
if controller.state.panel_images == nil {
controller.state.panel_images = make(map[string]core.Panel_Image)
}
// Clone URL to persistent pool to survive frame resets
img.url = pool_clone(img.url)
controller.state.panel_images[panel_id] = img
return fmt.aprintf("Generated panel %s", panel_id)
}
action_update_panel_prompt :: proc(controller: ^ui.App_Controller, panel_id: string, new_desc: string) -> string {
for i in 0..<len(controller.state.script.pages) {
for j in 0..<len(controller.state.script.pages[i].panels) {
if controller.state.script.pages[i].panels[j].panel_id == panel_id {
controller.state.script.pages[i].panels[j].description = new_desc
return "Panel description updated"
}
}
}
return "Panel not found"
} }
action_layout_auto :: proc(controller: ^ui.App_Controller) -> string { action_layout_auto :: proc(controller: ^ui.App_Controller) -> string {
@ -247,11 +305,21 @@ save_project_session_with_message :: proc(project_path: ^string, state: core.Com
} }
action_export :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format) -> string { action_export :: proc(controller: ^ui.App_Controller, export_path: string, export_format: core.Export_Format) -> string {
controller.state.workflow.is_generating = true
controller.state.workflow.generation_progress = 10
opts := adapters.Export_Options{format = export_format, page_size = controller.state.page_size, dpi = 300, quality = 90} opts := adapters.Export_Options{format = export_format, page_size = controller.state.page_size, dpi = 300, quality = 90}
controller.state.workflow.generation_progress = 40
err := adapters.export_comic_stub(export_path, controller.state.page_layouts, controller.state.panel_images, opts) err := adapters.export_comic_stub(export_path, controller.state.page_layouts, controller.state.panel_images, opts)
controller.state.workflow.generation_progress = 90
if !shared.is_ok(err) { if !shared.is_ok(err) {
controller.state.workflow.is_generating = false
return err.message return err.message
} }
controller.state.workflow.generation_progress = 100
controller.state.workflow.is_generating = false
controller.active_screen = .Export controller.active_screen = .Export
controller.state.workflow.current_step = .Complete controller.state.workflow.current_step = .Complete
controller.state.export_format = export_format controller.state.export_format = export_format
@ -306,9 +374,16 @@ run_panels_action :: proc(controller: ^ui.App_Controller, queue: ^adapters.Fal_G
if len(cfg.fal_api_key) == 0 { if len(cfg.fal_api_key) == 0 {
return "FAL_API_KEY not set" return "FAL_API_KEY not set"
} }
// Create default queue if none provided
default_q: adapters.Fal_Generation_Queue
q_ptr := queue
if q_ptr == nil {
default_q = adapters.new_fal_queue(2)
q_ptr = &default_q
}
panels := collect_script_panels(controller.state.script) panels := collect_script_panels(controller.state.script)
client := adapters.new_fal_client(queue) client := adapters.new_fal_client(q_ptr)
images, err := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, "digital art, comic style", "gui-project", nil) images, err := adapters.generate_all_panels_batched(client, cfg, panels, controller.state.characters, controller.state.art_style, "gui-project", controller.state.story_genre, controller.state.target_audience, nil)
if strings.contains(err.message, "error") || strings.contains(err.message, "fail") { if strings.contains(err.message, "error") || strings.contains(err.message, "fail") {
return fmt.tprintf("FAL panels failed: %s", err.message) return fmt.tprintf("FAL panels failed: %s", err.message)
} }
@ -318,8 +393,19 @@ run_panels_action :: proc(controller: ^ui.App_Controller, queue: ^adapters.Fal_G
delete(img.prompt) delete(img.prompt)
} }
delete(controller.state.panel_images) delete(controller.state.panel_images)
controller.state.panel_images = images // Clone URLs to persistent pool before storing
return fmt.tprintf("Generated %d panels via FAL", len(images)) cloned: map[string]core.Panel_Image
for pid, img in images {
cloned[pid] = core.Panel_Image{
url = pool_clone(img.url),
width = img.width,
height = img.height,
seed = img.seed,
prompt = pool_clone(img.prompt),
}
}
controller.state.panel_images = cloned
return fmt.tprintf("Generated %d panels via FAL", len(cloned))
} }
run_layout_action :: proc(controller: ^ui.App_Controller, can_layout: bool, is_dirty: ^bool) -> string { run_layout_action :: proc(controller: ^ui.App_Controller, can_layout: bool, is_dirty: ^bool) -> string {
@ -547,7 +633,7 @@ action_generate_character_reference :: proc(controller: ^ui.App_Controller, char
return "Character not found" return "Character not found"
} }
url, err := adapters.generate_character_reference_stub(cfg, target_char, controller.state.art_style) url, err := adapters.generate_character_reference_stub(cfg, target_char, controller.state.art_style, controller.state.story_genre, controller.state.target_audience)
if !shared.is_ok(err) { if !shared.is_ok(err) {
return err.message return err.message
} }
@ -587,7 +673,7 @@ action_generate_character_sheet :: proc(controller: ^ui.App_Controller, characte
return "Character not found" return "Character not found"
} }
sheet_urls, err := adapters.generate_character_sheet_stub(cfg, target_char, controller.state.art_style) sheet_urls, err := adapters.generate_character_sheet_stub(cfg, target_char, controller.state.art_style, controller.state.story_genre, controller.state.target_audience)
if !shared.is_ok(err) { if !shared.is_ok(err) {
return err.message return err.message
} }
@ -612,6 +698,56 @@ action_generate_character_sheet :: proc(controller: ^ui.App_Controller, characte
return fmt.tprintf("Generated %d-pose sheet for %s", len(sheet_urls), target_char.name) return fmt.tprintf("Generated %d-pose sheet for %s", len(sheet_urls), target_char.name)
} }
action_generate_all_character_refs :: proc(controller: ^ui.App_Controller) -> string {
cfg := shared.load_config()
if len(cfg.fal_api_key) == 0 {
return "FAL_API_KEY is missing"
}
if len(controller.state.characters) == 0 {
return "No characters to generate"
}
count := 0
for c in controller.state.characters {
if len(c.reference_image_url) > 0 { continue }
url, err := adapters.generate_character_reference_stub(cfg, c, controller.state.art_style, controller.state.story_genre, controller.state.target_audience)
if shared.is_ok(err) {
new_chars: [dynamic]core.Character
for ch in controller.state.characters {
nc := ch
if ch.id == c.id { nc.reference_image_url = url }
append(&new_chars, nc)
}
delete(controller.state.characters)
controller.state.characters = new_chars[:]
count += 1
} else {
return fmt.aprintf("Failed on %s: %s", c.name, err.message)
}
}
return fmt.aprintf("Generated %d character references", count)
}
action_update_character_desc :: proc(controller: ^ui.App_Controller, character_id: string, new_desc: string) -> string {
new_chars: [dynamic]core.Character
found := false
for c in controller.state.characters {
new_c := c
if c.id == character_id {
// Free old description? We don't have deep free logic everywhere, but we can assign the new one.
new_c.description = new_desc // Assume new_desc is cloned elsewhere if needed, or just static string inside GUI buffer
found = true
}
append(&new_chars, new_c)
}
if !found {
return "Character not found"
}
delete(controller.state.characters)
controller.state.characters = new_chars[:]
return "Character description updated"
}
action_update_bubble_text :: proc(controller: ^ui.App_Controller, bubble_id: string, new_text: string) -> string { action_update_bubble_text :: proc(controller: ^ui.App_Controller, bubble_id: string, new_text: string) -> string {
if controller.state.speech_bubbles == nil { if controller.state.speech_bubbles == nil {
return "No bubbles to update" return "No bubbles to update"

View File

@ -6,31 +6,29 @@ import "../core"
import "../shared" import "../shared"
import "../ui" import "../ui"
// Sidebar Declaration declare_sidebar :: proc(app: ^GUI_App_State, bp: shared.Breakpoint) {
declare_sidebar :: proc(app: ^GUI_App_State) { collapsed := app.sidebar_collapsed || bp == .Compact
screens := []ui.App_Screen{.Story, .Script, .Characters, .Panels, .Layout, .Bubbles, .Export, .Community} screens := []ui.App_Screen{.Story, .Script, .Characters, .Panels, .Layout, .Bubbles, .Export, .Community}
icons := []string{"1", "2", "3", "4", "5", "6", "7", "8"} icons := []string{"S", "Sc", "Ch", "Pn", "Ly", "Bb", "Ex", "Cm"}
names := []string{"Story", "Script", "Chars", "Panels", "Layout", "Bubbles", "Export", "Commty"} names := []string{"Story", "Script", "Characters", "Panels", "Layout", "Bubbles", "Export", "Community"}
if clay.UI(clay.ID("Sidebar"))({ if collapsed {
layout = {sizing = {width = clay.SizingFixed(220), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = {top = 12, right = 12, bottom = 12, left = 12}, childGap = 2}, if clay.UI(clay.ID("SidebarLogoC"))({
backgroundColor = CLAY_BG_SIDEBAR, layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(36)}, childAlignment = {x = .Center, y = .Center}},
}) { }) {
// Brand clay_title_text("CO", color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM)
clay_body_text("comic-odin", color = CLAY_ACCENT, size = 16) }
clay_muted_text("Pipeline GUI")
// Pipeline progress bar
ready, total := ready_stage_count(app.controller) ready, total := ready_stage_count(app.controller)
progress := f32(0) progress := f32(0)
if total > 0 { progress = f32(ready) / f32(total) } if total > 0 { progress = f32(ready) / f32(total) }
if clay.UI(clay.ID("SidebarProgress"))({ if clay.UI(clay.ID("SidebarProgress"))({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(4)}, layoutDirection = .LeftToRight}, layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(3)}, layoutDirection = .LeftToRight},
backgroundColor = CLAY_PROGRESS_TRACK, backgroundColor = CLAY_PROGRESS_TRACK,
cornerRadius = clay.CornerRadiusAll(2), cornerRadius = clay.CornerRadiusAll(2),
}) { }) {
if progress > 0 { if progress > 0 {
pct := f32(progress * 100); if pct > 100 { pct = 100 } pct := f32(progress); if pct > 1 { pct = 1 }
if clay.UI(clay.ID("SidebarPgFill"))({ if clay.UI(clay.ID("SidebarPgFill"))({
layout = {sizing = {width = clay.SizingPercent(pct), height = clay.SizingGrow({})}}, layout = {sizing = {width = clay.SizingPercent(pct), height = clay.SizingGrow({})}},
backgroundColor = CLAY_PROGRESS_FILL, backgroundColor = CLAY_PROGRESS_FILL,
@ -38,64 +36,133 @@ declare_sidebar :: proc(app: ^GUI_App_State) {
}) {} }) {}
} }
} }
clay_muted_text(fmt.tprintf("%d/4 done", ready))
// Navigation declare_divider("SidebarDividerTop")
for i in 0 ..< len(screens) { for i in 0 ..< len(screens) {
is_active := app.controller.active_screen == screens[i] is_active := app.controller.active_screen == screens[i]
bg := CLAY_NAV_HOVER_BG is_hov := clay.Hovered()
bg := clay.Color{0, 0, 0, 0}
if is_active { bg = CLAY_NAV_ACTIVE_BG } if is_active { bg = CLAY_NAV_ACTIVE_BG }
accent_w: u16 = 0 else if is_hov { bg = CLAY_NAV_HOVER_BG }
accent_c: clay.Color = {0, 0, 0, 0}
if is_active { accent_w = 3; accent_c = CLAY_NAV_ACTIVE }
if clay.UI(clay.ID("Nav", u32(i)))({ if clay.UI(clay.ID("Nav", u32(i)))({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(34)}, padding = {top = 4, right = 8, bottom = 4, left = 8}, childAlignment = {y = .Center}, layoutDirection = .LeftToRight, childGap = 6}, layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(36)}, padding = {top = 4, right = 4, bottom = 4, left = 4}, childAlignment = {x = .Center, y = .Center}, layoutDirection = .LeftToRight},
backgroundColor = bg, backgroundColor = bg,
cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM),
border = {color = accent_c, width = {accent_w, 0, 0, 0, 0}},
}) { }) {
text_color: clay.Color = CLAY_TEXT_SECONDARY text_color: clay.Color = CLAY_TEXT_TERTIARY
if is_active { text_color = CLAY_TEXT_BRIGHT } if is_active { text_color = CLAY_ACCENT }
clay_body_text(fmt.tprintf("%s %s", icons[i], names[i]), color = text_color) else if is_hov { text_color = CLAY_TEXT_PRIMARY }
clay_body_text(icons[i], color = text_color, size = CLAY_FONT_SIZE_XS)
} }
} }
// Spacer
gap_spacer := clay.UI(clay.ID("SidebarGap")) gap_spacer := clay.UI(clay.ID("SidebarGap"))
if gap_spacer({ if gap_spacer({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}, layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}},
}) {} }) {}
declare_button_small("btn_help", "?")
return
}
// Project name + help if clay.UI(clay.ID("SidebarLogo"))({
if clay.UI(clay.ID("SidebarFooter"))({ layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(40)}, childAlignment = {y = .Center}, layoutDirection = .LeftToRight, childGap = 8},
layout = {layoutDirection = .LeftToRight, childAlignment = {y = .Center}, childGap = 4}, }) {
}) { clay_title_text("comic-odin", color = CLAY_TEXT_BRIGHT, size = CLAY_FONT_SIZE_MD)
proj_name := app.project_path }
if len(proj_name) > 18 { proj_name = fmt.tprintf("...%s", proj_name[len(proj_name)-15:]) }
clay_muted_text(proj_name) ready, total := ready_stage_count(app.controller)
declare_button_small("btn_help", "?") progress := f32(0)
if total > 0 { progress = f32(ready) / f32(total) }
if clay.UI(clay.ID("SidebarProgress"))({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(3)}, layoutDirection = .LeftToRight},
backgroundColor = CLAY_PROGRESS_TRACK,
cornerRadius = clay.CornerRadiusAll(2),
}) {
if progress > 0 {
pct := f32(progress); if pct > 1 { pct = 1 }
if clay.UI(clay.ID("SidebarPgFill"))({
layout = {sizing = {width = clay.SizingPercent(pct), height = clay.SizingGrow({})}},
backgroundColor = CLAY_PROGRESS_FILL,
cornerRadius = clay.CornerRadiusAll(2),
}) {}
} }
} }
if clay.UI(clay.ID("SidebarProgressLabel"))({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(20)}, childAlignment = {y = .Center}, layoutDirection = .LeftToRight, childGap = 4},
}) {
clay_body_text(fmt.tprintf("%d of 4 done", ready), color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS)
}
declare_divider("SidebarDivider1")
if clay.UI(clay.ID("SidebarNavSection"))({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, layoutDirection = .TopToBottom, childGap = 2},
}) {
clay_label_text("WORKSPACE")
for i in 0 ..< len(screens) {
is_active := app.controller.active_screen == screens[i]
is_hov := clay.Hovered()
bg := clay.Color{0, 0, 0, 0}
if is_active { bg = CLAY_NAV_ACTIVE_BG }
else if is_hov { bg = CLAY_NAV_HOVER_BG }
if clay.UI(clay.ID("Nav", u32(i)))({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(34)}, padding = {top = 4, right = 10, bottom = 4, left = 10}, childAlignment = {y = .Center}, layoutDirection = .LeftToRight, childGap = 8},
backgroundColor = bg,
cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM),
}) {
if clay.UI(clay.ID(fmt.tprintf("NavIcon%d", i)))({
layout = {sizing = {width = clay.SizingFixed(24), height = clay.SizingFixed(24)}, childAlignment = {x = .Center, y = .Center}},
backgroundColor = CLAY_ACCENT_SURFACE if is_active else clay.Color{0, 0, 0, 0},
cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_XS),
}) {
icon_color := CLAY_TEXT_TERTIARY
if is_active { icon_color = CLAY_ACCENT }
clay.Text(icons[i], {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_XS, textColor = icon_color})
}
text_color: clay.Color = CLAY_TEXT_SECONDARY
if is_active { text_color = CLAY_TEXT_BRIGHT }
else if is_hov { text_color = CLAY_TEXT_PRIMARY }
clay_body_text(names[i], color = text_color, size = CLAY_FONT_SIZE_SM)
}
}
}
gap_spacer := clay.UI(clay.ID("SidebarGap"))
if gap_spacer({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}},
}) {}
declare_button_small("btn_toggle_sidebar", "Collapse")
clay_body_text("Ctrl+B", color = CLAY_TEXT_DISABLED, size = CLAY_FONT_SIZE_XS)
declare_divider("SidebarDivider2")
if clay.UI(clay.ID("SidebarFooter"))({
layout = {layoutDirection = .LeftToRight, sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({})}, childAlignment = {y = .Center}, childGap = 6},
}) {
proj_name := app.project_path
if len(proj_name) > 18 { proj_name = fmt.tprintf("...%s", proj_name[len(proj_name)-15:]) }
clay_body_text(proj_name, color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS)
declare_button_small("btn_help", "?")
}
} }
// Pipeline Bar declare_pipeline_bar :: proc(app: ^GUI_App_State, bp: shared.Breakpoint) {
// Pipeline Bar
declare_pipeline_bar :: proc(app: ^GUI_App_State) {
if clay.UI(clay.ID("PipelineBar"))({ if clay.UI(clay.ID("PipelineBar"))({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({min = 54, max = 48})}, padding = {top = 8, right = 16, bottom = 8, left = 16}, childGap = 12, childAlignment = {y = .Center}, layoutDirection = .LeftToRight}, layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({min = 50, max = 56})}, padding = {top = 8, right = 20, bottom = 8, left = 20}, childGap = 16, childAlignment = {y = .Center}, layoutDirection = .LeftToRight},
backgroundColor = CLAY_BG_TOPBAR, backgroundColor = CLAY_BG_TOPBAR,
border = {color = CLAY_BORDER_DIVIDER, width = {0, 0, 0, 1, 0}}, border = {color = CLAY_BORDER_DIVIDER, width = {0, 0, 1, 0, 0}},
}) { }) {
// Screen name
if clay.UI(clay.ID("PipelineTitle"))({ if clay.UI(clay.ID("PipelineTitle"))({
layout = {sizing = {width = clay.SizingFixed(110)}}, layout = {sizing = {width = clay.SizingFixed(120)}},
}) { }) {
clay_title_text(ui.screen_name(app.controller.active_screen), size = CLAY_FONT_SIZE_LG) clay_title_text(ui.screen_name(app.controller.active_screen), size = CLAY_FONT_SIZE_LG)
} }
// Pipeline stepper
script_ok := len(app.controller.state.script.pages) > 0 script_ok := len(app.controller.state.script.pages) > 0
panels_ok := len(app.controller.state.panel_images) > 0 panels_ok := len(app.controller.state.panel_images) > 0
layout_ok := len(app.controller.state.page_layouts) > 0 layout_ok := len(app.controller.state.page_layouts) > 0
@ -107,54 +174,61 @@ declare_pipeline_bar :: proc(app: ^GUI_App_State) {
{"Layout", layout_ok}, {"Layout", layout_ok},
{"Export", export_ok}, {"Export", export_ok},
} }
for i in 0 ..< len(steps) {
var_name := steps[i].name
is_current := (i == 0 && !script_ok) || (i == 1 && script_ok && !panels_ok) || (i == 2 && panels_ok && !layout_ok) || (i == 3 && layout_ok && !export_ok) || (i == 3 && export_ok)
circle_color: clay.Color = CLAY_BTN_DISABLED
if steps[i].done { circle_color = CLAY_SUCCESS }
if is_current && !steps[i].done { circle_color = CLAY_ACCENT }
if clay.UI(clay.ID("PStep", u32(i)))({ if clay.UI(clay.ID("PipelineSteps"))({
layout = {sizing = {width = clay.SizingFixed(84), height = clay.SizingFixed(36)}, layoutDirection = .TopToBottom, childAlignment = {x = .Center}, childGap = 1}, layout = {layoutDirection = .LeftToRight, childGap = 0, childAlignment = {y = .Center}, sizing = {width = clay.SizingFit({}), height = clay.SizingFit({})}},
backgroundColor = CLAY_NAV_HOVER_BG if clay.Hovered() else clay.Color{0, 0, 0, 0}, }) {
cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM), for i in 0 ..< len(steps) {
}) { is_current := (i == 0 && !script_ok) || (i == 1 && script_ok && !panels_ok) || (i == 2 && panels_ok && !layout_ok) || (i == 3 && layout_ok && !export_ok) || (i == 3 && export_ok)
mark := "○" is_hov := clay.Hovered()
mark_color: clay.Color = CLAY_TEXT_TERTIARY bg := clay.Color{0, 0, 0, 0}
if steps[i].done { mark = "●"; mark_color = CLAY_SUCCESS } if is_hov { bg = CLAY_NAV_HOVER_BG }
if is_current && !steps[i].done { mark_color = CLAY_ACCENT }
clay.Text(mark, {fontId = CLAY_FONT_BODY, fontSize = 14, textColor = mark_color}) if clay.UI(clay.ID("PStep", u32(i)))({
clay.Text(var_name, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_SM, textColor = mark_color}) layout = {sizing = {width = clay.SizingFixed(72), height = clay.SizingFixed(32)}, layoutDirection = .TopToBottom, childAlignment = {x = .Center}, childGap = 2},
} backgroundColor = bg,
if i < len(steps) - 1 { cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM),
line_color: clay.Color = clay.Color{255, 255, 255, 20} }) {
if steps[i].done { line_color = CLAY_SUCCESS } dot_color: clay.Color = CLAY_TEXT_DISABLED
if clay.UI(clay.ID("PLine", u32(i)))({ if steps[i].done { dot_color = CLAY_SUCCESS }
layout = {sizing = {width = clay.SizingFixed(12), height = clay.SizingFixed(1)}}, if is_current && !steps[i].done { dot_color = CLAY_ACCENT }
backgroundColor = line_color,
}) {} if clay.UI(clay.ID(fmt.tprintf("PStepDot%d", i)))({
layout = {sizing = {width = clay.SizingFixed(8), height = clay.SizingFixed(8)}},
backgroundColor = dot_color,
cornerRadius = clay.CornerRadiusAll(4),
}) {}
clay.Text(steps[i].name, {fontId = CLAY_FONT_BODY, fontSize = CLAY_FONT_SIZE_XS, textColor = dot_color})
}
if i < len(steps) - 1 {
line_color: clay.Color = CLAY_BORDER_SUBTLE
if steps[i].done { line_color = CLAY_SUCCESS }
if clay.UI(clay.ID("PLine", u32(i)))({
layout = {sizing = {width = clay.SizingFixed(16), height = clay.SizingFixed(2)}},
backgroundColor = line_color,
cornerRadius = clay.CornerRadiusAll(1),
}) {}
}
} }
} }
// Spacer
pspacer := clay.UI(clay.ID("PBarSpacer")) pspacer := clay.UI(clay.ID("PBarSpacer"))
if pspacer({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) {} if pspacer({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) {}
// Pipeline progress count
ready, total := ready_stage_count(app.controller) ready, total := ready_stage_count(app.controller)
if clay.UI(clay.ID("PBarProgress"))({ if clay.UI(clay.ID("PBarProgress"))({
layout = {layoutDirection = .LeftToRight, childAlignment = {y = .Center}, childGap = 4}, layout = {layoutDirection = .LeftToRight, childAlignment = {y = .Center}, childGap = 8},
}) { }) {
clay_body_text(fmt.tprintf("%d/%d", ready, total), color = CLAY_ACCENT, size = CLAY_FONT_SIZE_SM)
progress := f32(0) progress := f32(0)
if total > 0 { progress = f32(ready) / f32(total) } if total > 0 { progress = f32(ready) / f32(total) }
if progress > 0 && progress <= 100 { if clay.UI(clay.ID("PBarMinibar"))({
pct := f32(progress * 100); if pct > 100 { pct = 100 } layout = {sizing = {width = clay.SizingFixed(64), height = clay.SizingFixed(4)}, layoutDirection = .LeftToRight},
if clay.UI(clay.ID("PBarMinibar"))({ backgroundColor = CLAY_PROGRESS_TRACK,
layout = {sizing = {width = clay.SizingFixed(60), height = clay.SizingFixed(4)}, layoutDirection = .LeftToRight}, cornerRadius = clay.CornerRadiusAll(2),
backgroundColor = CLAY_PROGRESS_TRACK, }) {
cornerRadius = clay.CornerRadiusAll(2), pct := f32(progress); if pct > 1 { pct = 1 }
}) { if pct > 0 {
if clay.UI(clay.ID("PBarMinibarFill"))({ if clay.UI(clay.ID("PBarMinibarFill"))({
layout = {sizing = {width = clay.SizingPercent(pct), height = clay.SizingGrow({})}}, layout = {sizing = {width = clay.SizingPercent(pct), height = clay.SizingGrow({})}},
backgroundColor = CLAY_PROGRESS_FILL, backgroundColor = CLAY_PROGRESS_FILL,
@ -162,28 +236,30 @@ declare_pipeline_bar :: proc(app: ^GUI_App_State) {
}) {} }) {}
} }
} }
clay_body_text(fmt.tprintf("%d/%d", ready, total), color = CLAY_TEXT_TERTIARY, size = CLAY_FONT_SIZE_XS)
} }
// Status message (truncated) is_casual := app.controller.state.user_mode == .Casual
clay_body_text(fmt.tprintf("C:%d P:%d", len(app.controller.state.characters), len(app.controller.state.panel_images)), color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) if clay.UI(clay.ID("ModeToggleRow"))({
msg := app.status_msg layout = {layoutDirection = .LeftToRight, childGap = 2, padding = {top = 2, right = 2, bottom = 2, left = 2}},
if len(msg) > 40 { msg = fmt.tprintf("%s...", msg[:37]) } backgroundColor = CLAY_BG_STRIP,
clay_body_text(msg, color = CLAY_TEXT_SECONDARY, size = CLAY_FONT_SIZE_SM) cornerRadius = clay.CornerRadiusAll(CLAY_RADIUS_SM),
}) {
declare_nav_chip("btn_mode_casual", "Casual", is_casual)
declare_nav_chip("btn_mode_pro", "Pro", !is_casual)
}
} }
} }
// Workspace Declaration declare_workspace :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export, proj_ok, export_ok, has_deepseek: bool, pages_count: int, bp: shared.Breakpoint, main_w: i32) {
// Workspace Declaration
declare_workspace :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export, proj_ok, export_ok, has_deepseek: bool, pages_count: int) {
screen := app.controller.active_screen screen := app.controller.active_screen
if clay.UI(clay.ID("Workspace"))({ if clay.UI(clay.ID("Workspace"))({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = {top = 12, right = 16, bottom = 12, left = 16}, childGap = 8}, layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}, layoutDirection = .TopToBottom, padding = {top = CLAY_SPACE_12, right = CLAY_SPACE_20, bottom = CLAY_SPACE_12, left = CLAY_SPACE_20}, childGap = CLAY_SPACE_12},
}) { }) {
switch screen { switch screen {
case .Story: case .Story:
declare_story_workspace(app) declare_story_workspace(app, bp)
declare_action_log(app) declare_action_log(app)
case .Script: case .Script:
declare_script_workspace(app) declare_script_workspace(app)
@ -206,33 +282,25 @@ declare_workspace :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_e
} }
} }
// Script Workspace declare_bottom_bar :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export: bool, next_hint: string, has_fal_key: bool, bp: shared.Breakpoint) {
// Bottom Bar
declare_bottom_bar :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_export: bool, next_hint: string, has_fal_key: bool) {
if clay.UI(clay.ID("BottomBar"))({ if clay.UI(clay.ID("BottomBar"))({
layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({min = 50, max = 44})}, padding = {top = 6, right = 12, bottom = 6, left = 12}, childGap = 8, childAlignment = {y = .Center}, layoutDirection = .LeftToRight}, layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingFit({min = 48, max = 52})}, padding = {top = 6, right = 16, bottom = 6, left = 16}, childGap = 6, childAlignment = {y = .Center}, layoutDirection = .LeftToRight},
backgroundColor = CLAY_BG_TOPBAR, backgroundColor = CLAY_BG_TOPBAR,
border = {color = CLAY_BORDER_DIVIDER, width = {0, 0, 1, 0, 0}}, border = {color = CLAY_BORDER_DIVIDER, width = {0, 0, 1, 0, 0}},
}) { }) {
// Left: File ops declare_button_default("btn_new", "New")
declare_button_danger("btn_new", "New") declare_button_default("btn_open", "Open")
declare_button_soft("btn_open", "Open") declare_button_default("btn_save", "Save")
declare_button_soft("btn_save", "Save")
// Spacer
bbspacer1 := clay.UI(clay.ID("BBSpacer1")) bbspacer1 := clay.UI(clay.ID("BBSpacer1"))
if bbspacer1({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) {} if bbspacer1({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) {}
// Center: Primary CTA
cta_label := fmt.tprintf("Next: %s", next_hint) cta_label := fmt.tprintf("Next: %s", next_hint)
declare_button_recommended("btn_next", cta_label) declare_button_recommended("btn_next", cta_label)
// Spacer
bbspacer2 := clay.UI(clay.ID("BBSpacer2")) bbspacer2 := clay.UI(clay.ID("BBSpacer2"))
if bbspacer2({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) {} if bbspacer2({layout = {sizing = {width = clay.SizingGrow({}), height = clay.SizingGrow({})}}}) {}
// Right: quick actions
if has_fal_key { if has_fal_key {
declare_nav_chip("btn_fal_panels", "FAL", app.use_fal_panels) declare_nav_chip("btn_fal_panels", "FAL", app.use_fal_panels)
} }
@ -241,9 +309,7 @@ declare_bottom_bar :: proc(app: ^GUI_App_State, can_gen_panels, can_layout, can_
declare_button_small("btn_layout", "Layout") declare_button_small("btn_layout", "Layout")
declare_button_small("btn_export", "Export") declare_button_small("btn_export", "Export")
declare_button_primary("btn_auto", "Auto-All") declare_button_primary("btn_auto", "Auto-All")
declare_button_soft("btn_autosave_toggle", declare_button_ghost("btn_autosave_toggle",
fmt.tprintf("%s", "ON" if app.autosave_enabled else "OFF")) fmt.tprintf("%s", "ON" if app.autosave_enabled else "OFF"))
} }
} }
// Click Processing

Some files were not shown because too many files have changed in this diff Show More