current state
88
AGENTS.md
Normal 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 0–1, not 0–100.** `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
odin/assets/2gcegsLrN8TD4j03CyxLo.jpg
Normal file
|
After Width: | Height: | Size: 389 KiB |
BIN
odin/assets/2pEjv5xq_W3sLl_eBKXt5.jpg
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
odin/assets/3Jl1TM8UmRdrHnD8orOBY.jpg
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
odin/assets/46l-wNnIP46lkFUR3SEtp.jpg
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
odin/assets/55G16W3E5x8x73czZLob8.jpg
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
odin/assets/6U3oQ9eoLsNeGHCCnbx52.jpg
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
odin/assets/8WnwlSeWALOOZlUUXtB35.jpg
Normal file
|
After Width: | Height: | Size: 417 KiB |
BIN
odin/assets/9A2y5RiOc-4-CDb7FDX-z.jpg
Normal file
|
After Width: | Height: | Size: 626 KiB |
BIN
odin/assets/Bdv2I8wkmplzmUHH3uiwU.jpg
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
odin/assets/Bod0ehqYOHm7IIM_8TrsN.jpg
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
odin/assets/CM_buwj12cDqtePWooe79.jpg
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
odin/assets/DZ5gMnv66X5-QwnG11lYe.jpg
Normal file
|
After Width: | Height: | Size: 357 KiB |
BIN
odin/assets/Dz6fu_IwR0-tvOTWO7ccW.jpg
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
odin/assets/EX8QU_eu27eeV3Oalyerw.jpg
Normal file
|
After Width: | Height: | Size: 380 KiB |
BIN
odin/assets/F3y1b1f3EzzPpXYI6DeY1.jpg
Normal file
|
After Width: | Height: | Size: 271 KiB |
BIN
odin/assets/FJVw0TPgGJSyB9GcHi9RZ.jpg
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
odin/assets/GYgTd1B7TcieGrp5TGaii.jpg
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
odin/assets/HWqLtvvJLcNqpasvlUq_K.jpg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
odin/assets/IK_zAvkpLPuuwYh4jEtAu.jpg
Normal file
|
After Width: | Height: | Size: 439 KiB |
BIN
odin/assets/J_vkLN0IPDteP6le9gCwa.jpg
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
odin/assets/K09uo89t9OA_Ac5-03T60.jpg
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
odin/assets/KbiWO4HRdHmFc7RVk8tz3.jpg
Normal file
|
After Width: | Height: | Size: 596 KiB |
BIN
odin/assets/KoX2HMxt-NbNus2jzUJhk.jpg
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
odin/assets/L8rCWVxqZoeMA-v4INxF9.jpg
Normal file
|
After Width: | Height: | Size: 405 KiB |
BIN
odin/assets/LdVN2fUfluDU8AnEnhVdO.jpg
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
odin/assets/M18H8gzQr78AyVbHqQRvp.jpg
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
odin/assets/MIB7_CUoSOtcWkGVDZMNf.jpg
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
odin/assets/OjM7dpUq_b5WV1jnOKTx-.jpg
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
odin/assets/PKUrqw26x0bictBGfKUoo.jpg
Normal file
|
After Width: | Height: | Size: 297 KiB |
BIN
odin/assets/PMnRpKSmJeINBDsLRuxDw.jpg
Normal file
|
After Width: | Height: | Size: 422 KiB |
BIN
odin/assets/PgqOdRQcUaAEskxy7SoU3.jpg
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
odin/assets/QRzzHh3Z_lwzambb0_Gdy.jpg
Normal file
|
After Width: | Height: | Size: 269 KiB |
BIN
odin/assets/QgVb2D2EAkF46XgyNG05i.jpg
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
odin/assets/RTuYLiKv-X7NCeXpD6fER.jpg
Normal file
|
After Width: | Height: | Size: 425 KiB |
BIN
odin/assets/SFUWZB370vG5UR_7E4QZx.jpg
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
odin/assets/T49RL0lKhmhAKsM8JX0rd.jpg
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
odin/assets/UUd1kSUH9UPmJWzYmX21U.jpg
Normal file
|
After Width: | Height: | Size: 348 KiB |
BIN
odin/assets/UeOTNnJLlD1l1FqEQ_E8d.jpg
Normal file
|
After Width: | Height: | Size: 295 KiB |
BIN
odin/assets/VG0prZpMFnkfgYlrL4iZD.jpg
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
odin/assets/W9Mhstcb_7ka6_YI9ZiTT.jpg
Normal file
|
After Width: | Height: | Size: 332 KiB |
BIN
odin/assets/XWr3AayLIwUr-stSPFDZr.jpg
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
odin/assets/YccGhV3Yt1kgNCIfTjDFt.jpg
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
odin/assets/Z3cMhd91v6ThsQl_Vn-ph.jpg
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
odin/assets/be1K0ROeKdOueIMNXdApi.jpg
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
odin/assets/c_SZNcI8g7wJpBniBANi_.jpg
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
odin/assets/d8XYp2Yyb5uSK70aY_i5L.jpg
Normal file
|
After Width: | Height: | Size: 266 KiB |
BIN
odin/assets/dBgJnQBWa_5KrmRhwPzfy.jpg
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
odin/assets/df6YUrSA3Rey-HoC5_VE8.jpg
Normal file
|
After Width: | Height: | Size: 422 KiB |
BIN
odin/assets/dlK6N_EWhA1pyEvglDhvN.jpg
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
odin/assets/fTsq0Gg_P9LY2U0JNB4am.jpg
Normal file
|
After Width: | Height: | Size: 407 KiB |
BIN
odin/assets/flivfVOq4rymbyzGPRjum.jpg
Normal file
|
After Width: | Height: | Size: 413 KiB |
BIN
odin/assets/fonts/AdwaitaMono-Regular.ttf
Normal file
BIN
odin/assets/fonts/AdwaitaSans-Regular.ttf
Normal file
BIN
odin/assets/hQPWF8gu4b6zhG-MibArE.jpg
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
odin/assets/hk_V5i0kctqjcr-eeMzOg.jpg
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
odin/assets/hwU3YX9GljfeCVCPq84yI.jpg
Normal file
|
After Width: | Height: | Size: 292 KiB |
BIN
odin/assets/i2RsodqUYTIMPzONyEYCx.jpg
Normal file
|
After Width: | Height: | Size: 385 KiB |
BIN
odin/assets/izlV0OeemZJIArgzJkvY7.jpg
Normal file
|
After Width: | Height: | Size: 443 KiB |
BIN
odin/assets/jTMceH-aye5tdqVL5ULqe.jpg
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
odin/assets/jWjNSjl862ZHNsBII6PK0.jpg
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
odin/assets/kTupPN30lFeuPRVwWELwI.jpg
Normal file
|
After Width: | Height: | Size: 271 KiB |
BIN
odin/assets/ky3eRf3sxaOBGUNWTSZDx.jpg
Normal file
|
After Width: | Height: | Size: 362 KiB |
BIN
odin/assets/lLXSWYxVFUhmvOcxfuVoI.jpg
Normal file
|
After Width: | Height: | Size: 314 KiB |
BIN
odin/assets/n7KVB2LeoAIEmtQMbnOjp.jpg
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
odin/assets/naOK8Gd8PESgsQaHvP2xx.jpg
Normal file
|
After Width: | Height: | Size: 379 KiB |
BIN
odin/assets/p2RXMpWkJE9u2T2nz-oJ6.jpg
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
odin/assets/p_aV09rkFmmsTQLQmHX6I.jpg
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
odin/assets/pfwQSWsEGG22ezSjEor1S.jpg
Normal file
|
After Width: | Height: | Size: 481 KiB |
BIN
odin/assets/qWmJ1EcBuGyIkWc2Cry4V.jpg
Normal file
|
After Width: | Height: | Size: 278 KiB |
BIN
odin/assets/qspLI2_R3yS_PdSI_hD7T.jpg
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
odin/assets/shhHdWzvEUXFEFa0WpBUb.jpg
Normal file
|
After Width: | Height: | Size: 313 KiB |
BIN
odin/assets/sydqHbpgUP3AbEB2KRIe1.jpg
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
odin/assets/tY5opo8r3AHg7D1PVClJz.jpg
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
odin/assets/uNO4KmIsLeN9asYlpG1aW.jpg
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
odin/assets/vr0d18GJMCxRnnCBFWKU0.jpg
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
odin/assets/wN1Dhg_HnvJ0oU5xC86UU.jpg
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
odin/assets/wUA9Y90MDARwLJQMKM_iE.jpg
Normal file
|
After Width: | Height: | Size: 309 KiB |
BIN
odin/assets/ww-HB0SrT8-O-nQzmfHZY.jpg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
odin/assets/x4mR37Mj9XHaED4Wc25IL.jpg
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
odin/assets/y7m7vcnEIFC8hj8KmDMN_.jpg
Normal file
|
After Width: | Height: | Size: 315 KiB |
BIN
odin/assets/yIuc7V6dQYmvn0c6Ej2ms.jpg
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
odin/comic.pdf
@ -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
|
||||||
@ -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": [
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|||||||
BIN
odin/quick loic-cli-quick-1186622491/quick.pdP
Normal file
|
After Width: | Height: | Size: 16 KiB |
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 ──────────────────────────────────────────────
|
|
||||||
|
|||||||