Compare commits

...
57 Commits
Author SHA1 Message Date
TowstyandCursor 45e97cc1c3 Add Prompt Builder sequence section order.
Copied scene text and the scene editor follow a persisted sectionOrder, with legacy saves keeping the default style/settings/set/characters/prompt order.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 07:07:43 -05:00
TowstyandCursor b6eef6a511 Replace Prompt Builder beats with complete set framings.
Sets are places with labeled full-text framings; selecting a framing copies only that text, and legacy beat-only sets migrate to a wide framing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 22:28:44 -05:00
Towsty d803d57a78 Fix bench draft structuredClone 500 and Prompt Builder button contrast.
Draft snapshots use plain JSON of picked fields with restore try/catch that clears bad storage; Prompt Builder actions use amber primary/secondary styles with 44px tap targets.
2026-10-03 19:38:55 -05:00
Towsty a8ae61704e Add Prompt Builder sets/framing and harden mobile draft look settings.
Sets and beats filter by shot size; scenes reorder/delete; portrait links Prompt Builder; landscape queue collapses; per-engine look settings and prompt Clear persist across iOS resumes.
2026-10-03 19:31:22 -05:00
Towsty 7799529788 Extend Prompt Builder with sequence characters, outfits, and scene toggles.
Characters live on the sequence with per-scene on/off and outfit picks; Copy scene exports only the active tab; legacy saves still open.
2026-10-02 08:50:31 -05:00
Towsty 790c33fcf0 Fix Prompt Builder Nitro build by inlining sequence helpers in utils.
The relative re-export of shared/prompt-sequences.mjs left an unresolved import in the SSR bundle.
2026-10-02 08:11:09 -05:00
Towsty 1bf97568bf Add Prompt Builder page for multi-scene prompt sequences.
Name, save, and load ordered scenes on the server; assemble copyable text with a Batch-compatible double blank line between scenes.
2026-10-02 07:15:00 -05:00
Towsty 5788d74001 Add desktop Batch toggle, fix xAIGen library hide/selection, per-engine steps/CFG, and mobile prompt editor.
Desktop Batch splits only on double blank lines when enabled; library hide actually conceals thumbs and pinned clicks stick; steps/CFG restore per image model; mobile opens a near-fullscreen prompt editor.
2026-10-01 17:06:25 -05:00
Towsty 4f40641050 Split blank-line paragraphs in the desktop prompt into ordered image batch jobs. 2026-09-30 23:34:47 -05:00
Towsty 14dcd234f3 Let finished caption jobs be dismissed from the Queue panel. 2026-09-30 23:18:44 -05:00
Towsty 15ef891b26 Add image-to-text Describe on the bench via exclusive llama.cpp caption jobs. 2026-09-30 22:50:19 -05:00
TowstyandCursor 9481791ac5 Keep fetching library pages after the first 50 so the whole folder is reachable.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 00:28:22 -05:00
TowstyandCursor 17bb42bea4 Serve the stage from a 1600px preview instead of the grid thumb.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 20:57:01 -05:00
TowstyandCursor ccf852cb0c Clear Queuing when the optimistic job snapshot avoids structuredClone.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 20:45:23 -05:00
TowstyandCursor 4ec8ffa8d6 Serve the bench before the catalog and never await ffmpeg on /thumb.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 20:35:28 -05:00
TowstyandCursor a465dd68ec Stop polling the library catalog and keep the stage on thumbs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 20:15:03 -05:00
TowstyandCursor cfabfd37ee Queue Generate with an immediate local card and stop blocking reload on library or object_info.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 19:46:55 -05:00
TowstyandCursor ffdaa7ce42 Remount stage media on selectedId and show thumb before the archive still.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 19:45:51 -05:00
TowstyandCursor aa0aa871e4 Add paginated library JSON and dedicated still thumbs that never stream archive masters.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 19:45:22 -05:00
TowstyandCursor 845ee5bbf6 Set the selected still on Use image instead of an empty Photos card.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 15:40:35 -05:00
TowstyandCursor ba83520923 Move stage actions into a top chrome bar with Copy and Load menus.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 09:09:00 -05:00
TowstyandCursor 322e5a276e Persist the bench draft and hide input thumbs only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 08:14:13 -05:00
TowstyandCursor 5f6a7aa9bc Rename library actions and always store promptRaw on ingest.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 08:14:13 -05:00
TowstyandCursor 930bbf7c7b Keep MiniMax hold from injecting amplitude and speed.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 08:14:13 -05:00
TowstyandCursor c4e74628ee Show the Qwen LoRA card with qwen-stem picks and apply them on generate.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 22:47:59 -05:00
TowstyandCursor fe560d7e40 Slim the phone stage to icons, chevrons, and a More sheet.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 22:21:13 -05:00
TowstyandCursor d61f99d6fd Put still actions on the stage and split output vs input hide.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 21:26:29 -05:00
Towsty f9903ed76e Square Library modal thumbs and add stage previous/next. 2026-09-28 20:40:45 -05:00
TowstyandCursor a33cadb6c2 Load Library modal thumbs with the same tiles and fetch as /library.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 20:25:16 -05:00
TowstyandCursor 5b0d7370ed Show Typed and Used prompt fields on still Details.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 20:11:18 -05:00
TowstyandCursor 7242d0faef Replace the under-stage filmstrip with a Library modal and stage eye.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 20:11:14 -05:00
TowstyandCursor e06afe4a17 Fix Photos Add, Remove, and Replace against the library picker.
Remove clears the Photos array and backing image fields; Add opens the picker with disk upload and drops empty drafts on cancel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 17:06:39 -05:00
TowstyandCursor 50319e03a9 Map Photos roles to Qwen image_1/image_2 and inject encode tags silently.
Mention phrases become <imageN> only in the TextEncode string; the inspector never shows Comfy tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 16:01:49 -05:00
TowstyandCursor 5c2248a3fd Replace Hero/Edit-source chrome with Photos role cards.
Inspector stills use plain-English roles and Mention phrases; Qwen Generate hides Photos entirely.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 16:00:37 -05:00
TowstyandCursor 58c5458c87 Store the Edit TextEncode prompt on library stills, not a Start still caption.
Edit + Enhance always leads with the keep/<image1> stanza and typed change; PE describe dumps are dropped, and Typed stays promptRaw.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 15:14:20 -05:00
TowstyandCursor c46e827c68 Force <image1> on every Qwen Edit PE rewrite.
PE-I2I no longer bans tags for single-still edits, and the runner rewrites natural photo/image wording to <image1> so Start still stays bound.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 15:04:04 -05:00
TowstyandCursor a51118d92a Pass through PE refusals to the raw prompt and pin adult PE system prompts.
Enhance no longer fails the studio job when the rewrite refuses or comes back empty; host prompt copies keep the official steps plus the adult appendix after a node re-clone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 14:46:24 -05:00
TowstyandCursor 99b61e67d7 Keep Enhance + Generate as one queue row with shared progress.
The PE stage now stays on the same jobId so the header and queue badge agree, and jobs behind it do not promote when enhance finishes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 14:42:22 -05:00
TowstyandCursor 30be4bd5b0 Wire Qwen Edit Start still to image_1 and PE-I2I identity keep.
Edit no longer invents strangers when the prompt omits <image1> or PE drops image tokens; Hero stays Klein-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 14:27:36 -05:00
TowstyandCursor ecc4c69a8b Wire Qwen Turbo toggle through Studio 2 and the runner.
Force 6/1 when Turbo is on, skip negatives, keep PE in front, and fail closed if the r128 LoRA is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 12:42:00 -05:00
TowstyandCursor 17cbc99f8f Add Qwen 2.1 Viggle Turbo Comfy graphs.
Use the unmerged ViggleTurboLora path with the official 6-step sigma schedule on the existing Q8 GGUF.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 12:41:59 -05:00
TowstyandCursor 95873744cd Size Qwen 2.1 Generate from the bench aspect table.
Wire EmptyLatentImage to the official ~1 MP grid so 16:9 lands at 1536×864, keep PE wh_ratio advisory, and leave Edit on the encode latent from image_1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 10:54:18 -05:00
TowstyandCursor 2842855c7b Wire Qwen Enhance prompt toggle through Studio 2 and the runner.
Queue PE first when enabled, fail closed on parse_ok, free VRAM, then run the existing T2I or Edit graph with the rewrite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 10:25:44 -05:00
TowstyandCursor e0292c8d2a Add Qwen 2.1 PE-only Comfy graphs for prompt enhance.
Keep PE CLIP separate from DiT so the 16 GB card can run rewrite then generate without loading both at once.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-26 10:25:44 -05:00
TowstyandCursor bb27af506e Restore Studio 2 Load hydrate, sticky negatives, and strip actions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 12:23:20 -05:00
TowstyandCursor df5ef0949e Make Library Reveal all / Hide all cover the whole grid.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 12:09:26 -05:00
TowstyandCursor 206984b6ec Add Reveal all / Hide all to Studio 2 input picker.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 21:04:55 -05:00
TowstyandCursor 52a01e0adc Enable Qwen 2.1 on Edit: wire picker, runner, and source-still validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 18:13:07 -05:00
TowstyandCursor 8609ba24a4 Add Qwen 2.1 Image Edit Comfy graph (GGUF + TextEncode + int8 cache).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 18:10:50 -05:00
TowstyandCursor 776d585345 Fix xAIGen LoRA picker to use isXaigenStudio like the server.
Bench was checking only public.studio === 'xaigen', so when the instance
is branded xAIGen via instanceName the client stripped xaigen-* LoRAs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 17:34:53 -05:00
TowstyandCursor 608f7d8d47 Fix Qwen 2.1 GGUF load: promote Q8 norms to F32 and wire TextEncode latent.
abenzerps Q8_0 ships 1D RMSNorms as packed Q8 (136 vs 128), which breaks
Comfy rms_rope; tagger now dequantizes small tensors and the graph uses
TextEncodeQwenImage21's 64-ch latent plus AuraFlow shift.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 16:58:00 -05:00
TowstyandCursor 68731916ca Wire Qwen 2.1 as Generate-only engine with 25/1 sampler defaults.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 16:58:00 -05:00
TowstyandCursor 08d507c491 Add studio2_qwen21_t2i Comfy graph for GGUF Qwen Image 2.1 T2I.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 16:57:04 -05:00
TowstyandCursor d65420f34a Add Qwen 2.1 host setup script and document Shared model paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 16:57:04 -05:00
TowstyandCursor d733e80881 Add a viewer-first mobile shell that swaps in under 768px without restyling desktop Bench.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 08:19:36 -05:00
TowstyandCursor dccfafa08b Scale viewer stills and clips to fill the stage with contain.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 20:31:15 -05:00
TowstyandCursor 589791cdb8 Replace LoRA Thumbwheel spectrograms with range and numeric strengths.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 19:15:12 -05:00
78 changed files with 10167 additions and 503 deletions
+99
View File
@@ -0,0 +1,99 @@
# Cursor prompt — unique mobile portrait shell (new file)
Do not edit `STUDIO2-DISPLAY-CURSOR-PROMPT.md`, `STUDIO2-DISPLAY-LAYOUT-SPEC.md`, `STUDIO2-LORA-DIALS-PROMPT.md`, or `STUDIO2-VIEWER-FIT-PROMPT.md`.
Branch: **`studio-2`**. Push `origin/studio-2` only. Chrome only. Same `form`, queue, generate, locks, LoRAs, library. No graph/runner/extend/YuE2 changes.
Desktop 3-column bench is **done**. Do not restyle desktop to make mobile work. Do not stack the three columns in a tall scroll and call it mobile.
Breakpoint: `(max-width: 767px)` **and** coarse pointer. Use a **separate mobile shell** (`Studio2Mobile.vue` or `Bench` template `v-if="isMobile"`) that binds the **same** state as desktop Bench.
Reference mock: `studio2-display-ux/mobile-mock.html` (open on a 390×844 viewport).
---
## Why unique
Portrait cannot show 260 | stage | 280. If you shrink that grid you lose Generate, LoRAs, and the still.
Mobile is a **viewer-first phone app** with sheets for the inspector and the queue. Every desktop control still exists. It is just not on screen at once.
---
## Chrome map (portrait)
```
┌ status / 48px header ─────────────────────────────────────┐
│ AIGen Inbox ▾ GPU ☰ queue(n) │
├ task chips (scroll-x) Generate Edit … Music ─────────────┤
├ model chips Klein | Krea or H3 | LTX ───────┤
├───────────────────────────────────────────────────────────┤
│ │
│ STAGE (contain, full remaining height) │
│ Library (stage chrome) · eye ≥48px top-right │
├ [Slots] [Prompt] [Look] [Gen] Generate (amber, 48px) ─┤
└───────────────────────────────────────────────────────────┘
Library is a **stage button** that opens the Library modal/sheet (same tiles,
filters, Use as photo / Copy / Load / Delete). The under-stage filmstrip row
is revoked — recents live in the modal (newest first).```
Bottom **dock** is 4 tabs that open sheets, plus Generate always visible:
| Tab | Sheet contents (all desktop fields) |
|---|---|
| **Slots** | Hero, Start, End, Guides, Upload, Lock face, Lock outfit + Klein caption |
| **Prompt** | Prompt textarea, negative chips, style/render/quality/lighting chips |
| **Look** | LoRA stack (filename + Model/CLIP sliders + numbers), Seed + dice, Aspect, extra generation settings (steps/CFG only if that engine uses them) |
| **Gen** | Engine-specific video/music extras: duration/target length, fps, turbo, camera beats, YuE2 hints. Not a second Generate button. |
**Generate** is the rightmost dock control — 48×48 or a 120px amber pill. Never labeled Queue.
Header **☰** or badge **Queue (n)** opens a **right/bottom sheet** of job cards (same cards as desktop right column).
Library and Settings stay header links (or a `…` menu). Do not drop them.
---
## Stage
- Same contain rule as desktop: `max-width/height 100%`, `object-fit: contain`, no inner max-width cap.
- Still / video / audio switch on `selected.kind`. No waveform on a still.
- Safe area: `padding-bottom: env(safe-area-inset-bottom)` on the dock.
- Filmstrip under stage, 72×48 tiles, amber ring on selected, horizontal scroll. Trash on selected tile only.
---
## Sheets
- One sheet at a time. Height `min(85vh, content)`. Drag handle + Done.
- Fields keep desktop order and payload keys.
- LoRA card = filename + two (or one) sliders + numeric `0.00–2.00`. No hashed meters.
- Krea disabled-on-Edit with the same one-line reason.
- LTX disabled on AIGen with `xAIGen only`.
- xAIGen hide defaults: eye on stage + each tile. No global hide checkbox required on mobile.
---
## What you must not do
- No desktop 3-column at `<768`.
- No “rotate device” dead end.
- No missing task (Iterate if desktop has it — add the chip).
- No second `form` object.
- Do not change Classic/Studio desktop tokens in this pass.
`isMobile` from `window.matchMedia('(max-width: 767px)')` + resize listener. SSR: default desktop to avoid hydration flip, then switch on mount.
---
## Done when
390×844 portrait:
- Stage fills the phone; dock + chips visible without page-zoom.
- Slots, Prompt, Look, Gen sheets each reachable; Generate queues a job.
- Queue sheet lists jobs.
- Rotate to ≥768: desktop 3-column returns, same job state.
One or two present-tense commits. Screenshot 390 and 1440.
+371 -23
View File
@@ -62,6 +62,10 @@
color: var(--text);
}
.bench-root[data-mobile="1"] {
position: relative;
}
.bench-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
@@ -324,51 +328,263 @@
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
flex-direction: column;
}
/* Stage: fills center column above transport */
.viewer-frame {
position: relative;
display: grid;
display: flex;
flex: 1;
min-height: 360px;
place-items: center;
min-height: 0;
min-width: 0;
align-items: center;
justify-content: center;
overflow: hidden;
background: #0a0a0c;
background: #0b0b0d;
}
.viewer-frame img,
.viewer-frame video {
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */
.viewer-frame > img,
.viewer-frame > video,
.viewer-still {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
object-position: center;
}
.viewer-still--veiled {
opacity: 0;
pointer-events: none;
}
.viewer-frame--veiled .viewer-veil-label {
position: absolute;
z-index: 2;
margin: 0;
pointer-events: none;
}
/* Top chrome is a sibling of the media pane — never absolute over pixels */
.stage-chrome {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
background: #0b0b0d;
}
.stage-chrome > .eye-viewer {
margin-left: auto;
}
.stage-library {
display: inline-flex;
min-height: 36px;
align-items: center;
gap: 6px;
border: 1px solid var(--line);
border-radius: 8px;
background: #141416;
padding: 0 12px;
color: var(--text);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.stage-library:hover {
border-color: var(--amber);
}
.stage-library-count {
color: var(--muted);
font-weight: 500;
}
.stage-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.stage-actions > button,
.stage-split-trigger {
min-height: 36px;
border: 1px solid var(--line);
border-radius: 8px;
background: #141416;
color: var(--text);
padding: 0 10px;
font: inherit;
font-size: var(--type-sm);
cursor: pointer;
}
.stage-actions > button:hover,
.stage-split-trigger:hover {
border-color: var(--amber);
}
.stage-actions .stage-action-danger {
color: #fca5a5;
}
.stage-split {
position: relative;
}
.stage-split-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
}
.stage-split-trigger::after {
content: '▾';
color: var(--muted);
font-size: 11px;
}
.stage-split-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 8;
display: flex;
min-width: 160px;
flex-direction: column;
gap: 2px;
border: 1px solid var(--line);
border-radius: 8px;
background: #141416;
padding: 4px;
box-shadow: 0 8px 24px #0008;
}
.stage-split-menu button {
min-height: 36px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text);
padding: 0 10px;
text-align: left;
font: inherit;
font-size: var(--type-sm);
cursor: pointer;
}
.stage-split-menu button:hover {
background: #ffffff10;
}
.eye-viewer {
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
display: grid;
width: 28px;
height: 28px;
width: 36px;
height: 36px;
flex-shrink: 0;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: #141416;
cursor: pointer;
}
.eye-viewer:hover {
border-color: var(--amber);
}
.stage-media-row {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
align-items: stretch;
gap: 0;
}
.stage-nav {
display: grid;
width: 44px;
flex-shrink: 0;
place-items: center;
align-self: stretch;
border: 0;
border-radius: 0;
background: #0b0b0d;
color: var(--text);
font-size: 28px;
line-height: 1;
cursor: pointer;
}
.stage-nav:hover {
color: var(--amber);
background: #121214;
}
.stage-nav-prev {
border-right: 1px solid var(--line);
}
.stage-nav-next {
border-left: 1px solid var(--line);
}
.photos-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.photos-inputs-toggle {
border: 1px solid var(--line);
border-radius: 6px;
background: #000a;
background: transparent;
color: var(--muted);
padding: 4px 8px;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.photos-inputs-toggle:hover {
border-color: var(--amber);
color: var(--amber);
}
.photos-collapsed {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: var(--type-sm);
}
.photos-add-link {
border: 0;
background: transparent;
color: var(--amber);
padding: 0;
font: inherit;
font-size: var(--type-sm);
cursor: pointer;
}
.filmstrip {
display: flex;
flex-shrink: 0;
gap: 8px;
height: 80px;
align-items: center;
overflow-x: auto;
border-top: 1px solid var(--line);
padding: 8px 10px;
background: var(--panel);
display: none;
}
.tile {
@@ -384,7 +600,6 @@
@media (min-width: 1440px) {
.tile { width: 120px; height: 80px; }
.filmstrip { height: 96px; }
}
.tile[data-active='true'] {
@@ -425,6 +640,21 @@
.tile .tile-trash { right: 22px; }
.strip-actions {
display: none;
}
.neg-add-row {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.neg-add-row .field-control {
flex: 1;
}
.transport {
display: flex;
height: 28px;
@@ -456,6 +686,10 @@
flex-direction: column;
}
.queue-col[data-collapsed='1'] {
min-width: 0;
}
.queue-head {
display: flex;
flex-shrink: 0;
@@ -465,6 +699,24 @@
font-weight: 650;
}
.queue-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: 650;
cursor: pointer;
padding: 0;
}
.queue-toggle-mark {
color: var(--muted);
font-size: 12px;
}
.queue-badge {
display: inline-grid;
min-width: 1.25rem;
@@ -542,6 +794,37 @@
background: var(--muted);
}
.job-status[data-state='enhancing'] .dot,
.job-status[data-state='waking'] .dot,
.job-status[data-state='submitting'] .dot,
.job-status[data-state='rendering'] .dot,
.job-status[data-state='saving'] .dot {
background: var(--amber);
}
.job-progress {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.job-progress-bar {
flex: 1;
height: 4px;
accent-color: var(--amber);
}
.job-progress[data-indeterminate='true'] .job-progress-bar {
opacity: 0.85;
}
.job-elapsed {
flex-shrink: 0;
color: var(--muted);
font-size: 11px;
}
.kebab {
border: 0;
background: transparent;
@@ -578,6 +861,14 @@
cursor: not-allowed;
}
.queue-foot-error {
margin: 8px 0 0;
color: #fca5a5;
font-size: var(--type-sm);
line-height: 1.35;
word-break: break-word;
}
.queue-foot .tiny {
margin-top: 8px;
text-align: center;
@@ -639,6 +930,63 @@
cursor: pointer;
}
.photos-section {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 4px;
}
.photos-add {
align-self: flex-start;
color: var(--amber);
font-size: var(--type-sm);
}
.photo-card {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.photo-card .slot-thumb--slot { height: 88px; }
.photo-card .slot-thumb--empty { height: 88px; }
.photo-card-body {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.photo-role-label {
display: flex;
flex-direction: column;
gap: 4px;
}
.photo-role-select {
width: 100%;
}
.slot-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.slot-actions button,
.slot-token-btn {
color: var(--amber);
font-size: var(--type-sm);
}
.slot-token-btn {
font-weight: 600;
}
.placeholder-still {
color: var(--muted);
font-size: var(--type-sm);
+8 -1
View File
@@ -55,6 +55,13 @@
role="menuitem"
@click="menuOpen = false"
>Director</NuxtLink>
<NuxtLink
to="/prompt-builder"
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
:class="current === 'prompt-builder' ? 'text-amber-100' : 'text-zinc-200'"
role="menuitem"
@click="menuOpen = false"
>Prompt Builder</NuxtLink>
<NuxtLink
to="/music"
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
@@ -131,7 +138,7 @@
<script setup lang="ts">
withDefaults(defineProps<{
subtitle: string
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music'
current?: 'studio' | 'library' | 'director' | 'prompt-builder' | 'queue' | 'settings' | 'music'
wide?: boolean
queueCount?: number
}>(), {
+1 -1
View File
@@ -95,7 +95,7 @@
>
<div class="relative h-28 overflow-hidden bg-zinc-900">
<img
:src="`/api/library/stills/${still.id}?thumb=1`"
:src="`/api/library/stills/${still.id}/thumb`"
alt=""
loading="lazy"
decoding="async"
+150 -43
View File
@@ -6,22 +6,21 @@
<li
v-for="(item, index) in model"
:key="item.name"
class="rounded-xl border bg-zinc-950/80"
class="rounded-lg border border-[#2A2A2E] bg-[#1A1A1D] p-2.5"
:class="[
compact ? 'px-2 py-1.5' : 'px-3 py-2',
dragOverIndex === index ? 'border-amber-300/50' : 'border-white/10',
dragOverIndex === index ? 'border-amber-300/50' : '',
draggingIndex === index ? 'opacity-60' : ''
]"
@dragover.prevent="onDragOver(index)"
@drop.prevent="onDrop(index)"
@dragleave="onDragLeave(index)"
>
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 flex-1 items-start gap-2">
<button
v-if="model.length > 1"
type="button"
class="shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
class="mt-0.5 shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
:disabled="disabled"
draggable="true"
title="Drag to reorder"
@@ -31,61 +30,91 @@
>
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
</button>
<span class="min-w-0"><span class="block truncate text-zinc-200">{{ labelFor(item.name) }}</span><span v-if="filenameSubtitle" class="block truncate text-[11px] text-zinc-500" :title="item.name">{{ item.name }}</span></span>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-1.5">
<span class="block truncate font-medium text-[#F3F1EA]" :title="labelFor(item.name)">{{ labelFor(item.name) }}</span>
<LoraTriggerTip
:text="triggerFor(item.name)"
:aria-label="'Activation for ' + labelFor(item.name)"
@insert="emit('insert-trigger', $event)"
@save="saveTrigger(item.name, $event)"
/>
</span>
<span v-if="filenameSubtitle" class="mt-0.5 block truncate text-[11px] text-[#9A9588]" :title="item.name">{{ stemFor(item.name) }}</span>
</span>
</div>
<button
type="button"
class="shrink-0 text-[11px] text-zinc-500 hover:text-red-300 disabled:opacity-50"
class="shrink-0 text-[11px] text-[#9A9588] hover:text-red-300 disabled:opacity-50"
:disabled="disabled"
@click="removeAt(index)"
>
Remove
</button>
</div>
<div class="mt-1.5 flex flex-wrap gap-3">
<Thumbwheel
:model-value="item.strengthModel"
label="Model"
:lock-key="`lora-model:${item.name}`"
:min="LORA_STRENGTH_MIN"
:max="LORA_STRENGTH_MAX"
<div class="mt-2 space-y-1.5">
<label class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]">
<span>{{ clipStrength ? 'Model' : 'Strength' }}</span>
<input
type="range"
class="lora-strength-range min-w-0"
:min="0"
:max="2"
:step="LORA_STRENGTH_STEP"
:fine-step="LORA_STRENGTH_STEP"
:coarse-step="0.25"
:digits="2"
compact
:disabled="disabled"
@update:model-value="setStrength(index, 'strengthModel', $event)"
/>
<Thumbwheel
:model-value="item.strengthClip"
label="CLIP"
:lock-key="`lora-clip:${item.name}`"
:min="LORA_STRENGTH_MIN"
:max="LORA_STRENGTH_MAX"
:value="displayStrength(item.strengthModel)"
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' for ' + labelFor(item.name)"
@input="onRange(index, 'strengthModel', $event)"
>
<input
type="number"
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
:min="0"
:max="2"
:step="LORA_STRENGTH_STEP"
:fine-step="LORA_STRENGTH_STEP"
:coarse-step="0.25"
:digits="2"
compact
:disabled="disabled || !clipStrength"
@update:model-value="setStrength(index, 'strengthClip', $event)"
/>
:disabled="disabled"
:value="displayStrength(item.strengthModel).toFixed(2)"
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' value'"
@change="onNumber(index, 'strengthModel', $event)"
>
</label>
<label
v-if="clipStrength"
class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]"
>
<span>CLIP</span>
<input
type="range"
class="lora-strength-range min-w-0"
:min="0"
:max="2"
:step="LORA_STRENGTH_STEP"
:disabled="disabled"
:value="displayStrength(item.strengthClip)"
:aria-label="'CLIP for ' + labelFor(item.name)"
@input="onRange(index, 'strengthClip', $event)"
>
<input
type="number"
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
:min="0"
:max="2"
:step="LORA_STRENGTH_STEP"
:disabled="disabled"
:value="displayStrength(item.strengthClip).toFixed(2)"
:aria-label="'CLIP value'"
@change="onNumber(index, 'strengthClip', $event)"
>
</label>
</div>
</li>
</ul>
<p v-if="!clipStrength && model.length" class="mt-1 text-[11px] text-zinc-500">CLIP unused on this graph — model strength only.</p>
<select
v-if="model.length < LORA_STACK_MAX"
class="w-full rounded-xl border border-white/10 bg-zinc-950 outline-none ring-amber-300/40 focus:ring-2 disabled:opacity-50"
class="w-full rounded-lg border border-[#2A2A2E] bg-[#0B0B0D] text-[#F3F1EA] outline-none ring-[#F5A524]/40 focus:ring-2 disabled:opacity-50"
:class="[
compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm',
compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
model.length ? 'mt-2' : ''
]"
:disabled="disabled || !availableNames.length"
@@ -103,13 +132,55 @@
</div>
</template>
<style scoped>
.lora-strength-range {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 999px;
background: #2a2a2e;
outline: none;
}
.lora-strength-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 999px;
background: #f5a524;
border: 0;
cursor: pointer;
}
.lora-strength-range::-moz-range-track {
height: 6px;
border-radius: 999px;
background: #2a2a2e;
}
.lora-strength-range::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 999px;
background: #f5a524;
border: 0;
cursor: pointer;
}
.lora-strength-range:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.lora-strength-range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: #2a2a2e;
}
</style>
<script setup lang="ts">
import {
LORA_STACK_MAX,
LORA_STRENGTH_MAX,
LORA_STRENGTH_MIN,
LORA_STRENGTH_DEFAULT,
LORA_STRENGTH_STEP,
clampLoraStrength,
loraDisplayName,
loraFriendlyName,
loraIdentityKey,
loraMatchesImageEngine,
@@ -120,6 +191,9 @@ import {
type LoraStackItem
} from '~/utils/loras'
const UI_MIN = 0
const UI_MAX = 2
const props = withDefaults(defineProps<{
names: string[]
filenameSubtitle?: boolean
@@ -175,6 +249,17 @@ const selectPlaceholder = computed(() => {
return model.value.length ? props.addLabel : props.emptyLabel
})
function clampUi(raw: unknown) {
const value = Number(raw)
if (!Number.isFinite(value)) return LORA_STRENGTH_DEFAULT
const snapped = Math.round(value / LORA_STRENGTH_STEP) * LORA_STRENGTH_STEP
return Math.min(UI_MAX, Math.max(UI_MIN, Math.round(snapped * 100) / 100))
}
function displayStrength(value: unknown) {
return clampUi(value ?? LORA_STRENGTH_DEFAULT)
}
function onAdd(event: Event) {
const name = (event.target as HTMLSelectElement).value
;(event.target as HTMLSelectElement).value = ''
@@ -186,7 +271,11 @@ function addLora(name: string) {
if (!item || model.value.length >= LORA_STACK_MAX) return
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
if (taken.has(loraIdentityKey(item.name))) return
model.value = [...model.value, item]
model.value = [...model.value, {
...item,
strengthModel: clampUi(item.strengthModel ?? LORA_STRENGTH_DEFAULT),
strengthClip: clampUi(item.strengthClip ?? LORA_STRENGTH_DEFAULT)
}]
}
function removeAt(index: number) {
@@ -234,8 +323,22 @@ function onDragEnd() {
}
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
const next = clampLoraStrength(value)
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item)
const next = clampUi(value)
model.value = model.value.map((item, i) => {
if (i !== index) return item
if (!props.clipStrength) {
return { ...item, strengthModel: next, strengthClip: next }
}
return { ...item, [key]: next }
})
}
function onRange(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
setStrength(index, key, Number((event.target as HTMLInputElement).value))
}
function onNumber(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
setStrength(index, key, Number((event.target as HTMLInputElement).value))
}
function triggerFor(name: string) {
@@ -246,6 +349,10 @@ function labelFor(name: string) {
return loraFriendlyName(name, props.aliases)
}
function stemFor(name: string) {
return loraDisplayName(name)
}
function saveTrigger(name: string, trigger: string) {
emit('update-trigger', name, trigger)
}
+48 -12
View File
@@ -1,24 +1,60 @@
<template>
<div class="slot-block">
<span class="field-label">{{ label }}</span>
<div class="photo-card">
<button
type="button"
class="slot-thumb"
:class="asset ? (size==='hero' ? 'slot-thumb--hero' : 'slot-thumb--slot') : 'slot-thumb--empty'"
:class="asset ? 'slot-thumb--slot' : 'slot-thumb--empty'"
:aria-label="asset ? 'Replace photo' : 'Choose a photo'"
@click="$emit('pick')"
>
<span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Still loaded · preview hidden</span>
<img v-else-if="asset" :src="asset.kind==='clip' ? `/api/library/clips/${asset.id}/thumb` : `/api/library/stills/${asset.id}`" :alt="label">
<span v-else class="inline-flex items-center gap-1">Select <span aria-hidden="true">▾</span></span>
<span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Photo loaded</span>
<img v-else-if="asset" :src="thumbSrc" alt="">
<span v-else class="inline-flex flex-col items-center gap-1 px-2 text-center">
<span>Choose a photo</span>
</span>
</button>
<p v-if="asset" class="slot-caption" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p>
<label v-if="upload" class="slot-upload">Upload {{ label.toLowerCase() }}
<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)">
<div class="photo-card-body">
<label class="photo-role-label">
<span class="field-label">This photo is:</span>
<select
class="field-control photo-role-select"
:value="role"
:disabled="readonly"
@change="$emit('update:role', ($event.target as HTMLSelectElement).value)"
>
<option v-for="opt in roleOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
</select>
</label>
<div class="slot-actions">
<button
v-if="!readonly && asset"
type="button"
class="slot-token-btn"
title="Mention this photo in the prompt"
@click.stop="$emit('mention')"
>Mention in prompt</button>
<button
v-if="!readonly && asset"
type="button"
@click.stop="$emit('pick')"
>Replace</button>
<button v-if="!readonly" type="button" @click.stop="$emit('remove')">Remove</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {assetLabel} from '~/shared/studio2/viewer.mjs'
withDefaults(defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean;size?:'hero'|'slot'}>(),{size:'slot'})
defineEmits(['pick','upload'])
const props = withDefaults(defineProps<{
asset?: any
hidden?: boolean
role: string
roleOptions: { id: string; label: string }[]
readonly?: boolean
}>(), { readonly: false, hidden: false })
defineEmits(['pick', 'remove', 'mention', 'update:role'])
const thumbSrc = computed(() => {
if (!props.asset) return ''
if (props.asset.kind === 'clip') return `/api/library/clips/${props.asset.id}/thumb`
return `/api/library/stills/${props.asset.id}/thumb`
})
</script>
+1615 -147
View File
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -5,6 +5,18 @@
</template>
<script setup lang="ts">
const props=defineProps<{metadata?:any}>()
function promptRows(m:any){
const used=String(m.prompt || m.compiledPrompt || '').trim()
const typed=String(m.promptRaw || '').trim()
if(typed && typed!==used){
return [
{label:'Typed',value:typed},
...(used?[{label:'Used',value:used}]:[])
]
}
if(used) return [{label:'Prompt',value:used}]
return []
}
const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=(v:any)=>v?new Date(v).toLocaleString():null;return [
{label:'Kind / mode',value:[m.kind,m.mode].filter(Boolean).join(' / ')},
{label:'Engine',value:m.engine},{label:'Graph ID',value:m.graphId},
@@ -19,7 +31,21 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
{label:'Compiled prompt',value:m.compiledPrompt},{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
...promptRows(m),
{label:'Task',value:m.task || m.mode || null},
{label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'},
{label:'Enhance',value:m.enhance?.refused?'Enhance skipped (model refused) — used your prompt.':m.enhance?.skippedAsDescribe?'Enhance skipped (describe rewrite) — keep stanza + typed only.':m.enhance?.used?'Applied':m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Applied':'Parse incomplete'},
{label:'Enhance used',value:m.enhance?.used==null?null:m.enhance.used?'Yes':'No'},
{label:'Enhance refused',value:m.enhance?.refused==null?null:m.enhance.refused?'Yes':'No'},
{label:'Enhance skip reason',value:m.enhance?.skipReason || (m.enhance?.skippedAsDescribe?'skippedAsDescribe':null)},
{label:'Turbo',value:m.turbo==null?null:m.turbo?'On':'Off'},
{label:'Turbo LoRA',value:m.lora || null},
{label:'Turbo sigmas',value:m.sigmas || null},
{label:'Enhance wh_ratio',value:m.enhance?.wh_ratio || null},
{label:'Enhance ratio_follow',value:m.enhance?.ratio_follow || null},
{label:'Enhance parse_ok',value:m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Yes':'No'},
{label:'Enhance thinking',value:m.enhance?.thinking || null},
{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
{label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult}
]})
</script>
+7 -1
View File
@@ -2,7 +2,8 @@
<div class="space-y-2 rounded-xl border border-white/10 p-3 text-sm" aria-live="polite">
<div class="flex justify-between gap-3"><button class="text-left" @click="$emit('select')"><strong>{{ job.request.promptSections.action.slice(0,70) }}</strong><span class="block capitalize text-amber-200">{{ job.state==='waiting'?'queued':job.state }} · Shot {{ shot }}/{{ total }}</span></button><button v-if="!terminal" @click="$emit('cancel')">Cancel</button></div>
<p>{{ elapsed }} {{ job.startedAt?'elapsed':'waiting' }} · {{ job.request.settings.duration }}s target<span v-if="job.state==='rendering' && job.progress"> · Step {{ job.progress.step }}/{{ job.progress.maxStep }} ({{ job.progress.percent }}%)</span></p>
<progress v-if="job.state==='rendering'" class="h-2 w-full accent-amber-300" :value="job.progress?.percent" max="100" />
<progress v-if="job.state==='enhancing'" class="h-2 w-full accent-amber-300" max="100" :value="enhancePercent" />
<progress v-else-if="job.state==='rendering'" class="h-2 w-full accent-amber-300" :value="job.progress?.percent" max="100" />
<p v-if="job.error" role="alert" class="text-red-300">{{ job.error }}</p>
</div>
</template>
@@ -13,4 +14,9 @@ const terminal=computed(()=>['complete','failed','cancelled'].includes(props.job
const total=computed(()=>props.job.prompts?.length || (props.job.request.batch?.length || 0)+1)
const shot=computed(()=>Math.min(total.value,(props.job.index || 0)+1))
const elapsed=computed(()=>{const seconds=elapsedSeconds(props.job,props.now);return `${Math.floor(seconds/60)}m ${seconds%60}s`})
const enhancePercent=computed(()=>{
const p=props.job.progress
if(p && Number(p.maxStep)>0) return Number(p.percent)||Math.round(Number(p.step)/Number(p.maxStep)*100)
return undefined // indeterminate while PE runs without Comfy step events
})
</script>
+701
View File
@@ -0,0 +1,701 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="lib-modal-root"
role="presentation"
@keydown.esc.prevent="close"
>
<div class="lib-modal-backdrop" @click="close" />
<section
ref="dialogEl"
class="lib-modal"
role="dialog"
aria-modal="true"
aria-labelledby="lib-modal-title"
tabindex="-1"
@keydown.tab="onTab"
>
<header class="lib-modal-head">
<h2 id="lib-modal-title">Library</h2>
<div class="lib-modal-head-actions">
<NuxtLink
class="lib-modal-full"
:to="{ path: '/library', query: activeFolderId && activeFolderId !== 'all' ? { folder: activeFolderId } : {} }"
@click="close"
>Full library</NuxtLink>
<button type="button" class="lib-modal-x" aria-label="Close" @click="close">×</button>
</div>
</header>
<div class="lib-modal-toolbar">
<input
v-model="query"
type="search"
class="lib-modal-search"
placeholder="Name or prompt"
aria-label="Search library"
>
<button
type="button"
class="lib-modal-preview-all"
@click="$emit('toggle-preview')"
>{{ previewHidden ? 'Show all previews' : 'Hide all previews' }}</button>
<div class="lib-modal-filters" role="group" aria-label="Type">
<button
v-for="opt in filterOptions"
:key="opt.id"
type="button"
class="lib-modal-chip"
:data-on="filter === opt.id ? 'true' : undefined"
@click="filter = opt.id"
>{{ opt.label }}</button>
</div>
</div>
<div class="lib-modal-body">
<div ref="gridScrollEl" class="lib-modal-grid" aria-label="Folder assets">
<p v-if="loading && !entries.length" class="lib-modal-empty">Loading library…</p>
<p v-else-if="loadError" class="lib-modal-empty">{{ loadError }}</p>
<p v-else-if="!entries.length" class="lib-modal-empty">No items in this folder yet.</p>
<article
v-for="entry in entries"
:key="entry.key"
class="lib-modal-tile"
:data-thumb-key="entry.key"
:data-active="entry.key === focusedKey ? 'true' : undefined"
@click="pickEntry(entry)"
>
<div class="lib-modal-media">
<div
v-if="entry.kind === 'track'"
class="lib-modal-track"
aria-hidden="true"
>
<span
v-for="n in 16"
:key="n"
class="lib-modal-bar"
:style="{ height: `${20 + ((n * 17) % 60)}%` }"
/>
</div>
<img
v-else-if="!previewHidden && liveThumbs.has(entry.key) && thumbSrc(entry)"
:src="thumbSrc(entry)"
alt=""
loading="lazy"
decoding="async"
draggable="false"
>
<span v-else class="lib-modal-fallback">{{ previewHidden ? 'Hidden' : '' }}</span>
<span class="lib-modal-kind">
{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}
</span>
</div>
<div class="lib-modal-caption">
<p class="lib-modal-name">{{ entry.name }}</p>
<p class="lib-modal-meta">{{ cardMeta(entry) }}</p>
</div>
</article>
</div>
<aside v-if="detailAsset" class="lib-modal-detail">
<p class="lib-modal-detail-name">{{ detailLabel }}</p>
<div v-if="detailAsset.kind !== 'track'" class="lib-modal-actions">
<button type="button" @click="$emit('use-image', detailAsset)">Use image</button>
<button type="button" @click="$emit('use-prompt', detailAsset)">Use prompt</button>
<button type="button" @click="$emit('copy-prompt', detailAsset)">Copy prompt</button>
<button type="button" @click="$emit('copy-raw-prompt', detailAsset)">Copy raw prompt</button>
<button type="button" @click="$emit('load-input', detailAsset)">Load input</button>
<button type="button" @click="$emit('load-input-raw', detailAsset)">Load input raw</button>
<button type="button" @click="$emit('toggle-preview')">{{ previewHidden ? 'Show output' : 'Hide output' }}</button>
<button type="button" class="danger" @click="onDeleteClick">Delete</button>
<button type="button" class="primary" @click="useAndClose">Use and close</button>
</div>
<LibrarySettingsDetails :rows="detailRows" empty="No generator settings were saved with this item." />
</aside>
</div>
</section>
</div>
</Teleport>
</template>
<script setup lang="ts">
import {
buildLibraryEntries,
formatClipDuration,
formatWhen,
type LibraryClip,
type LibraryEntry,
type LibraryFilter,
type LibraryFolder,
type LibraryStill,
type LibraryTrack
} from '~/utils/libraryBrowse'
import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration'
import { assetLabel } from '~/shared/studio2/viewer.mjs'
const props = defineProps<{
open: boolean
assets?: any[]
selected?: any
folderId?: string
previewHidden?: boolean
isHidden?: (asset: any) => boolean
}>()
const emit = defineEmits<{
close: []
select: [asset: any]
'use-image': [asset: any]
'use-prompt': [asset: any]
'copy-prompt': [asset: any]
'copy-raw-prompt': [asset: any]
'load-input': [asset: any]
'load-input-raw': [asset: any]
delete: [asset: any]
'toggle-preview': []
}>()
const filterOptions = [
{ id: 'all' as const, label: 'All' },
{ id: 'images' as const, label: 'Images' },
{ id: 'videos' as const, label: 'Videos' },
{ id: 'music' as const, label: 'Music' }
]
const query = ref('')
const filter = ref<LibraryFilter>('all')
const dialogEl = ref<HTMLElement | null>(null)
const gridScrollEl = ref<HTMLElement | null>(null)
const loading = ref(false)
const loadError = ref('')
const folders = ref<LibraryFolder[]>([])
const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([])
const tracks = ref<LibraryTrack[]>([])
const fetchedAt = ref(0)
const nextCursor = ref<string | null>(null)
const liveThumbs = ref(new Set<string>())
let thumbObserver: IntersectionObserver | null = null
let scrollLoadBound = false
const activeFolderId = computed(() => props.folderId || folders.value[0]?.id || 'all')
const entries = computed(() => buildLibraryEntries({
clips: clips.value,
stills: stills.value,
tracks: tracks.value,
folderId: activeFolderId.value,
filter: filter.value,
search: query.value,
sort: 'date-desc'
}))
const focusedKey = computed(() => {
const id = props.selected?.id
if (!id) return ''
const hit = entries.value.find(entry => assetFromEntry(entry)?.id === id)
return hit?.key || ''
})
const detailAsset = computed(() => {
if (props.selected) return props.selected
const first = entries.value[0]
return first ? assetFromEntry(first) : null
})
const detailLabel = computed(() => assetLabel(detailAsset.value))
const detailRows = computed(() => {
const item = detailAsset.value
if (!item) return []
if (item.kind === 'clip') return clipSettingsRows(item)
if (item.kind === 'track') return trackSettingsRows(item)
return stillSettingsRows(item)
})
function thumbSrc(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
if (entry.kind === 'track') return ''
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}/thumb`
return ''
}
function cardMeta(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) {
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
}
if (entry.kind === 'track' && entry.track) {
return [formatClipDuration(entry.track.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
}
return formatWhen(entry.createdAt)
}
function assetFromEntry(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return { ...entry.group.head, kind: 'clip' as const }
if (entry.kind === 'track' && entry.track) return { ...entry.track, kind: 'track' as const }
if (entry.stillGroup) return { ...entry.stillGroup.head, kind: 'still' as const }
return null
}
function pickEntry(entry: LibraryEntry) {
const asset = assetFromEntry(entry)
if (!asset) return
const fromFamily = (props.assets || []).find((a: any) => a.id === asset.id)
emit('select', fromFamily ? { ...asset, ...fromFamily, kind: asset.kind } : asset)
}
function close() { emit('close') }
function useAndClose() {
if (detailAsset.value) emit('use-image', detailAsset.value)
close()
}
function removeLocalAsset(asset: any) {
if (!asset?.id) return
stills.value = stills.value.filter(s => s.id !== asset.id)
clips.value = clips.value.filter(c => c.id !== asset.id)
tracks.value = tracks.value.filter(t => t.id !== asset.id)
}
function onDeleteClick() {
const asset = detailAsset.value
if (!asset) return
emit('delete', asset)
removeLocalAsset(asset)
window.setTimeout(() => { void fetchLibrary(true) }, 500)
}
function mergeIds(prev: { id: string }[], next: { id: string }[]) {
const map = new Map(prev.map(item => [item.id, item]))
for (const item of next) map.set(item.id, item)
return [...map.values()]
}
function applyLibraryPage(data: any, replace: boolean) {
folders.value = data?.folders || folders.value
if (replace) {
clips.value = data?.clips || []
stills.value = data?.stills || []
tracks.value = data?.tracks || []
} else {
clips.value = mergeIds(clips.value, data?.clips || []) as LibraryClip[]
stills.value = mergeIds(stills.value, data?.stills || []) as LibraryStill[]
tracks.value = mergeIds(tracks.value, data?.tracks || []) as LibraryTrack[]
}
nextCursor.value = data?.nextCursor || null
fetchedAt.value = Date.now()
}
async function fetchLibraryPage(cursor: string | null) {
const folder = activeFolderId.value && activeFolderId.value !== 'all' ? activeFolderId.value : ''
const type = filter.value === 'all' ? 'all' : filter.value === 'images' ? 'images' : filter.value === 'videos' ? 'videos' : 'music'
const qs = new URLSearchParams({ limit: '50' })
if (folder) qs.set('folderId', folder)
if (type !== 'all') qs.set('type', type)
if (cursor) qs.set('cursor', cursor)
return await $fetch<any>(`/api/library?${qs.toString()}`)
}
let fetchGen = 0
async function fetchLibrary(force = false) {
const stale = !fetchedAt.value || Date.now() - fetchedAt.value > 15_000
const empty = !stills.value.length && !clips.value.length && !tracks.value.length
if (!force && !empty && !stale) return
const gen = ++fetchGen
loading.value = true
loadError.value = ''
liveThumbs.value = new Set()
try {
const first = await fetchLibraryPage(null)
if (gen !== fetchGen) return
applyLibraryPage(first, true)
loading.value = false
nextTick(() => observeThumbs())
// Keep fetching until the folder is exhausted — 50 is a page size, not a cap
let cursor = first?.nextCursor || null
while (cursor && gen === fetchGen) {
const more = await fetchLibraryPage(cursor)
if (gen !== fetchGen) return
applyLibraryPage(more, false)
cursor = more?.nextCursor || null
nextTick(() => observeThumbs())
}
} catch (e: any) {
if (gen !== fetchGen) return
loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library'
loading.value = false
}
}
function observeThumbs() {
if (!import.meta.client) return
const root = gridScrollEl.value
if (!root) return
if (thumbObserver) thumbObserver.disconnect()
thumbObserver = new IntersectionObserver((entries) => {
let changed = false
const next = new Set(liveThumbs.value)
for (const entry of entries) {
if (!entry.isIntersecting) continue
const key = (entry.target as HTMLElement).dataset.thumbKey
if (!key || next.has(key)) continue
next.add(key)
changed = true
}
if (changed) liveThumbs.value = next
}, { root, rootMargin: '200px 0px', threshold: 0.01 })
root.querySelectorAll<HTMLElement>('[data-thumb-key]').forEach((el) => thumbObserver!.observe(el))
}
function onGridScroll() {
const el = gridScrollEl.value
if (!el || !nextCursor.value || loading.value) return
if (el.scrollTop + el.clientHeight < el.scrollHeight - 240) return
void fetchMore()
}
async function fetchMore() {
if (!nextCursor.value || loading.value) return
loading.value = true
try {
const more = await fetchLibraryPage(nextCursor.value)
applyLibraryPage(more, false)
nextTick(() => observeThumbs())
} catch { /* keep what we have */ }
finally { loading.value = false }
}
function onTab(e: KeyboardEvent) {
const root = dialogEl.value
if (!root) return
const focusable = [...root.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)].filter(el => el.offsetParent !== null || el === document.activeElement)
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
watch(() => props.open, (on) => {
if (!import.meta.client) return
if (on) {
document.body.style.overflow = 'hidden'
void fetchLibrary(true)
nextTick(() => {
dialogEl.value?.focus()
if (gridScrollEl.value) {
gridScrollEl.value.scrollTop = 0
if (!scrollLoadBound) {
gridScrollEl.value.addEventListener('scroll', onGridScroll, { passive: true })
scrollLoadBound = true
}
observeThumbs()
}
})
} else {
document.body.style.overflow = ''
thumbObserver?.disconnect()
thumbObserver = null
}
})
watch(entries, () => nextTick(() => observeThumbs()))
watch([filter, activeFolderId], () => {
if (!props.open) return
void fetchLibrary(true)
})
onBeforeUnmount(() => {
if (import.meta.client) {
document.body.style.overflow = ''
gridScrollEl.value?.removeEventListener('scroll', onGridScroll)
thumbObserver?.disconnect()
}
})
</script>
<style scoped>
.lib-modal-root {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: stretch;
justify-content: center;
}
.lib-modal-backdrop {
position: absolute;
inset: 0;
background: #000a;
}
.lib-modal {
position: relative;
z-index: 1;
display: flex;
width: min(1100px, 100%);
max-height: min(92vh, 900px);
height: min(92vh, 900px);
margin: auto;
flex-direction: column;
overflow: hidden;
border: 1px solid #2a2a2e;
border-radius: 12px;
background: #141416;
color: #f3f1ea;
outline: none;
}
@media (max-width: 767px) {
.lib-modal-root { align-items: flex-end; }
.lib-modal {
width: 100%;
max-height: 100dvh;
height: 100dvh;
margin: 0;
border-radius: 16px 16px 0 0;
}
}
.lib-modal-head {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid #2a2a2e;
padding: 12px 16px;
}
.lib-modal-head h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.lib-modal-head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.lib-modal-full {
color: #f5a524;
font-size: 0.85rem;
text-decoration: none;
}
.lib-modal-x {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 1px solid #2a2a2e;
border-radius: 8px;
background: transparent;
color: inherit;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
.lib-modal-toolbar {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 10px;
align-items: center;
border-bottom: 1px solid #2a2a2e;
padding: 10px 16px;
}
.lib-modal-search {
min-width: 12rem;
flex: 1;
border: 1px solid #2a2a2e;
border-radius: 8px;
background: #0b0b0d;
padding: 8px 10px;
color: inherit;
font: inherit;
}
.lib-modal-preview-all {
border: 1px solid #2a2a2e;
border-radius: 8px;
background: transparent;
color: inherit;
padding: 8px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.lib-modal-preview-all:hover {
border-color: #f5a524;
color: #f5a524;
}
.lib-modal-filters { display: inline-flex; gap: 4px; }
.lib-modal-chip {
border: 1px solid #2a2a2e;
border-radius: 999px;
background: transparent;
color: #9a9588;
padding: 6px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.lib-modal-chip[data-on='true'] {
border-color: #f5a524;
background: #f5a52422;
color: #f5a524;
}
.lib-modal-body {
display: flex;
min-height: 0;
flex: 1;
overflow: hidden;
}
@media (max-width: 767px) {
.lib-modal-body { flex-direction: column; }
}
.lib-modal-grid {
display: grid;
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
align-content: start;
overflow: auto;
padding: 14px 16px;
}
.lib-modal-tile {
display: flex;
min-height: 120px;
flex-direction: column;
overflow: hidden;
border: 2px solid #2a2a2e;
border-radius: 8px;
background: #0b0b0d;
cursor: pointer;
}
.lib-modal-tile[data-active='true'] {
border-color: #f5a524;
box-shadow: 0 0 0 2px #f5a524;
}
.lib-modal-media {
position: relative;
aspect-ratio: 1 / 1;
min-height: 120px;
width: 100%;
overflow: hidden;
background: #18181b;
}
.lib-modal-media > img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.lib-modal-fallback {
display: block;
width: 100%;
height: 100%;
background: #27272a;
}
.lib-modal-track {
display: flex;
height: 100%;
width: 100%;
align-items: flex-end;
gap: 2px;
padding: 16px;
}
.lib-modal-bar {
flex: 1;
border-radius: 999px;
background: rgba(245, 165, 36, 0.7);
}
.lib-modal-kind {
position: absolute;
top: 8px;
left: 8px;
border-radius: 4px;
background: #000a;
padding: 2px 6px;
color: #e4e4e7;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.lib-modal-caption {
padding: 6px 8px 8px;
}
.lib-modal-name {
margin: 0;
overflow: hidden;
color: #f4f4f5;
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.lib-modal-meta {
margin: 2px 0 0;
overflow: hidden;
color: #71717a;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.lib-modal-empty {
grid-column: 1 / -1;
color: #9a9588;
font-size: 0.9rem;
}
.lib-modal-detail {
flex: 0 0 min(320px, 38%);
min-width: 0;
min-height: 0;
overflow: auto;
border-left: 1px solid #2a2a2e;
padding: 14px 16px;
}
@media (max-width: 767px) {
.lib-modal-detail {
flex: 0 0 auto;
max-height: 40%;
border-left: 0;
border-top: 1px solid #2a2a2e;
}
}
.lib-modal-detail-name {
margin: 0 0 10px;
font-weight: 600;
}
.lib-modal-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.lib-modal-actions button {
border: 1px solid #2a2a2e;
border-radius: 6px;
background: transparent;
color: inherit;
padding: 6px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.lib-modal-actions button.primary {
border-color: #f5a524;
background: #f5a524;
color: #1a1204;
font-weight: 700;
}
.lib-modal-actions button.danger { color: #fca5a5; }
</style>
+891
View File
@@ -0,0 +1,891 @@
<template>
<div class="m-shell" data-mobile="1" :data-queue-collapsed="queueRailCollapsed ? '1' : undefined">
<header class="m-header">
<span class="m-brand">{{ ctx.instanceName }}</span>
<label class="m-crumb">
<select v-model="ctx.form.folderId" aria-label="Folder">
<option v-for="f in ctx.library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
</select>
</label>
<span class="m-grow" />
<span class="m-gpu" :data-tone="ctx.gpu.tone" :title="ctx.gpu.detail">● {{ ctx.gpuChipLabel }}</span>
<button type="button" class="m-chip m-queue-chip-portrait" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
<button type="button" class="m-chip m-queue-chip-landscape" :aria-expanded="queueRailCollapsed ? 'false' : 'true'" @click="queueRailCollapsed=!queueRailCollapsed">
Queue {{ ctx.queueCount }} {{ queueRailCollapsed ? '▸' : '▾' }}
</button>
<details class="m-more">
<summary aria-label="More">⋯</summary>
<div class="m-more-menu">
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
<NuxtLink to="/prompt-builder" @click="closeMenus">Prompt Builder</NuxtLink>
<NuxtLink to="/queue" @click="closeMenus">Queue</NuxtLink>
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
</div>
</details>
</header>
<div class="m-chips" role="tablist" aria-label="Tasks">
<button
v-for="task in ctx.tasks"
:key="task"
type="button"
class="m-chip capitalize"
:class="{ on: ctx.form.mode===task }"
:aria-pressed="ctx.form.mode===task"
@click="ctx.chooseTask(task)"
>{{ task }}</button>
</div>
<div v-if="ctx.form.mode!=='music'" class="m-chips" role="group" aria-label="Model">
<button
v-for="opt in ctx.engineOptions"
:key="opt.value"
type="button"
class="m-chip"
:class="{ on: ctx.form.engine===opt.value }"
:disabled="opt.disabled"
:title="opt.hint || undefined"
:aria-pressed="ctx.form.engine===opt.value"
@click="!opt.disabled && (ctx.form.engine=opt.value)"
>{{ opt.label }}</button>
</div>
<div v-else class="m-chips"><span class="m-chip on">YuE2</span></div>
<div
class="m-stage"
:class="{ 'm-stage--veiled': ctx.selected && ctx.isHidden(ctx.selected) }"
@touchstart.passive="onStageTouchStart"
@touchend.passive="onStageTouchEnd"
>
<div class="m-stage-chrome" aria-label="Stage controls">
<button
type="button"
class="m-icon-btn m-library"
:aria-expanded="ctx.libraryOpen ? 'true' : 'false'"
aria-label="Library"
@click="ctx.openLibrary()"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="4" width="7" height="7" rx="1.5" />
<rect x="14" y="4" width="7" height="7" rx="1.5" />
<rect x="3" y="13" width="7" height="7" rx="1.5" />
<rect x="14" y="13" width="7" height="7" rx="1.5" />
</svg>
<span v-if="ctx.familyStillCount" class="m-library-badge">{{ ctx.familyStillCount > 99 ? '99+' : ctx.familyStillCount }}</span>
</button>
<div class="m-stage-chrome-end">
<button
v-if="ctx.selected && ctx.selected.kind!=='track'"
type="button"
class="m-icon-btn"
aria-label="More"
:aria-expanded="sheet==='actions' ? 'true' : 'false'"
@click.stop="toggleSheet('actions')"
>•••</button>
<button
v-if="ctx.selected"
type="button"
class="m-icon-btn m-eye"
:aria-label="ctx.eyeOpen ? 'Hide output' : 'Show output'"
@click.stop="ctx.toggleReveal()"
>
<img :src="ctx.eyeOpen ? '/icons/display/eye.svg' : '/icons/display/eye-off.svg'" alt="" width="20" height="20">
</button>
</div>
</div>
<button
v-if="(ctx.family || []).length > 1"
type="button"
class="m-nav m-nav-prev"
aria-label="Previous"
@click.stop="ctx.stepFamily(-1)"
>‹</button>
<button
v-if="(ctx.family || []).length > 1"
type="button"
class="m-nav m-nav-next"
aria-label="Next"
@click.stop="ctx.stepFamily(1)"
>›</button>
<p
v-show="ctx.selected && ctx.isHidden(ctx.selected)"
class="m-hidden-note"
>{{ ctx.selected?.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
<template v-if="ctx.selected?.kind==='track'">
<div v-show="!ctx.isHidden(ctx.selected)" class="m-audio">
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
</div>
</template>
<video
v-else-if="ctx.selected?.kind==='clip'"
v-show="!ctx.isHidden(ctx.selected)"
:key="ctx.selected.id"
:src="`/api/library/clips/${ctx.selected.id}/video`"
controls
playsinline
/>
<img
v-else-if="ctx.selected"
v-show="!ctx.isHidden(ctx.selected)"
:key="ctx.selected.id+'-'+(ctx.stagePreviewNonce||0)"
class="m-stage-still"
:src="ctx.stagePreviewSrc || ctx.stillPreviewUrl(ctx.selected.id)"
alt="Selected still"
decoding="async"
@error="ctx.onStagePreviewError?.()"
>
<p v-else class="m-muted">Open Library to pick a still or clip</p>
</div>
<Studio2LibraryModal
:open="!!ctx.libraryOpen"
:assets="ctx.family || []"
:selected="ctx.selected"
:folder-id="ctx.form?.folderId"
:preview-hidden="!ctx.eyeOpen"
:is-hidden="ctx.isHidden"
@close="ctx.closeLibrary()"
@select="ctx.selectOutput"
@use-image="ctx.onLibraryUseImage"
@use-prompt="ctx.onLibraryUsePrompt"
@copy-prompt="ctx.onLibraryCopyPrompt"
@copy-raw-prompt="ctx.onLibraryCopyRawPrompt"
@load-input="ctx.onLibraryLoadInput"
@load-input-raw="ctx.onLibraryLoadInputRaw"
@delete="ctx.onLibraryDelete"
@toggle-preview="ctx.toggleReveal()"
/>
<div class="m-dock">
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Photos</button>
<button type="button" :class="{ on: sheet==='prompt' }" @click="toggleSheet('prompt')">Prompt</button>
<button type="button" :class="{ on: sheet==='look' }" @click="toggleSheet('look')">Look</button>
<button type="button" :class="{ on: sheet==='gen' }" @click="toggleSheet('gen')">Gen</button>
<button type="button" class="m-cta" :disabled="ctx.submitting" @click="ctx.queue">{{ ctx.submitting ? '…' : 'Generate' }}</button>
</div>
<!-- Sheets -->
<div v-if="sheet" class="m-sheet-backdrop" @click="sheet=''" />
<aside v-if="sheet==='actions'" class="m-sheet m-sheet-actions" role="dialog" aria-label="Still actions">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Actions</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body m-actions-list">
<button type="button" @click="runAction('use-image')">Use image</button>
<button type="button" @click="runAction('use-prompt')">Use prompt</button>
<button type="button" @click="runAction('copy')">Copy prompt</button>
<button type="button" @click="runAction('copy-raw')">Copy raw prompt</button>
<button type="button" @click="runAction('load-input')">Load input</button>
<button type="button" @click="runAction('load-input-raw')">Load input raw</button>
<button type="button" class="m-danger" @click="runAction('delete')">Delete</button>
</div>
</aside>
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Photos">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Photos</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
<template v-if="ctx.form.mode==='music' || !ctx.photosPlanState?.show">
<p class="m-muted">No photos for this task.</p>
</template>
<template v-else-if="ctx.photosPlanState.handoffReadonly">
<p class="m-muted">Continues from last frame</p>
</template>
<template v-else>
<div class="m-photos-head">
<button type="button" class="m-link" @click="ctx.toggleHideInputs()">
{{ ctx.hideInputs ? 'Show inputs' : 'Hide inputs' }}
</button>
</div>
<p v-if="!ctx.form.photos?.length && ctx.photosPlanState.emptyHint" class="m-muted">{{ ctx.photosPlanState.emptyHint }}</p>
<Studio2AssetSlot
v-for="(card, index) in ctx.form.photos"
:key="'m-photo-'+index+'-'+card.stillId"
:asset="ctx.stillById(card.stillId)"
:hidden="!!ctx.hideInputs"
:role="card.role"
:role-options="ctx.photoRoleOptions"
@pick="ctx.setPicker('photo:'+index)"
@remove="ctx.removePhoto(index)"
@mention="ctx.mentionPhoto(index)"
@update:role="ctx.setPhotoRole(index, $event)"
/>
<button v-if="(ctx.form.photos?.length||0) < ctx.photosPlanState.max" type="button" class="m-amber" @click="ctx.addPhoto">+ Add photo</button>
<p v-if="ctx.photoRoleWarning" class="m-hint">{{ ctx.photoRoleWarning }}</p>
<template v-if="ctx.showIdentityLocks">
<label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label>
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label>
</template>
<p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p>
</template>
</div>
</aside>
<aside v-if="sheet==='prompt'" class="m-sheet" role="dialog" aria-label="Prompt">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Prompt</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
<template v-if="ctx.form.mode==='music'">
<label class="m-label">Genre / style tags<textarea v-model="ctx.music.tags" rows="3" class="m-area" /></label>
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
</template>
<template v-else>
<label class="m-label">
<span class="m-label-row">Prompt<button type="button" class="m-link" @click.stop="ctx.clearPrompt()">Clear</button></span>
<textarea
:value="ctx.form.promptSections.action"
rows="5"
class="m-area"
placeholder="Describe the image or the shot."
readonly
@focus="openPromptEditor"
@click="openPromptEditor"
/>
</label>
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label>
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label>
<p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
<div>
<span class="m-label">Negative prompt</span>
<div class="m-neg">
<span v-for="chip in ctx.negativeChips" :key="chip" class="m-neg-chip">
{{ chip }}
<button type="button" :aria-label="'Remove '+chip" @click="ctx.removeNegative(chip)">×</button>
</span>
</div>
<div class="m-neg-add">
<input
:value="ctx.negativeDraft"
type="text"
class="m-input"
placeholder="Add negative chip"
@input="ctx.setNegativeDraft(($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="ctx.addNegative"
>
<button type="button" @click="ctx.addNegative">Add</button>
</div>
</div>
<Studio2StyleChips v-if="!ctx.video" v-model="ctx.form.imageStyles" />
</template>
<p v-if="ctx.error" class="m-err">{{ ctx.error }}</p>
</div>
</aside>
<aside v-if="sheet==='look'" class="m-sheet" role="dialog" aria-label="Look">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Look</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
<template v-if="ctx.form.mode==='music'">
<label class="m-label">Seed<input v-model="ctx.music.seed" class="m-input" placeholder="random"></label>
<label class="m-label">Track name<input v-model="ctx.music.name" class="m-input" placeholder="Optional"></label>
</template>
<template v-else>
<div>
<span class="m-label">LoRA</span>
<LoraSelect v-model="ctx.form.settings.loraStack" compact :names="ctx.pickerLoras" :use="ctx.currentLoraUse" :clip-strength="ctx.form.engine!=='qwen21' && !ctx.video" filename-subtitle :aliases="ctx.loraAliases" :triggers="ctx.loraTriggers" missing-label="None" @insert-trigger="ctx.form.promptSections.action += ' '+$event" />
</div>
<label class="m-label">Seed
<div class="m-seed">
<input
:value="ctx.form.settings.seed ?? ''"
type="number"
min="0"
max="2147483647"
class="m-input"
@input="ctx.form.settings.seed=Number(($event.target as HTMLInputElement).value);ctx.form.settings.seedMode='locked'"
>
<button type="button" class="m-dice" aria-label="Random seed" @click="ctx.randomizeSeed">🎲</button>
</div>
</label>
<label class="m-label">Aspect
<select v-model="ctx.form.settings.aspect" class="m-input">
<option v-for="opt in ctx.aspectOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
</select>
</label>
<label class="m-label">Steps<input v-model.number="ctx.form.settings.steps" type="number" class="m-input"></label>
<label class="m-label">CFG<input v-model.number="ctx.form.settings.cfg" type="number" class="m-input"></label>
</template>
</div>
</aside>
<aside v-if="sheet==='gen'" class="m-sheet" role="dialog" aria-label="Gen extras">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Gen</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
<template v-if="ctx.form.mode==='music'">
<label class="m-label">Target length (sec)<input v-model.number="ctx.music.duration" type="number" min="15" max="150" class="m-input"></label>
<p class="m-hint">Caps semantic tokens (~25/s). Actual length follows the model end token.</p>
</template>
<template v-else-if="ctx.video">
<label class="m-label">Duration (sec)<input v-model.number="ctx.form.settings.duration" type="number" class="m-input"></label>
<label class="m-label">FPS<input v-model.number="ctx.form.settings.fps" type="number" class="m-input"></label>
<label class="m-check"><input v-model="ctx.form.settings.turbo" type="checkbox"> Turbo</label>
<Studio2PromptControls v-model="ctx.form.promptSections" />
<details class="m-details">
<summary>Queue more shots</summary>
<textarea :value="ctx.batchText" rows="4" class="m-area" placeholder="First additional prompt&#10;&#10;Next" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
</details>
</template>
<template v-else>
<p class="m-muted">Image extras live in Look (steps / CFG / seed / aspect).</p>
<details v-if="ctx.form.mode==='edit'" class="m-details">
<summary>Queue extra edit passes</summary>
<textarea :value="ctx.batchText" rows="4" class="m-area" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
</details>
</template>
</div>
</aside>
<aside v-if="sheet==='queue'" class="m-sheet m-queue-sheet-portrait" role="dialog" aria-label="Queue">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Queue</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
<div
v-for="card in ctx.queueCards"
:key="card.id"
class="m-job"
:class="{ on: ctx.focusedJobId===card.id }"
@click="ctx.focusQueueCard(card)"
>
<div class="m-job-id">{{ card.code }}</div>
<div class="m-job-title">{{ card.title }}</div>
<div class="m-muted">{{ card.meta }}</div>
<div class="m-job-status">
<span>● {{ card.statusLabel }}</span>
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
<button v-else-if="card.dismissable" type="button" @click.stop="ctx.dismissFinishedCard(card)">Dismiss</button>
</div>
<p v-if="card.resultText" class="m-muted" style="white-space:pre-wrap">{{ card.resultText }}</p>
<div v-if="card.resultText" class="m-job-status">
<button type="button" @click.stop="ctx.copyCaption(card.resultText)">Copy</button>
<button type="button" @click.stop="ctx.useCaptionAsPrompt(card.resultText)">Use as prompt</button>
</div>
<div v-if="card.progress" class="m-job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
<progress
class="m-job-progress-bar"
max="100"
:value="card.progress.indeterminate ? undefined : card.progress.percent"
/>
<span v-if="card.elapsed" class="m-muted">{{ card.elapsed }}</span>
</div>
</div>
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
</div>
</aside>
<aside v-show="!queueRailCollapsed" class="m-queue-rail" aria-label="Queue">
<div class="m-sheet-head">
<strong>Queue</strong>
<button type="button" @click="queueRailCollapsed=true">Hide</button>
</div>
<div class="m-sheet-body">
<div
v-for="card in ctx.queueCards"
:key="'rail-'+card.id"
class="m-job"
:class="{ on: ctx.focusedJobId===card.id }"
@click="ctx.focusQueueCard(card)"
>
<div class="m-job-id">{{ card.code }}</div>
<div class="m-job-title">{{ card.title }}</div>
<div class="m-muted">{{ card.meta }}</div>
<div class="m-job-status">
<span>● {{ card.statusLabel }}</span>
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
<button v-else-if="card.dismissable" type="button" @click.stop="ctx.dismissFinishedCard(card)">Dismiss</button>
</div>
</div>
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
</div>
</aside>
<div
v-if="promptEditorOpen"
class="m-prompt-editor"
role="dialog"
aria-modal="true"
aria-label="Edit prompt"
>
<header class="m-prompt-editor-head">
<strong>Prompt</strong>
<button type="button" @click="closePromptEditor">Done</button>
</header>
<textarea
ref="promptEditorEl"
v-model="promptEditorDraft"
class="m-prompt-editor-area"
placeholder="Describe the image or the shot."
/>
</div>
</div>
</template>
<script setup lang="ts">
export type Studio2MobileCtx = Record<string, any>
const props = defineProps<{ ctx: Studio2MobileCtx }>()
const sheet = ref('')
const queueRailCollapsed = ref(false)
const touchStart = ref<{ x: number; y: number } | null>(null)
const promptEditorOpen = ref(false)
const promptEditorDraft = ref('')
const promptEditorEl = ref<HTMLTextAreaElement | null>(null)
function openPromptEditor() {
promptEditorDraft.value = String(props.ctx.form?.promptSections?.action || '')
promptEditorOpen.value = true
nextTick(() => {
promptEditorEl.value?.focus()
const el = promptEditorEl.value
if (el) {
const n = el.value.length
el.setSelectionRange(n, n)
}
})
}
function closePromptEditor() {
if (props.ctx.form?.promptSections) props.ctx.form.promptSections.action = promptEditorDraft.value
promptEditorOpen.value = false
}
function toggleSheet(id: string) {
sheet.value = sheet.value === id ? '' : id
}
function closeMenus() {
sheet.value = ''
if (promptEditorOpen.value) closePromptEditor()
}
function runAction(kind: 'use-image' | 'use-prompt' | 'copy' | 'copy-raw' | 'load-input' | 'load-input-raw' | 'delete') {
sheet.value = ''
if (kind === 'use-image') props.ctx.applyUseImage?.()
else if (kind === 'use-prompt') props.ctx.applyUsePrompt?.()
else if (kind === 'copy') props.ctx.copySelectedPrompt?.()
else if (kind === 'copy-raw') props.ctx.copySelectedRawPrompt?.()
else if (kind === 'load-input') props.ctx.loadInputUsed?.()
else if (kind === 'load-input-raw') props.ctx.loadInputRaw?.()
else if (kind === 'delete') props.ctx.deleteOutput?.()
}
function onStageTouchStart(e: TouchEvent) {
const t = e.changedTouches[0]
if (!t) return
touchStart.value = { x: t.clientX, y: t.clientY }
}
function onStageTouchEnd(e: TouchEvent) {
const start = touchStart.value
touchStart.value = null
const t = e.changedTouches[0]
if (!start || !t || (props.ctx.family || []).length < 2) return
const dx = t.clientX - start.x
const dy = t.clientY - start.y
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.4) return
props.ctx.stepFamily?.(dx < 0 ? 1 : -1)
}
watch(() => props.ctx.form.mode, () => { sheet.value = '' })
</script>
<style scoped>
.m-shell {
--bg: #0b0b0d;
--panel: #141416;
--line: #2a2a2e;
--amber: #f5a524;
--amber-on: #1a1204;
--text: #f3f1ea;
--muted: #9a9588;
position: relative;
display: flex;
height: 100dvh;
max-height: 100dvh;
flex-direction: column;
overflow: hidden;
background: var(--bg);
color: var(--text);
font: 14px ui-sans-serif, system-ui, sans-serif;
}
.m-header {
display: flex;
height: 48px;
flex-shrink: 0;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--line);
padding: 0 12px;
}
.m-brand { font-weight: 700; }
.m-crumb select {
max-width: 7rem;
border: 0;
background: transparent;
color: var(--muted);
font-size: 12px;
}
.m-grow { flex: 1; }
.m-gpu {
border: 1px solid #2d4a2a;
border-radius: 999px;
padding: 3px 8px;
color: #b8e0a8;
font-size: 11px;
}
.m-gpu[data-tone='busy'] { border-color: color-mix(in srgb, var(--amber) 40%, transparent); color: var(--amber); }
.m-chips {
display: flex;
flex-shrink: 0;
gap: 6px;
overflow-x: auto;
padding: 8px 12px;
}
.m-chip {
flex: 0 0 auto;
border: 1px solid var(--line);
border-radius: 8px;
background: #1a1a1d;
padding: 6px 10px;
color: var(--muted);
font: inherit;
}
.m-chip.on, .m-dock button.on {
border-color: var(--amber);
background: var(--amber);
color: var(--amber-on);
font-weight: 700;
}
.m-chip:disabled { opacity: 0.45; }
.m-more { position: relative; }
.m-more summary { list-style: none; cursor: pointer; padding: 4px 8px; color: var(--muted); }
.m-more-menu {
position: absolute;
right: 0;
z-index: 20;
display: flex;
min-width: 8rem;
flex-direction: column;
gap: 4px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
padding: 8px;
}
.m-more-menu a { color: var(--text); text-decoration: none; padding: 6px 8px; }
.m-stage {
position: relative;
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
align-items: center;
justify-content: center;
overflow: hidden;
background: #080808;
}
.m-stage > img,
.m-stage > video,
.m-stage-still {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.m-stage-still--veiled {
opacity: 0;
pointer-events: none;
}
.m-hidden-note {
position: absolute;
z-index: 3;
margin: 0;
color: var(--muted);
font-size: var(--type-sm);
pointer-events: none;
}
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
.m-stage-chrome {
position: absolute;
top: calc(8px + env(safe-area-inset-top, 0px));
right: 8px;
left: 8px;
z-index: 5;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
pointer-events: none;
}
.m-stage-chrome > *,
.m-stage-chrome-end {
pointer-events: auto;
}
.m-stage-chrome-end {
display: flex;
align-items: center;
gap: 8px;
}
.m-icon-btn {
position: relative;
display: grid;
width: 44px;
height: 44px;
flex-shrink: 0;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: #000a;
color: var(--text);
font: inherit;
font-size: 16px;
line-height: 1;
}
.m-library-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 18px;
border-radius: 999px;
background: var(--amber);
color: var(--amber-on);
font-size: 10px;
font-weight: 700;
line-height: 16px;
padding: 0 4px;
text-align: center;
}
.m-nav {
position: absolute;
top: 50%;
z-index: 4;
display: grid;
width: 44px;
height: 44px;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: #000a;
color: var(--text);
font-size: 28px;
line-height: 1;
opacity: 0.7;
transform: translateY(-50%);
}
.m-nav-prev { left: 8px; }
.m-nav-next { right: 8px; }
.m-photos-head { margin-bottom: 8px; }
.m-sheet-actions {
max-height: min(50vh, 100%);
}
.m-actions-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.m-actions-list button {
min-height: 48px;
border: 1px solid var(--line);
border-radius: 8px;
background: #1a1a1d;
color: var(--text);
font: inherit;
text-align: left;
padding: 0 14px;
}
.m-actions-list .m-danger { color: #fca5a5; }
.m-dock {
display: grid;
grid-template-columns: repeat(4, 1fr) 120px;
flex-shrink: 0;
gap: 6px;
border-top: 1px solid var(--line);
background: var(--panel);
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
}
.m-dock button {
height: 48px;
border: 1px solid var(--line);
border-radius: 8px;
background: #1a1a1d;
color: var(--text);
font: inherit;
}
.m-cta {
background: var(--amber) !important;
color: var(--amber-on) !important;
font-weight: 700 !important;
}
.m-cta:disabled { opacity: 0.5; }
.m-sheet-backdrop {
position: absolute;
inset: 0;
z-index: 30;
background: #0008;
}
.m-sheet {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 31;
display: flex;
max-height: min(85vh, 100%);
flex-direction: column;
border-top: 1px solid var(--line);
border-radius: 16px 16px 0 0;
background: var(--panel);
padding: 10px 14px 20px;
}
.m-handle {
display: block;
width: 40px;
height: 4px;
margin: 4px auto 12px;
border: 0;
border-radius: 4px;
background: #444;
}
.m-sheet-head {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.m-sheet-head button { border: 0; background: transparent; color: var(--amber); }
.m-sheet-body {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.m-label { display: block; font-size: 11px; color: var(--muted); }
.m-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.m-area, .m-input {
width: 100%;
margin-top: 4px;
border: 1px solid var(--line);
border-radius: 8px;
background: #1a1a1d;
padding: 8px;
color: var(--text);
font: inherit;
}
.m-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
.m-hint, .m-muted { color: var(--muted); font-size: 12px; }
.m-amber { color: var(--amber); font-size: 12px; }
.m-err { color: #fca5a5; font-size: 12px; }
.m-neg { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.m-neg-chip {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid #5a4a28;
border-radius: 999px;
background: #2a2418;
padding: 4px 10px;
color: #e8c37a;
font-size: 12px;
}
.m-neg-chip button { border: 0; background: transparent; color: inherit; }
.m-neg-add { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.m-neg-add .m-input { flex: 1; margin-top: 0; }
.m-seed { display: flex; gap: 8px; margin-top: 4px; }
.m-seed .m-input { margin-top: 0; flex: 1; }
.m-dice {
width: 40px;
height: 40px;
border: 1px solid var(--line);
border-radius: 8px;
background: #1a1a1d;
}
.m-details { border: 1px solid var(--line); border-radius: 8px; padding: 8px; }
.m-job {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
cursor: pointer;
}
.m-job.on { border-color: var(--amber); border-width: 2px; }
.m-job-id { color: var(--muted); font-size: 12px; }
.m-job-title { font-weight: 600; }
.m-job-status { display: flex; justify-content: space-between; margin-top: 6px; font-size: 12px; }
.m-job-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.m-job-progress-bar { flex: 1; height: 4px; accent-color: var(--amber); }
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
.m-prompt-editor {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
flex-direction: column;
background: #0e0e10;
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0);
}
.m-prompt-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.m-prompt-editor-head button {
border: 0;
background: transparent;
color: var(--amber);
font-size: 16px;
font-weight: 600;
}
.m-queue-chip-landscape { display: none; }
.m-queue-rail { display: none; }
@media (orientation: landscape) {
.m-queue-chip-portrait,
.m-queue-sheet-portrait { display: none !important; }
.m-queue-chip-landscape { display: inline-flex; align-items: center; }
.m-shell { position: relative; }
.m-queue-rail {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 25;
display: flex;
width: min(42vw, 16rem);
flex-direction: column;
border-left: 1px solid var(--line);
background: var(--panel);
}
.m-shell[data-queue-collapsed='1'] .m-queue-rail { display: none; }
.m-queue-rail .m-sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
}
.m-queue-rail .m-sheet-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 10px 12px;
}
.m-stage { padding-right: 0; }
.m-shell:not([data-queue-collapsed='1']) .m-stage,
.m-shell:not([data-queue-collapsed='1']) .m-dock,
.m-shell:not([data-queue-collapsed='1']) .m-chips {
margin-right: min(42vw, 16rem);
}
}
.m-prompt-editor-area {
flex: 1;
width: 100%;
margin: 0;
padding: 16px;
border: 0;
resize: none;
background: #0e0e10;
color: #f3f1ea;
font: inherit;
font-size: 17px;
line-height: 1.45;
outline: none;
}
</style>
+3 -1
View File
@@ -4,7 +4,8 @@
<div class="flex flex-wrap gap-2"><button v-for="(beat,i) in model.cameraBeats" :key="i" type="button" class="rounded-lg border px-3 py-2" :class="active===i?'border-amber-300 text-amber-200':'border-white/20'" @click="active=i">{{ i+1 }} · {{ beat.moves.join(' + ') }} · {{ beat.durationSec == null ? 'to end' : `${beat.durationSec}s` }}</button></div>
<div v-if="current" class="space-y-3 rounded-xl border border-white/15 p-3">
<div class="flex flex-wrap gap-2"><button v-for="move in cameraMoves" :key="move" type="button" :aria-pressed="current.moves.includes(move)" class="rounded-full border px-3 py-1 text-sm" :class="current.moves.includes(move)?'border-amber-300 bg-amber-300/10 text-amber-200':'border-white/15'" @click="model.cameraBeats[active]=toggleMove(current,move)">{{ move.replaceAll('-',' ') }}</button></div>
<div class="flex flex-wrap gap-3"><label>Amplitude<select v-model="current.amplitude" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['subtle','moderate','large']" :key="v">{{ v }}</option></select></label><label>Speed<select v-model="current.speed" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['slow','medium','fast']" :key="v">{{ v }}</option></select></label><label>Duration<select v-model="current.durationSec" class="block rounded bg-zinc-800 p-2"><option :value="null">To end</option><option v-for="n in 30" :key="n" :value="n/2">{{ n/2 }}s</option></select></label><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
<div v-if="!isHold" class="flex flex-wrap gap-3"><label>Amplitude<select v-model="current.amplitude" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['subtle','moderate','large']" :key="v">{{ v }}</option></select></label><label>Speed<select v-model="current.speed" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['slow','medium','fast']" :key="v">{{ v }}</option></select></label><label>Duration<select v-model="current.durationSec" class="block rounded bg-zinc-800 p-2"><option :value="null">To end</option><option v-for="n in 30" :key="n" :value="n/2">{{ n/2 }}s</option></select></label><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
<div v-else class="flex flex-wrap gap-3"><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
</div>
<div class="flex gap-4 text-sm text-amber-200"><button @click="addBeat">+ Camera beat</button><button @click="model.cameraBeats=[];model.camera='';active=0">Clear camera</button></div>
</fieldset>
@@ -17,6 +18,7 @@
import {cameraMoves,soundscapeOptions,normalizeBeat,toggleMove} from '~/shared/studio2/prompt.mjs'
const model=defineModel<any>({required:true}),active=ref(0)
const current=computed(()=>model.value.cameraBeats[active.value])
const isHold=computed(()=>!!current.value?.moves?.includes?.('hold'))
function addBeat(){const last=model.value.cameraBeats.at(-1);if(last?.durationSec==null&&last)last.durationSec=2;model.value.cameraBeats.push(normalizeBeat());active.value=model.value.cameraBeats.length-1}
function toggleSound(sound:string){const chips=model.value.soundscapeChips;model.value.soundscapeChips=chips.includes(sound)?chips.filter((s:string)=>s!==sound):[...chips,sound]}
</script>
+29
View File
@@ -0,0 +1,29 @@
/** Portrait shell gate. SSR defaults desktop to avoid hydration flip. */
export function useIsMobile() {
const isMobile = useState('studio2.isMobile', () => false)
function read() {
if (!import.meta.client) return false
return window.matchMedia('(max-width: 767px)').matches
}
function sync() {
isMobile.value = read()
}
if (import.meta.client) {
onMounted(() => {
sync()
const mq = window.matchMedia('(max-width: 767px)')
const onChange = () => { isMobile.value = mq.matches }
mq.addEventListener?.('change', onChange)
window.addEventListener('resize', sync)
onBeforeUnmount(() => {
mq.removeEventListener?.('change', onChange)
window.removeEventListener('resize', sync)
})
})
}
return { isMobile }
}
+52
View File
@@ -0,0 +1,52 @@
# Qwen Image 2.1 (engine `qwen21`) — host weights + Comfy graph
## Host paths (this RTX 5080 box)
| Role | Resolved path |
|---|---|
| ComfyUI root (Klein / host agent `ComfyUI (1)`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI` |
| Models root (Desktop Shared via `shared_model_paths.yaml`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Shared\models` |
Weights must land under Shared so every Desktop instance sees them:
- `diffusion_models\qwen-image-2.1-Q8_0.gguf` (~7.59 GiB) — from `abenzerps/Qwen-Image-2.1-Uncensored-GGUF`
- `text_encoders\qwen3vl_8b_int8_convrot.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (INT8; BF16 8B VL OOMs on 16 GB)
- `vae\qwen_image_2.1_vae_bf16.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (**not** the old Qwen-Image 1.0 VAE)
Custom node: `custom_nodes\ComfyUI-GGUF` (`UnetLoaderGGUF`). Do not install a second GGUF pack.
## Setup
```powershell
powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
```
DiT download uses the exact host invocation:
```powershell
hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
```
That lands in the Hugging Face hub cache; the setup script copies it into Shared `diffusion_models`. The abenzerps GGUF ships with `kv_count=0` (no `general.architecture`) **and** Q8_0-quantized 1D RMSNorm weights (logical 128 → packed 136), which breaks Comfy’s fused `rms_rope`. `scripts/tag-qwen21-gguf.py` rewrites the file with `general.architecture=qwen_image` and promotes small/1D tensors to F32 so city96 `UnetLoaderGGUF` can load it.
Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false`). Confirm `object_info` lists `UnetLoaderGGUF` and the three filenames.
## App
- Engine key: `qwen21` · UI label: **Qwen 2.1**
- **Generate** (T2I) and **Edit** (same checkpoint, second graph). Compose / Iterate / Video / Extend / Music stay disabled unless a graph exists.
- Edit maps Photos roles → graph sockets: Photo to change → `images.image_1`, Outfit / object or Extra → `image_2`. The inspector never shows `<image1>`; the runner injects tags into the TextEncode string on submit (and expands Mention phrases like “this photo” / “the outfit photo”). If `<image1>` is still missing, prepend the keep-identity stanza. Klein hero-ref is off for Qwen.
- Face / outfit locks stay Klein semantics — they do not drive Qwen slots.
- Sampler defaults: euler / simple / cfg **1** / steps **25**. Edit uses `QwenImage21Cache` (device auto, dtype int8). T2I uses `ModelSamplingAuraFlow` shift **3.1**.
- Default Generate canvas follows the bench Aspect control on the Qwen-safe ~1 MP grid (`EmptyLatentImage`): 1:1 → 1024×1024, 16:9 → 1536×864, 9:16 → 864×1536 (and the other table rows). PE `wh_ratio` is advisory only and never sizes the canvas. Do not use native 2K bins on 16 GB.
- Edit follows `image_1` via the encode node’s latent (resolution long-edge ~1024). Do not inject a picker EmptyLatentImage onto the edit sampler.
- Optional **Enhance prompt** (off by default): runs a separate PE-only Comfy graph (`CLIPLoader` + rewrite node), then frees VRAM and queues the existing T2I/Edit graph with the rewritten prompt. Never loads PE CLIP + DiT together on 16 GB. Fail closed if `parse_ok` is false or the rewrite is empty.
- Generate + Enhance → PE-T2I only (`pe_t2i`). No images.
- Edit + Enhance → PE-I2I only (`pe_i2i`) with the same Start still on `image_1`. After PE, the sample prompt is always the keep-identity stanza + typed instruction first (`<image1>` in front). A PE rewrite is appended only when it is an edit directive; T2I-style observer captions (`The image is…`) are dropped (`enhance.skippedAsDescribe`) and the stanza + raw remain. Never let the PE chunk be the entire prompt. Library stores `prompt` (TextEncode string) and `promptRaw` (Typed). Never fall back to PE-T2I on Edit.
- If PE refuses or returns an empty/gutted rewrite, the job continues with `promptRaw` (Edit still gets the keep-identity stanza). Details shows `Enhance skipped (model refused) — used your prompt.` (`enhance.refused`).
- Host PE system prompts live in `host/qwen21-pe-prompts/` and are copied onto the node pack by `scripts/setup-qwen21.ps1` (official steps + Adult appendix). Edit prompt must lead with an operation and `<image1>` — never “The image is a photograph of…”. Do not swap Heretic PE weights on 16 GB.
- PE weights (int8 only): `text_encoders\qwen3.5_9b_qwen_image_2.1_pe_{t2i,i2i}.int8_convrot.safetensors` — do not replace the image TE `qwen3vl_8b_int8_convrot.safetensors`.
- Optional **Turbo** (off by default): Viggle DMD LoRA `Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors` on the same Q8 GGUF via `ViggleTurboLora` + `ViggleTurboSigmas` (`1.0, 0.9375, 0.875, 0.75, 0.5, 0.25`), 6 steps, CFG 1, empty negative. Not a new engine. Enhance prompt stays compatible and is recommended with Turbo.
- Custom node: `ComfyUI-Viggle-Turbo` (`viggle_turbo.py`). Do not merge the LoRA with stock `LoraLoaderModelOnly` (lossy on int8/bf16).
Graphs: `server/assets/studio2_qwen21_t2i.json`, `server/assets/studio2_qwen21_edit.json`, `server/assets/studio2_qwen21_t2i_turbo.json`, `server/assets/studio2_qwen21_edit_turbo.json`, `server/assets/studio2_qwen21_pe_t2i.json`, `server/assets/studio2_qwen21_pe_edit.json`.
@@ -0,0 +1,213 @@
# Edit Prompt Enhancer — General (v2, 精简版)
**FIRST — there are TWO separate language decisions. Do NOT conflate them.**
**(A) Language of the rewritten prompt's DESCRIPTIVE prose — every word OUTSIDE double quotes (the description you write for the diffusion model, NOT the text painted into the image). This decision is final and non-negotiable:**
- User instruction is in Chinese → write the description in Chinese.
- User instruction is in English → write the description in English.
- User instruction is in ANY other language (Japanese, Korean, French, Spanish, Thai, etc.) → write the description in English.
**(B) Language of the TEXT THAT WILL BE RENDERED INTO THE OUTPUT IMAGE — the content INSIDE double quotes. Decide it in this strict priority order:**
1. If the user's instruction gives the exact text to write, OR names a target language for the text (e.g. "改成'夏日特惠'", "把标题写成英文", "add a Japanese title", "write the caption in Thai") → render exactly that text / in exactly that specified language.
2. Otherwise, if the input image already contains text → render in the DOMINANT language of the image's existing text — even when the instruction is written in a different language.
3. Otherwise (the image contains no text AND the instruction names no target language) → render in the language of the user's instruction itself — including Japanese, Korean, Thai, Arabic, French, etc. Do NOT force it to English.
Worked example: image is mostly Thai, instruction is in English asking to add/redesign a title without giving the exact words or a language → the rendered (quoted) text must be **Thai** (the image's dominant language), while the surrounding description (A) is still written in English.
Two reinforcements on decision (B): all rendered (quoted) text must be **monolingual** — do not mix Chinese and English inside the quotes and do not emit a bilingual pair unless the user explicitly asks for one. And **genre never overrides input language**: a "spec sheet / cinematic data-document / storyboard / technical parameter" look is achieved through layout and typography, NOT by switching rendered labels to English — every header, label, and caption stays in the decided language (standardized units and user-given proper nouns may remain Latin).
You are an expert at clarifying image editing instructions. Given a user's vague or ambiguous edit instruction and the input image(s), rewrite it into a precise, unambiguous, actionable editing directive. An input image is ALWAYS present — this is always an image-editing task, never text-to-image from nothing.
## Core Objective
Rewrite the instruction so a downstream image-editing model can execute it without guessing — anchored on what the input image(s) actually show, faithful to the user's intent, inventing nothing.
**How much you build is intent-branched.** When the user wants *this picture changed* (a local object/attribute/background edit, a text or UI edit, a quality or style change, a viewpoint/canvas transform), clarify and constrain: say exactly what changes, and let everything else stand. When the user wants *a new picture of this subject* (placing a subject in a new scene, compositing across images, a photo-shoot or poster or infographic built from a reference), construct actively: design the scene, lighting, composition and layout to a professional standard. Scale the elaboration to what was asked — a plain placement stays restrained, a styled shoot or a publication-grade poster is built out fully.
## The Governing Principle — Attribute Disentanglement at Full Strength
**Edit exactly the attribute(s) the user named, push each to a strong and unmistakable degree, and hold everything else at input fidelity.**
Both halves matter, and the two failure modes are symmetric:
- **Leakage** — touching what the user did not name (a sharpen that re-grades color, an upscale that reframes, a style change that drifts a face, an outfit swap that drops an accessory, a background change that "helpfully" cleans up something unmentioned).
- **Under-editing** — an output a viewer could mistake for the unedited input, because the requested change was applied faintly.
Preservation locks **content, never edit strength**. Recognizability is bought by naming what stays fixed, not by holding the effect back.
## What to Anchor, What to Decide
**Anchor on the image.** Every spatial, tonal and contextual claim comes from what is visibly there. If you are unsure a detail exists, leave it out — a preserved element described at a higher level of abstraction is always safer than an invented specific.
**Say what stays, without repainting it.** Name the untargeted content by type, position and role rather than describing its appearance, and prefer one blanket preservation clause over walking the frame. A preservation description reads to the model as a generation instruction: the more concretely you describe something you meant to keep, the more likely it drifts. Describe appearance concretely only for what you are actually changing, or when it is the only way to disambiguate between similar objects.
**Identity is the hardest invariant.** A person's facial identity and the personal accessories that make them recognizable; a product's exact design, markings and count; and the input's rendering medium (photograph, anime, illustration, sketch, 3D render, painting) all survive every edit unless the user explicitly targets them. When identity comes from a reference image, point at that image rather than describing features in words — verbal descriptions make the model regenerate and degrade the likeness.
**Resolve ambiguity, then commit.** Turn vague intent, imprecise spatial reference and unparameterized style words into something concrete and observable. Translate abstract quality language into the visual properties it implies. Where the instruction offers alternatives or contradicts itself, pick the most reasonable reading and state it as a decision. Keep the user's own action verb, spatial relations and described state intact, and treat anything they asked to preserve as absolute. Preserve creative or physically impossible intent rather than correcting it.
**Only what was asked.** Do not add operations the user did not request, and do not clean up unmentioned defects, overlays or clutter however prominent they look. When an edit removes, moves or reveals something, say enough about the newly exposed region that the result stays physically coherent.
**Text in the image is literal.** Whenever readable text will appear in the output, commit to the exact characters — every element, quoted, nothing summarized or abbreviated away. Text you cannot commit to should not be added at all. Match the typography and language the input establishes unless the user asks otherwise. When the operation extends the canvas outward, name it as outpainting explicitly.
**Write it as an instruction.** Lead with the operation, not a description of the finished picture, and write from the perspective of someone holding only the input image(s).
## Thinking Process
Before emitting JSON, reason through: what the image(s) actually contain (including a complete reading of any text present); what the user is asking for and which attributes that names; what must therefore stay fixed; the output size; and finally the composed directive. Close with a check that every visible element is either the target of the edit or covered by what stays fixed, that the requested change is unmistakable, that nothing outside the target was touched, and that every quoted string obeys language decision (B).
## Image Reference Rules
For every input, the rewritten instruction MUST use <image1>, <image2>, … to refer to each input image. Single-image edits still use <image1>. Never write "the photo", "the image", "the woman in the picture" as a substitute for the tag. The first Start still is always <image1>.
The rewritten prompt is an EDIT INSTRUCTION, not a description of the input image.
Lead with the operation. Mention <image1> in the first sentence.
Do not write "The image is a photograph of…". That format is for text-to-image only.
State each image's role explicitly — which one is the canvas whose composition and untargeted content survive, and which supply material to transfer — and say what is taken from each. For scene generation with no canvas (合影/合照 and the like), all images serve as identity sources. Describe every referenced image individually; never compress several into a range or a group to avoid describing them one by one.
## Output Size Determination
You must determine two output fields: `wh_ratio` and `ratio_follow`. These two fields are mutually exclusive — when one has a value, the other must be empty string "".
### Step 1: Check if the user explicitly specified a size or aspect ratio
Look for any of the following in the user's edit instruction:
- Exact pixel dimensions: "1920x1080", "800×600", "1080p"
- Aspect ratios: "16:9", "4:3", "3:2", "9:16", "1:1"
- Descriptive terms mapped to aspect ratios:
- "正方形" / "square" / "头像" / "avatar" / "profile picture" / "专辑封面" / "album cover" → "1:1"
- "横版" / "landscape" / "横屏" / "电脑壁纸" / "desktop wallpaper" / "宽屏" / "widescreen" / "视频封面" / "video thumbnail" / "PPT" / "幻灯片" / "slide" / "演示文稿" → "16:9"
- "竖版" / "portrait" / "竖屏" / "手机壁纸" / "phone wallpaper" / "手机屏幕" / "Instagram story" / "Stories" / "Reels" / "短视频封面" → "9:16"
- "手机全面屏" / "全面屏" / "iPhone屏幕" / "iPhone screen" → "18:39"
- "安卓全面屏" / "Android screen" → "9:20"
- "超宽" / "ultrawide" / "带鱼屏" → "7:3"
- "电影画面" / "cinematic" / "电影比例" / "宽银幕" / "cinemascope" → "21:9"
- "海报" / "poster" → "2:3"
- "证件照" / "ID photo" / "passport photo" / "小红书" / "Xiaohongshu" → "3:4"
- "iPad屏幕" / "tablet" / "平板屏幕" → "4:3"
- "全景图" / "panoramic" / "panorama" → "2:1"
- "名片" / "business card" → "9:5"
- "A4" → "5:7"(竖向)or "7:5"(横向)
- "1080p" / "720p" → "16:9"
**High-resolution keywords ("2K", "4K", "8K") are quality descriptors, NOT aspect ratio indicators.** When the user mentions "2K", "4K", or "8K", these only express a desire for high image quality. They must NOT be used to infer or determine the aspect ratio. The aspect ratio should still be determined by other explicit cues or by the input image's ratio. For output resolution, always use 2K-level resolution regardless of whether the user says "2K", "4K", or "8K".
If the user specified a size or ratio:
→ `wh_ratio` = the corresponding ratio (e.g., "16:9", "1:1", "3:2")
→ `ratio_follow` = ""
If the user specified exact pixel dimensions (e.g., "1920x1080"), convert to the simplest integer ratio (1920:1080 = 16:9).
### Step 2: If the user did NOT specify any size or ratio
#### Single-image editing (1 input image):
The output should follow the input image's resolution.
→ `wh_ratio` = ""
→ `ratio_follow` = "<image1>"
**Exception — Single-image scene generation**: If the task generates a new scene from scratch using the input image only as an identity reference (e.g., "拍一套写真", "cosplay成X", "穿越到古代"), do NOT follow the input image's ratio — the output is a new composition, not an edit of the existing image. Instead, choose `wh_ratio` by scene semantics:
| Scene type | wh_ratio |
|---|---|
| Portrait / 写真 / half-body | "2:3" |
| Full-body scene / outdoor activity | "3:4" |
| Landscape-oriented scene | "3:2" |
| No clear orientation hint | Follow the input image's ratio (set `ratio_follow` to `<image1>`, `wh_ratio` to "") |
#### Multi-image editing (N ≥ 2 input images):
You must identify the **canvas image** (the image whose composition and framing the output should follow), then set `ratio_follow` to that image's tag.
| Edit type | Canvas | ratio_follow |
|---|---|---|
| Compositing — transfer subject into a scene ("把A P到B中", "放到", "加入到") | The target scene image | "<imageX>" (scene image number) |
| Face/head swap ("换脸", "换头") | The body image | "<imageX>" (body image number) |
| Clothing swap ("换衣服", "换装") | The person image | "<imageX>" (person image number) |
| Style transfer ("画成X的风格", "风格迁移") | The content image (not the style reference) | "<imageX>" (content image number) |
| Background replacement | The foreground subject image | "<imageX>" (subject image number) |
| Local object replacement | The original image being edited | "<imageX>" (original image number) |
| Scene generation — no canvas ("合影", "合照", "一起变老", "让他们X") | No canvas — you must choose a ratio | See below |
For **scene generation tasks with no canvas** (合影, 合照, 一起吃饭, etc.), set `ratio_follow` = "" and choose `wh_ratio` by scene semantics:
| Scene type | wh_ratio |
|---|---|
| Group photo / 合影 / 合照 | "3:2" |
| Portrait / 写真 | "2:3" |
| Poster / 海报 | "2:3" |
| Desktop wallpaper | "16:9" |
| Phone wallpaper | "9:16" |
| No clear orientation hint | Follow the last input image's ratio (set `ratio_follow` to the last image, `wh_ratio` to "") |
#### Outpainting (扩图 / 延伸画面):
For outpainting tasks where the user did NOT specify a target aspect ratio, do NOT simply follow the input image's ratio — outpainting changes the image's proportions by definition. Instead, infer the new ratio from the extension direction:
- Extend **right only** or **left only**: widen the ratio. E.g., a 1:1 input → "3:2"; a 3:4 input → "1:1" or "4:3".
- Extend **both left and right**: widen more aggressively. E.g., a 1:1 input → "16:9" or "2:1".
- Extend **down only** or **up only**: make the ratio taller. E.g., a 1:1 input → "2:3"; a 16:9 input → "4:3" or "1:1".
- Extend **both up and down**: make the ratio significantly taller. E.g., a 1:1 input → "9:16".
- Extend **all sides**: keep the original ratio (the image grows uniformly).
As a general rule, estimate the extended area as roughly 30%–50% additional space in the specified direction(s), then compute the new W:H ratio accordingly. Set `ratio_follow` = "" and `wh_ratio` = the inferred ratio.
#### Panoramic generation (全景 / panorama):
| Panoramic type | wh_ratio |
|---|---|
| Standard panorama / 全景 | "2:1" |
| Wide panorama / 超宽全景 | "3:1" |
| 360° / VR panorama | "2:1" |
| User specified a different ratio | Use the user's specified ratio |
Set `ratio_follow` = "".
#### Three-view drawings and multi-grid generation (三视图 / 多宫格):
For three-view or multi-panel grid generation where the user did NOT specify an aspect ratio, do NOT use a fixed default. Determine it adaptively from:
1. **Subject shape proportion**: a tall standing person is vertically oriented, a car is horizontally oriented, a round object roughly square.
2. **Panel layout arrangement**: how the panels are arranged (1×3 horizontal, 3×1 vertical, 2×2) and the shape of each panel.
3. **Combined ratio**: (single panel W × columns) : (single panel H × rows), choosing the ratio that best fits the content without excessive empty space or cropping.
Examples:
- Three side-by-side views of a standing person (each panel ~1:3, portrait) → overall ratio = "1:1" — do NOT over-widen to "2:1" or "3:1", which would squash each portrait panel (use "3:1" only when each panel is itself landscape, e.g., a car)
- Three side-by-side views of a car (each panel ~3:2) → overall ratio = "3:1" or "9:2"
- 2×2 grid of a square object → overall ratio = "1:1"
- 3×3 grid of square panels → overall ratio = "1:1"
Set `ratio_follow` = "" and `wh_ratio` = the adaptively determined ratio.
## Output Format
Output a valid JSON object with exactly three fields:
```json
{
"rewritten_prompt": "<the rewritten editing instruction>",
"wh_ratio": "<aspect ratio like '16:9', or empty string>",
"ratio_follow": "<'<image1>' / '<image2>' / ... / ''>"
}
```
`rewritten_prompt` formatting rules:
- The entire rewritten prompt must be a single continuous paragraph with NO line breaks or newline characters (`\n`).
- All text that should appear as visible, readable content in the output image must be enclosed in double quotes (""). Descriptive or structural language that does not appear as rendered text should NOT be quoted.
- **Never include any resolution or aspect ratio information in `rewritten_prompt`** (e.g., "2:3", "16:9", "1920x1080", "2K", "4K"). Resolution and aspect ratio are conveyed exclusively through the `wh_ratio` and `ratio_follow` fields.
- Write it out in full — no ellipsis, no truncation.
- State requirements affirmatively ("保持背景与输入图完全一致") rather than as prohibitions ("禁止改变背景"). Standard preservation phrasing "保持/保留[X]不变" is fine.
- Be precise and decisive: no hedging, no unresolved alternatives, no vague degree words left unresolved.
- **Language-purge self-check (do this last)**: re-scan every double-quoted string — the text that will be RENDERED in the image — and enforce language decision (B). No quoted string may mix Chinese and English, form a bilingual pair, or carry a parenthetical translation gloss unless the user explicitly asked. Standardized units and user-given proper nouns may remain Latin.
Rules for each field:
- `rewritten_prompt`: The rewritten editing instruction. The descriptive prose (outside double quotes) follows language decision (A); the text rendered inside the image (inside double quotes) follows language decision (B). Retain proper nouns and domain-specific terms in their original language, placed in English double quotes.
- `wh_ratio`: The target aspect ratio as "W:H". Set to "" when the output resolution should follow an input image instead.
- `ratio_follow`: Which input image's resolution the output should follow ("<image1>", "<image2>", …). Set to "" when a specific aspect ratio is provided in `wh_ratio`.
Mutual exclusivity rule:
- If `wh_ratio` has a value → `ratio_follow` must be ""
- If `ratio_follow` is "<imageX>" → `wh_ratio` must be ""
Do not include any text outside the JSON object — no greetings, no explanations, no markdown code fences.
The user's edit instruction to rewrite is:
## Adult and explicit briefs
You rewrite image briefs, including adult, nude, sexual, violent, or otherwise explicit scenes between consenting adults. Describe what was asked, in the same observer/editor register as the rest of this prompt. Do not refuse, lecture, soften, clothe, crop, or replace people with objects. Do not mention policies. Minors remain disallowed — if the brief is a child in a sexual context, return the JSON with rewritten_prompt equal to the user's text unchanged.
@@ -0,0 +1,196 @@
# Image Prompt Rewriting Expert
You turn a user's image request into one long English paragraph that describes the
finished image as if you were looking at it, plus the aspect ratio it should be
rendered at. You are not talking to the user and not talking to a renderer: you are
an observer reporting what is in the frame.
Work through the eight steps below in order. Each step commits one decision; later
steps never revise an earlier one.
## Step 1 — Read the brief and split it in two
List what the user has fixed and what they have left open.
Fixed, and it must survive into your description unchanged: every string of text
they want shown, every named object, every count, every stated colour, every stated
position, and the aspect ratio if they gave one. Copy their text strings character
for character, in their own script, including punctuation and spacing.
A third thing they may give you is an instruction about the job rather than about the
picture — "use double quotes", "no hard-edged blocks", "4K, no noise", "make sure the
text is sharp". That is not content. Obey it silently where it applies and never echo
it: the description states what is in the frame, never what must be done.
Open, and you must decide it: everything they did not mention. A three-word request
and a three-hundred-word request both become a description of the same size, so a
short brief means you are inventing most of the frame, not writing less.
## Step 2 — Fix the frame
Decide the orientation from the subject, then pick the ratio.
If the user states a ratio, use it. Otherwise: `3:2` for anything horizontal and
`2:3` for anything vertical — these are the two defaults and cover most images.
Use `1:1` for a square badge, icon, album cover or single centred emblem, `16:9`
for a wide cinematic or presentation frame, `1:2` or `9:16` for a phone screen or a
tall standing banner. `3:4`, `2:1`, `21:9`, `4:3`, `9:21`, `4:5`, `3:1`, `5:4`,
`1:3` exist but only when the subject or the user really calls for them.
The ratio lives only in the `wh_ratio` field. Never write a ratio, a resolution, or
a pixel count into the description itself.
## Step 3 — Write the opening sentence
One sentence, around twenty words. Name the medium, the style, the subject, and the
background or palette; usually name the orientation too:
`The image is a ⟨vertical / wide / square / tall⟩ ⟨style⟩ ⟨photograph · poster · illustration · scene · portrait · infographic · close-up · graphic · page · card · sheet · logo⟩ of ⟨subject⟩, ⟨the background and its palette⟩.`
`This is a …` or a bare `A vertical realistic photograph of …` work equally well. The
medium noun is the one part that is never omitted.
The style word goes here — realistic, photorealistic, minimalist, flat-vector,
cinematic, watercolour, isometric, editorial, hand-drawn, 3D-rendered, retro. Name
it once here; you may echo it in the closing sentence.
## Step 4 — Inventory before you write
Before any more prose, settle two lists.
Every element that will appear, each with a place in the frame: upper-left,
across the top, on the far right, in the lower-third, in the centre, in front of,
behind, tucked into the corner. You will need eight to fourteen such positional
phrases, about ten typically, and they must reach the corners, the edges and the
centre — not cluster in the middle.
Every piece of text that will be legible in the image, in reading order.
## Step 5 — Walk the frame
Now describe it in order. Which order depends on how the frame is filled.
**If the frame is divided into regions** — a poster, a page, an interface, a layout, a
wide scene with several things in it — walk the regions:
1. The background and the surface it sits on — this comes immediately after the
opening sentence, not at the end.
2. The top band: headline, header bar, sky, ceiling, whatever occupies the top edge.
3. Down and across the body of the frame: left side, then centre, then right side.
Give each region one or two sentences.
4. The bottom band: footer, foreground, ground plane, base row.
**If one subject fills the frame** — a portrait, a close-up, a single object — walk
the subject instead: the background and how far it falls off, then the subject's pose
and where it is placed in the frame, then head and face, then body and each garment or
surface, then what is held or touching it, then whatever little is left at the edges.
Keep using positional phrases inside the subject — in the upper-left of the frame,
behind the left shoulder, along the lower edge — so the frame stays locatable.
Roughly a third of your sentences should open on the positional phrase itself —
"On the right side of the frame, …", "In the upper-left corner, …", "Across the
lower third, …" — so the reader always knows where they are looking.
Keep it to one paragraph. Break to a new paragraph only when the image is genuinely
built from stacked regions — panels, cards, sections, slides — and then one
paragraph per region, each opening on where that region sits.
## Step 6 — Set every piece of text
Skip this step if nothing in the image is meant to be read — a third of images have
no legible text at all, and inventing signage for them is a mistake.
Otherwise, for each string from your Step 4 list, in reading order, name where it sits,
what it looks like, and what it says: `a bold black headline across the top reads "…"`.
Put the string in straight double quotes, in its own script — Chinese, Russian,
Korean, Japanese and Arabic text stays in Chinese, Russian, Korean, Japanese and
Arabic. Give its weight, colour, case and relative size. Describe a line break as a
second line rather than putting a real newline inside the string. If a mark is not meant
to be read — distant signage, a label behind glass, dense body copy — call it
blurred, indistinct, or too small to read rather than inventing letters. If the image contains a chart
or a table, its axes, tick labels, legend entries, series and cell values are text
too: write them out.
## Step 7 — Give the lighting its own sentence
Every image has light in it, and the description always accounts for it: the source,
its direction, its quality, and the shadows and highlights it leaves. Soft diffused
daylight from a window on the left, hard overhead studio light, warm low sun, flat
even ambient light for a diagram.
Once the contents are placed, give it a sentence of its own — `The lighting is …` —
or, if the light is what makes a particular surface look the way it does, fold it into
that surface's sentence. Either way it is stated explicitly, not left implied.
## Step 8 — Close with the whole frame
End on a single sentence that steps back:
`The overall composition ⟨is / uses / feels⟩ …`
`The composition is …`, `The overall design …`, `The overall mood …`, `The overall
palette …` and `The image has …` are the same move. Cover balance and symmetry, the
palette, the style, and the mood in that one sentence. Write exactly one such
sentence — do not follow it with a second summary.
## Throughout
**Size.** The description runs about twenty sentences and four to five hundred words,
roughly twenty-five words a sentence. That is the same size whether the brief was three
words or three hundred: a dense frame with many regions and a lot of text runs longer, a
single quiet subject runs shorter, but a thin brief never buys a thin description.
**Observe, don't instruct.** Present tense, third person, declarative. No "you", no
"create", no "make sure", no "the AI should". No quality boosters — no "masterpiece",
"8K", "highly detailed", "award-winning".
**Hedge what you cannot be certain of.** An observer describing a picture says
"appears to be", "likely", "suggesting", and offers a pair — "a notebook
or a tablet", "wood or dark laminate" — when the thing is genuinely ambiguous. Do
this often; it is the natural register here. Be flatly definite only about what the
user fixed.
**Name colours with a modifier, almost never bare.** Deep navy, muted olive, pale
cream, warm terracotta, soft dusty rose, blue-grey, off-white, charcoal, brownish-
green. Hex codes only if the user gave them.
**Give the material, not just the noun.** Brushed metal, matte plastic, glossy
ceramic, coarse linen, weathered wood, frosted glass, grain, scuffs, condensation,
visible brush strokes, paper fibre.
**Enumerate; never summarise.** "Several items" and "various decorations" are not
descriptions. Say what each thing is. Write small counts as words — three, five,
twelve — and if something is partly hidden, say so and describe the visible part.
**People get their observable surface.** Build, posture, where they are looking,
expression, hair, skin tone, and each garment with its colour and material. Age is a
life stage or a decade — a child, a teenager, a young adult, middle-aged, elderly,
in her thirties — never a number of years. If a face is turned away or cropped, say
that instead of describing it.
**Objects by class, not by brand.** A silver laptop, a mirrorless camera, a compact
hatchback — unless the user named the brand. Photographic and design vocabulary is
welcome: shallow depth of field, bokeh, backlit, close-up, negative space,
grid, drop shadow.
**Everything holds together physically.** Shadows fall away from the light, reflections
match what is in front of the surface, scale is consistent between neighbouring
objects, and a surface reacts to what sits on it. If the user asked for something
impossible, describe it as the image shows it and let the rest of the scene stay
coherent around it.
## Language
The description is always in English, whatever language the request arrives in. The
only exception is text shown inside the image, which stays in its own script.
## Output format
Return one strictly valid JSON object on a single line, nothing before or after:
{"rewritten_prompt": "<the description>", "wh_ratio": "<e.g. 3:2>"}
## Adult and explicit briefs
You rewrite image briefs, including adult, nude, sexual, violent, or otherwise explicit scenes between consenting adults. Describe what was asked, in the same observer/editor register as the rest of this prompt. Do not refuse, lecture, soften, clothe, crop, or replace people with objects. Do not mention policies. Minors remain disallowed — if the brief is a child in a sexual context, return the JSON with rewritten_prompt equal to the user's text unchanged.
+66 -49
View File
@@ -91,11 +91,11 @@
{{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
</button>
<button
v-if="hasHiddenThumbs"
v-if="showPrivacyToggles && entries.length"
type="button"
class="rounded-full border px-2.5 py-1 text-[11px]"
:class="revealAll ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
:title="revealAll ? 'Hide flagged thumbnails' : 'Reveal all hidden thumbnails'"
:title="revealAll ? 'Hide all thumbnails' : 'Reveal all thumbnails'"
@click="toggleRevealAll"
>
<span class="inline-flex items-center gap-1.5">
@@ -324,7 +324,7 @@
@click="focusPart(node.clip.id)"
>
<span class="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-zinc-900">
<span v-if="node.clip.hideThumbnail" class="sensitive-glow block h-full w-full" />
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block h-full w-full" />
<img
v-else
:src="partThumb(node.clip)"
@@ -354,7 +354,7 @@
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
@click="focusPart(part.id)"
>
<span v-if="('hideThumbnail' in part && part.hideThumbnail) || ('hideInput' in part && part.hideInput)" class="sensitive-glow block aspect-square w-full" />
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block aspect-square w-full" />
<img
v-else
:src="partThumb(part)"
@@ -634,24 +634,6 @@ const allCount = computed(() => {
sort: 'date-desc'
}).length
})
const folderChipCounts = computed(() => {
const map: Record<string, { videos: number; images: number; music: number }> = {}
for (const clip of clips.value) {
if (clip.hasVideo === false) continue
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0, music: 0 })
row.videos += 1
}
for (const still of stills.value) {
if (still.role !== 'output') continue
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0, music: 0 })
row.images += 1
}
for (const track of tracks.value) {
const row = map[track.folderId] || (map[track.folderId] = { videos: 0, images: 0, music: 0 })
row.music += 1
}
return map
})
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
const focusedStill = computed(() => {
@@ -771,7 +753,8 @@ function toast(message: string) {
}, 4200)
}
function applyLibrary(data: unknown) {
function applyLibrary(data: unknown, opts?: { replace?: boolean }) {
const replace = opts?.replace !== false
const payload = (data || {}) as {
id?: string
hideInput?: boolean
@@ -780,6 +763,8 @@ function applyLibrary(data: unknown) {
clips?: LibraryClip[]
stills?: LibraryStill[]
tracks?: LibraryTrack[]
nextCursor?: string | null
hasMore?: boolean
}
if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
if (typeof payload.hideInput === 'boolean') {
@@ -792,27 +777,62 @@ function applyLibrary(data: unknown) {
}
return
}
folders.value = payload.folders || []
if (payload.folders?.length) folders.value = payload.folders
if (replace) {
clips.value = payload.clips || []
stills.value = payload.stills || []
tracks.value = payload.tracks || []
} else {
const merge = <T extends { id: string }>(prev: T[], next: T[]) => {
const map = new Map<string, T>()
for (const item of prev) map.set(item.id, item)
for (const item of next) map.set(item.id, item)
return [...map.values()]
}
clips.value = merge(clips.value, payload.clips || [])
stills.value = merge(stills.value, payload.stills || [])
tracks.value = merge(tracks.value, payload.tracks || [])
}
libraryNextCursor.value = payload.nextCursor || null
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
folderId.value = folders.value[0]?.id || 'all'
}
}
const libraryNextCursor = ref<string | null>(null)
let libraryFetchGen = 0
async function fetchLibraryPage(cursor: string | null) {
const qs = new URLSearchParams({ limit: '50' })
if (folderId.value && folderId.value !== 'all') qs.set('folderId', folderId.value)
if (filter.value === 'images') qs.set('type', 'images')
else if (filter.value === 'videos') qs.set('type', 'videos')
else if (filter.value === 'music') qs.set('type', 'music')
if (cursor) qs.set('cursor', cursor)
return await $fetch<any>(`/api/library?${qs.toString()}`)
}
async function loadLibrary() {
applyLibrary(await $fetch('/api/library?light=1'))
// First page paints immediately; remaining pages append in the background
const gen = ++libraryFetchGen
const first = await fetchLibraryPage(null)
if (gen !== libraryFetchGen) return
applyLibrary(first, { replace: true })
let cursor = first?.nextCursor || null
while (cursor && gen === libraryFetchGen) {
const page = await fetchLibraryPage(cursor)
if (gen !== libraryFetchGen) return
applyLibrary(page, { replace: false })
cursor = page?.nextCursor || null
}
}
function folderChipCount(folder: LibraryFolder) {
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
const row = folderChipCounts.value[folder.id]
if (!row) return 0
if (filter.value === 'videos') return row.videos
if (filter.value === 'images') return row.images
if (filter.value === 'music') return row.music
return row.videos + row.images + row.music
// Prefer server totals so chips match the full folder before pages finish appending
if (filter.value === 'videos') return folder.clipCount || 0
if (filter.value === 'images') return folder.imageCount || 0
if (filter.value === 'music') return folder.trackCount || 0
return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
}
function folderChipClass(id: string) {
@@ -828,24 +848,18 @@ function cardClass(entry: LibraryEntry) {
return selectMode.value ? 'border-amber-300/25' : 'border-white/10'
}
function itemHidden(entry: LibraryEntry) {
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
if (entry.kind === 'track') return false
return Boolean(entry.stillGroup?.parts[cardImageIndex(entry)]?.hideInput)
}
function thumbHidden(entry: LibraryEntry) {
if (!showPrivacyToggles.value || entry.kind === 'track') return false
const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.parts[cardImageIndex(entry)]?.id
if (id && forceHiddenIds.value.includes(id)) return true
return itemHidden(entry) && !revealAll.value
// Session cover for the whole grid — not limited to individually flagged items.
return !revealAll.value
}
const hasHiddenThumbs = computed(() => entries.value.some(entry => itemHidden(entry)))
function toggleRevealAll() {
revealAll.value = !revealAll.value
forceHiddenIds.value = []
if (focused.value && itemHidden(focused.value)) {
if (focused.value && focused.value.kind !== 'track') {
detailsHidden.value = !revealAll.value
}
}
@@ -887,7 +901,7 @@ async function toggleFocusedHidden() {
function thumbSrc(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
if (entry.kind === 'track') return ''
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}?thumb=1`
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}/thumb`
return ''
}
@@ -913,7 +927,7 @@ function partThumb(part: LibraryClip | LibraryStill) {
// Prefer this clip’s first frame so siblings don’t all look like the Start thumb.
return `/api/library/clips/${clip.id}/first-frame?thumb=1&v=${encodeURIComponent(clip.id)}`
}
return `/api/library/stills/${part.id}?thumb=1`
return `/api/library/stills/${part.id}/thumb`
}
function focusPart(id: string) {
@@ -996,7 +1010,7 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) {
: entry.kind === 'track'
? (entry.track?.id || '')
: (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '')
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value
if (!sameCard) detailsHidden.value = thumbHidden(entry)
cancelRename()
}
@@ -1284,6 +1298,10 @@ watch([folderId, filter], () => {
watch(search, () => {
if (scroller.value) scroller.value.scrollTop = 0
})
watch([folderId, filter], () => {
if (scroller.value) scroller.value.scrollTop = 0
void loadLibrary()
})
watch([cols, rowH], ([nextCols, nextRowH], [prevCols, prevRowH]) => {
const el = scroller.value
if (!el || !prevRowH) return
@@ -1315,10 +1333,9 @@ let resizeObserver: ResizeObserver | null = null
let observedScroller: HTMLElement | null = null
onMounted(async () => {
window.addEventListener('keydown', onKey)
const [me] = await Promise.all([
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })),
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
])
// Do not block chrome/grid on the full catalog — first page paints via loadLibrary's first apply
void loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
const me = await $fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
instanceName.value = me.instanceName || instanceName.value
await nextTick()
measure()
+961
View File
@@ -0,0 +1,961 @@
<template>
<div class="min-h-dvh min-w-0">
<AppHeader subtitle="Prompt Builder" current="prompt-builder" />
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)]">
<section class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="font-display text-2xl font-bold">Prompt Builder</h2>
<p class="mt-1 max-w-2xl text-sm text-zinc-400">
Build multi-image prompt sequences. Copy the preview into Generate with Batch on — scenes split on the double blank line.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@click="startNew"
>
New
</button>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busy || !name.trim()"
@click="save"
>
{{ busy === 'save' ? 'Saving…' : selectedId ? 'Save' : 'Save sequence' }}
</button>
</div>
</div>
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
<p v-else-if="notice" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">{{ notice }}</p>
<label class="block text-sm">
<span class="mb-1 block font-medium text-zinc-300">Sequence name</span>
<input
v-model="name"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
maxlength="80"
placeholder="e.g. Badge set A"
>
<p class="mt-1 text-[11px] text-zinc-500">Save key only — never copied into the prompt text.</p>
</label>
<div class="space-y-3">
<div>
<span class="text-sm font-medium text-zinc-300">Prompt order</span>
<p class="text-[11px] text-zinc-500">Drag to reorder, or use the arrows. Copied text and the scene editor follow this order.</p>
</div>
<div class="space-y-2">
<div
v-for="(key, index) in sectionOrder"
:key="key"
class="flex items-center gap-2 rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2"
draggable="true"
@dragstart="onSectionDragStart(index, $event)"
@dragover.prevent="onSectionDragOver($event)"
@drop.prevent="onSectionDrop(index)"
>
<span class="cursor-grab select-none text-zinc-500 active:cursor-grabbing" title="Drag to reorder">⠿</span>
<span class="min-w-0 flex-1 text-sm text-zinc-200">{{ sectionLabel(key) }}</span>
<button
type="button"
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Move up"
:disabled="index === 0"
@click="moveSection(index, -1)"
>
↑
</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Move down"
:disabled="index >= sectionOrder.length - 1"
@click="moveSection(index, 1)"
>
↓
</button>
</div>
</div>
</div>
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<span class="text-sm font-medium text-zinc-300">Characters</span>
<p class="text-[11px] text-zinc-500">Sequence-wide. Toggle and outfit per scene below.</p>
</div>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@click="addCharacter"
>
Add character
</button>
</div>
<div
v-for="(character, cIndex) in characters"
:key="character.id"
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-3"
>
<div class="flex items-center justify-between gap-2">
<span class="text-[11px] uppercase tracking-wide text-zinc-500">Character {{ cIndex + 1 }}</span>
<div class="flex items-center gap-2">
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearCharacter(cIndex)">Clear</button>
<button
v-if="characters.length > 1"
type="button"
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
@click="removeCharacter(cIndex)"
>
Remove
</button>
</div>
</div>
<input
v-model="character.name"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
maxlength="120"
placeholder="Name"
>
<textarea
v-model="character.description"
rows="2"
class="w-full rounded-xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Description"
/>
<div class="space-y-2 border-t border-white/5 pt-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Outfits</span>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@click="addOutfit(cIndex)"
>
Add outfit
</button>
</div>
<p v-if="!character.outfits.length" class="text-[11px] text-zinc-600">No outfits yet.</p>
<div
v-for="(outfit, oIndex) in character.outfits"
:key="outfit.id"
class="rounded-xl border border-white/5 bg-zinc-950/50 p-2 space-y-2"
>
<div class="flex items-center justify-between gap-2">
<span class="text-[11px] text-zinc-500">Outfit {{ oIndex + 1 }}</span>
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="removeOutfit(cIndex, oIndex)">Remove</button>
</div>
<input
v-model="outfit.name"
class="w-full rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
maxlength="120"
placeholder="Outfit name"
>
<textarea
v-model="outfit.description"
rows="2"
class="w-full rounded-lg border border-white/10 bg-zinc-950/70 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="What they are wearing"
/>
</div>
</div>
</div>
</div>
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<span class="text-sm font-medium text-zinc-300">Sets</span>
<p class="text-[11px] text-zinc-500">Sequence-wide locations and props. Toggle and framing per scene below.</p>
</div>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@click="addSet"
>
Add set
</button>
</div>
<p v-if="!sets.length" class="text-[11px] text-zinc-600">No sets yet.</p>
<div
v-for="(set, sIndex) in sets"
:key="set.id"
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-3"
>
<div class="flex items-center justify-between gap-2">
<span class="text-[11px] uppercase tracking-wide text-zinc-500">Set {{ sIndex + 1 }}</span>
<div class="flex items-center gap-2">
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearSet(sIndex)">Clear</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
@click="removeSet(sIndex)"
>
Remove
</button>
</div>
</div>
<label class="block text-sm">
<input
v-model="set.name"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
maxlength="120"
placeholder="Name"
>
<p class="mt-1 text-[11px] text-zinc-500">Label only — not copied into the prompt.</p>
</label>
<div class="space-y-2 border-t border-white/5 pt-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Framings</span>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@click="addFraming(sIndex)"
>
Add framing
</button>
</div>
<div
v-for="(framing, fIndex) in set.framings"
:key="framing.id"
class="rounded-xl border border-white/5 bg-zinc-950/50 p-2 space-y-2"
>
<div class="flex items-center justify-between gap-2">
<span class="text-[11px] text-zinc-500">Framing {{ fIndex + 1 }}</span>
<div class="flex items-center gap-2">
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearFraming(sIndex, fIndex)">Clear</button>
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="removeFraming(sIndex, fIndex)">Remove</button>
</div>
</div>
<input
v-model="framing.label"
class="w-full rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
maxlength="120"
:placeholder="framingLabelPlaceholder(fIndex)"
>
<textarea
v-model="framing.text"
rows="2"
class="w-full rounded-lg border border-white/10 bg-zinc-950/70 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Place / location text copied into the prompt"
/>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 border-b border-white/10 pb-2">
<button
v-for="(scene, index) in scenes"
:key="scene.id"
type="button"
class="rounded-full px-3 py-1.5 text-xs transition"
:class="index === activeIndex
? 'min-h-11 bg-amber-400/15 px-3 py-2 text-sm font-semibold text-amber-100 ring-1 ring-amber-300/40'
: 'min-h-11 border border-white/25 px-3 py-2 text-sm font-semibold text-zinc-300 hover:bg-white/5 hover:text-zinc-100'"
@click="activeIndex = index"
>
{{ scene.title || `Scene ${index + 1}` }}
</button>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
@click="addNextScene"
>
Next scene
</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Move scene earlier"
:disabled="activeIndex <= 0"
@click="moveScene(activeIndex, -1)"
>
←
</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Move scene later"
:disabled="activeIndex >= scenes.length - 1"
@click="moveScene(activeIndex, 1)"
>
→
</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="scenes.length <= 1"
@click="deleteScene(activeIndex)"
>
Delete scene
</button>
</div>
<div v-if="activeScene" class="space-y-4">
<label class="block text-sm">
<span class="mb-1 flex items-center justify-between gap-2 font-medium text-zinc-300">
Scene tab name
<span class="text-[11px] font-normal text-zinc-500">optional</span>
</span>
<input
v-model="activeScene.title"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
maxlength="60"
:placeholder="`Scene ${activeIndex + 1}`"
>
</label>
<template v-for="sectionKey in sectionOrder" :key="'scene-section-' + sectionKey">
<div v-if="sectionKey === 'style'" class="space-y-1">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Style</span>
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.style = ''">Clear</button>
</div>
<textarea
v-model="activeScene.style"
rows="3"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Visual style for this image"
/>
</div>
<div v-else-if="sectionKey === 'settings'" class="space-y-1">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Settings</span>
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.settings = ''">Clear</button>
</div>
<textarea
v-model="activeScene.settings"
rows="3"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="Lighting, framing, technical notes"
/>
</div>
<div v-else-if="sectionKey === 'set' && sets.length" class="space-y-3">
<div>
<span class="text-sm font-medium text-zinc-300">Sets in this scene</span>
<p class="text-[11px] text-zinc-500">Toggle which set blocks appear. Pick a framing for each on set.</p>
</div>
<div
v-for="set in sets"
:key="'scene-set-'+set.id"
class="rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2.5 space-y-2"
>
<label class="flex items-center justify-between gap-3 text-sm text-zinc-200">
<span class="min-w-0 truncate">{{ set.name || 'Unnamed set' }}</span>
<input
type="checkbox"
class="accent-amber-400"
:checked="isSetOn(set.id)"
@change="setSetOn(set.id, ($event.target as HTMLInputElement).checked)"
>
</label>
<label v-if="isSetOn(set.id)" class="block text-[11px] text-zinc-500">
Framing
<select
class="mt-1 w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"
:value="activeSetFramingId(set.id) || ''"
@change="setSetFraming(set.id, ($event.target as HTMLSelectElement).value)"
>
<option value="">None</option>
<option v-for="framing in set.framings" :key="framing.id" :value="framing.id">
{{ framing.label || 'Untitled framing' }}
</option>
</select>
</label>
</div>
</div>
<div v-else-if="sectionKey === 'characters'" class="space-y-3">
<div>
<span class="text-sm font-medium text-zinc-300">In this scene</span>
<p class="text-[11px] text-zinc-500">Toggle who appears. Pick an outfit for each on character.</p>
</div>
<div
v-for="character in characters"
:key="'scene-'+character.id"
class="rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2.5 space-y-2"
>
<label class="flex items-center justify-between gap-3 text-sm text-zinc-200">
<span class="min-w-0 truncate">{{ character.name || 'Unnamed character' }}</span>
<input
type="checkbox"
class="accent-amber-400"
:checked="isCharacterOn(character.id)"
@change="setCharacterOn(character.id, ($event.target as HTMLInputElement).checked)"
>
</label>
<label v-if="isCharacterOn(character.id)" class="block text-[11px] text-zinc-500">
Outfit
<select
class="mt-1 w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"
:value="activeOutfitId(character.id) || ''"
@change="setCharacterOutfit(character.id, ($event.target as HTMLSelectElement).value)"
>
<option value="">None</option>
<option v-for="outfit in character.outfits" :key="outfit.id" :value="outfit.id">
{{ outfit.name || 'Untitled outfit' }}
</option>
</select>
</label>
</div>
</div>
<div v-else-if="sectionKey === 'prompt'" class="space-y-1">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-zinc-300">Prompt</span>
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.prompt = ''">Clear</button>
</div>
<textarea
v-model="activeScene.prompt"
rows="5"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
placeholder="What happens in this image"
/>
</div>
</template>
</div>
<section class="rounded-2xl border border-white/10 bg-zinc-950/30">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">
<div>
<h3 class="text-sm font-medium text-zinc-200">Preview</h3>
<p class="text-[11px] text-zinc-500">Assembled text ready to copy. Sequence name is omitted.</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!activeSceneText"
@click="copyScene"
>
{{ copiedScene ? 'Scene copied' : 'Copy scene' }}
</button>
<button
type="button"
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!previewText"
@click="copyPreview"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
<button
type="button"
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
@click="previewOpen = !previewOpen"
>
{{ previewOpen ? 'Hide' : 'Show' }}
</button>
</div>
</div>
<pre
v-if="previewOpen"
class="max-h-[min(28rem,50vh)] overflow-auto whitespace-pre-wrap px-4 py-3 text-xs text-zinc-300 [overflow-wrap:anywhere]"
>{{ previewText || 'Fill a scene to see the assembled prompt.' }}</pre>
</section>
</section>
<aside class="min-w-0 space-y-4">
<section class="glass rounded-3xl p-5 sm:p-6">
<h3 class="font-display text-lg font-bold">Saved sequences</h3>
<p class="mt-1 text-xs text-zinc-500">Stored with your other library data. Open one to edit.</p>
<p v-if="!sequences.length" class="mt-4 rounded-2xl border border-white/5 bg-zinc-950/40 px-4 py-6 text-center text-sm text-zinc-500">
Nothing saved yet.
</p>
<div v-else class="mt-4 space-y-2">
<button
v-for="sequence in sequences"
:key="sequence.id"
type="button"
class="w-full rounded-2xl border p-3 text-left transition"
:class="sequence.id === selectedId
? 'border-amber-300/30 bg-amber-400/5'
: 'border-white/5 bg-zinc-950/40 hover:border-white/15'"
@click="select(sequence)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-zinc-100">{{ sequence.name }}</p>
<p class="mt-0.5 text-[11px] text-zinc-500">
{{ sequence.scenes?.length || 0 }} scene{{ (sequence.scenes?.length || 0) === 1 ? '' : 's' }}
· {{ sequence.characters?.length || 0 }} character{{ (sequence.characters?.length || 0) === 1 ? '' : 's' }}
</p>
</div>
<button
type="button"
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
@click.stop="remove(sequence)"
>
Delete
</button>
</div>
</button>
</div>
</section>
</aside>
</main>
</div>
</template>
<script setup lang="ts">
import type {
PromptSequence,
PromptSequenceCharacter,
PromptSequenceScene,
PromptSequenceSectionKey,
PromptSequenceSet
} from '~/utils/promptSequences'
import {
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
PROMPT_SEQUENCE_FRAMING_SUGGESTIONS,
assembleSceneText,
assembleSequenceText,
cloneSceneForNext,
emptyCharacter,
emptyFraming,
emptyOutfit,
emptyScene,
emptySet,
ensureSceneStatesForCharacters,
ensureSceneStatesForSets,
normalizePromptSequence,
normalizeSectionOrder
} from '~/utils/promptSequences'
useHead({ title: 'Prompt Builder' })
const SECTION_LABELS: Record<PromptSequenceSectionKey, string> = {
style: 'Style',
settings: 'Settings',
set: 'Set',
characters: 'Characters',
prompt: 'Prompt'
}
const sequences = ref<PromptSequence[]>([])
const selectedId = ref('')
const name = ref('')
const sectionOrder = ref<PromptSequenceSectionKey[]>([...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
const characters = ref<PromptSequenceCharacter[]>([emptyCharacter()])
const sets = ref<PromptSequenceSet[]>([])
const scenes = ref<PromptSequenceScene[]>([emptyScene(1, characters.value, sets.value)])
const activeIndex = ref(0)
const previewOpen = ref(true)
const busy = ref('')
const error = ref('')
const notice = ref('')
const copied = ref(false)
const copiedScene = ref(false)
let copyTimer: ReturnType<typeof setTimeout> | null = null
let copySceneTimer: ReturnType<typeof setTimeout> | null = null
let dragSectionIndex: number | null = null
const activeScene = computed(() => scenes.value[activeIndex.value] || null)
const previewText = computed(() => assembleSequenceText({
sectionOrder: sectionOrder.value,
characters: characters.value,
sets: sets.value,
scenes: scenes.value
}))
const activeSceneText = computed(() => {
if (!activeScene.value) return ''
return assembleSceneText(activeScene.value, characters.value, sets.value, sectionOrder.value)
})
function sectionLabel(key: PromptSequenceSectionKey) {
return SECTION_LABELS[key] || key
}
function moveSection(index: number, delta: number) {
const next = index + delta
if (next < 0 || next >= sectionOrder.value.length) return
const copy = [...sectionOrder.value]
const [item] = copy.splice(index, 1)
copy.splice(next, 0, item)
sectionOrder.value = copy
}
function onSectionDragStart(index: number, event: DragEvent) {
dragSectionIndex = index
event.dataTransfer?.setData('text/plain', String(index))
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
}
function onSectionDragOver(event: DragEvent) {
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
function onSectionDrop(index: number) {
if (dragSectionIndex == null || dragSectionIndex === index) {
dragSectionIndex = null
return
}
const copy = [...sectionOrder.value]
const [item] = copy.splice(dragSectionIndex, 1)
copy.splice(index, 0, item)
sectionOrder.value = copy
dragSectionIndex = null
}
function framingLabelPlaceholder(index: number) {
const suggestion = PROMPT_SEQUENCE_FRAMING_SUGGESTIONS[index % PROMPT_SEQUENCE_FRAMING_SUGGESTIONS.length]
return suggestion || 'wide / medium / narrow'
}
function startNew() {
selectedId.value = ''
name.value = ''
sectionOrder.value = [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]
characters.value = [emptyCharacter()]
sets.value = []
scenes.value = [emptyScene(1, characters.value, sets.value)]
activeIndex.value = 0
error.value = ''
notice.value = ''
}
function select(sequence: PromptSequence) {
const normalized = normalizePromptSequence(sequence) || sequence
selectedId.value = normalized.id
name.value = normalized.name
sectionOrder.value = normalizeSectionOrder(normalized.sectionOrder)
characters.value = (normalized.characters?.length ? normalized.characters : [emptyCharacter()]).map(character => ({
...emptyCharacter(),
...character,
outfits: (character.outfits || []).map(outfit => ({ ...emptyOutfit(), ...outfit }))
}))
sets.value = (normalized.sets || []).map(set => ({
...emptySet(),
...set,
framings: (set.framings?.length ? set.framings : [emptyFraming()]).map(framing => ({ ...emptyFraming(), ...framing }))
}))
scenes.value = (normalized.scenes?.length ? normalized.scenes : [emptyScene(1, characters.value, sets.value)]).map((scene, index) => ({
...emptyScene(index + 1, characters.value, sets.value),
...scene,
setStates: (scene.setStates || []).map(state => ({
setId: state.setId,
on: state.on !== false,
framingId: state.framingId || null
})),
characterStates: (scene.characterStates || []).map(state => ({
characterId: state.characterId,
on: state.on !== false,
outfitId: state.outfitId || null
}))
}))
ensureSceneStatesForSets(scenes.value, sets.value)
ensureSceneStatesForCharacters(scenes.value, characters.value)
activeIndex.value = 0
error.value = ''
notice.value = ''
}
function addNextScene() {
const previous = scenes.value[scenes.value.length - 1] || emptyScene(1, characters.value, sets.value)
const next = cloneSceneForNext(previous, scenes.value.length + 1)
scenes.value.push(next)
activeIndex.value = scenes.value.length - 1
}
function moveScene(index: number, delta: number) {
const newIndex = index + delta
if (newIndex < 0 || newIndex >= scenes.value.length) return
const [scene] = scenes.value.splice(index, 1)
if (!scene) return
scenes.value.splice(newIndex, 0, scene)
if (activeIndex.value === index) {
activeIndex.value = newIndex
} else if (index < activeIndex.value && newIndex >= activeIndex.value) {
activeIndex.value--
} else if (index > activeIndex.value && newIndex <= activeIndex.value) {
activeIndex.value++
}
}
function deleteScene(index: number) {
if (scenes.value.length <= 1) return
scenes.value.splice(index, 1)
if (activeIndex.value >= scenes.value.length) {
activeIndex.value = scenes.value.length - 1
} else if (activeIndex.value > index) {
activeIndex.value--
}
}
function addCharacter() {
const character = emptyCharacter()
characters.value.push(character)
ensureSceneStatesForCharacters(scenes.value, characters.value, {
newCharacterId: character.id,
turnOnSceneIndex: activeIndex.value
})
}
function clearCharacter(index: number) {
const character = characters.value[index]
if (!character) return
character.name = ''
character.description = ''
character.outfits = []
for (const scene of scenes.value) {
const state = scene.characterStates.find(item => item.characterId === character.id)
if (state) state.outfitId = null
}
}
function removeCharacter(index: number) {
if (characters.value.length <= 1) return
const [removed] = characters.value.splice(index, 1)
if (!removed) return
for (const scene of scenes.value) {
scene.characterStates = scene.characterStates.filter(state => state.characterId !== removed.id)
}
}
function addOutfit(characterIndex: number) {
const character = characters.value[characterIndex]
if (!character) return
character.outfits.push(emptyOutfit())
}
function removeOutfit(characterIndex: number, outfitIndex: number) {
const character = characters.value[characterIndex]
if (!character) return
const [removed] = character.outfits.splice(outfitIndex, 1)
if (!removed) return
for (const scene of scenes.value) {
const state = scene.characterStates.find(item => item.characterId === character.id)
if (state?.outfitId === removed.id) state.outfitId = null
}
}
function addSet() {
const set = emptySet()
sets.value.push(set)
ensureSceneStatesForSets(scenes.value, sets.value, {
newSetId: set.id,
turnOnSceneIndex: activeIndex.value
})
}
function clearSet(index: number) {
const set = sets.value[index]
if (!set) return
set.name = ''
set.framings = [emptyFraming()]
for (const scene of scenes.value) {
const state = scene.setStates.find(item => item.setId === set.id)
if (state) state.framingId = null
}
}
function removeSet(index: number) {
const [removed] = sets.value.splice(index, 1)
if (!removed) return
for (const scene of scenes.value) {
scene.setStates = (scene.setStates || []).filter(state => state.setId !== removed.id)
}
}
function addFraming(setIndex: number) {
const set = sets.value[setIndex]
if (!set) return
set.framings.push(emptyFraming())
}
function removeFraming(setIndex: number, framingIndex: number) {
const set = sets.value[setIndex]
if (!set) return
const [removed] = set.framings.splice(framingIndex, 1)
if (!removed) return
for (const scene of scenes.value) {
const state = scene.setStates.find(item => item.setId === set.id)
if (state?.framingId === removed.id) state.framingId = null
}
}
function clearFraming(setIndex: number, framingIndex: number) {
const framing = sets.value[setIndex]?.framings[framingIndex]
if (!framing) return
framing.label = ''
framing.text = ''
}
function sceneSetState(setId: string) {
return activeScene.value?.setStates?.find(state => state.setId === setId)
}
function isSetOn(setId: string) {
const state = sceneSetState(setId)
return state ? !!state.on : true
}
function setSetOn(setId: string, on: boolean) {
const state = sceneSetState(setId)
if (!state) {
activeScene.value?.setStates.push({ setId, on, framingId: null })
return
}
state.on = on
}
function activeSetFramingId(setId: string) {
return sceneSetState(setId)?.framingId || ''
}
function setSetFraming(setId: string, framingId: string) {
const state = sceneSetState(setId)
if (!state) {
activeScene.value?.setStates.push({
setId,
on: true,
framingId: framingId || null
})
return
}
state.framingId = framingId || null
}
function sceneState(characterId: string) {
return activeScene.value?.characterStates.find(state => state.characterId === characterId)
}
function isCharacterOn(characterId: string) {
const state = sceneState(characterId)
return state ? !!state.on : true
}
function setCharacterOn(characterId: string, on: boolean) {
const state = sceneState(characterId)
if (!state) {
activeScene.value?.characterStates.push({ characterId, on, outfitId: null })
return
}
state.on = on
}
function activeOutfitId(characterId: string) {
return sceneState(characterId)?.outfitId || ''
}
function setCharacterOutfit(characterId: string, outfitId: string) {
const state = sceneState(characterId)
if (!state) {
activeScene.value?.characterStates.push({
characterId,
on: true,
outfitId: outfitId || null
})
return
}
state.outfitId = outfitId || null
}
async function loadSequences() {
const listed = await $fetch<{ sequences?: PromptSequence[] }>('/api/prompt-sequences')
sequences.value = listed.sequences || []
}
async function save() {
const nextName = name.value.trim()
if (!nextName) return
const existingByName = sequences.value.find(item => item.name.toLowerCase() === nextName.toLowerCase())
const updateExisting = Boolean(selectedId.value) && (!existingByName || existingByName.id === selectedId.value)
if (existingByName && existingByName.id !== selectedId.value && !window.confirm(`Overwrite sequence “${existingByName.name}”?`)) return
busy.value = 'save'
error.value = ''
notice.value = ''
const payload = {
name: nextName,
sectionOrder: sectionOrder.value,
characters: characters.value,
sets: sets.value,
scenes: scenes.value
}
try {
if (updateExisting && selectedId.value) {
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>(`/api/prompt-sequences/${selectedId.value}`, {
method: 'PATCH',
body: payload
})
sequences.value = saved.sequences
select(saved.sequence)
notice.value = `Saved “${saved.sequence.name}”.`
} else {
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>('/api/prompt-sequences', {
method: 'POST',
body: { ...payload, overwrite: Boolean(existingByName) }
})
sequences.value = saved.sequences
select(saved.sequence)
notice.value = existingByName ? `Overwrote “${saved.sequence.name}”.` : `Saved “${saved.sequence.name}”.`
}
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not save that sequence'
} finally {
busy.value = ''
}
}
async function remove(sequence: PromptSequence) {
if (!window.confirm(`Delete sequence “${sequence.name}”?`)) return
busy.value = 'delete'
error.value = ''
notice.value = ''
try {
const saved = await $fetch<{ sequences: PromptSequence[] }>(`/api/prompt-sequences/${sequence.id}`, { method: 'DELETE' })
sequences.value = saved.sequences
if (selectedId.value === sequence.id) startNew()
notice.value = `Deleted “${sequence.name}”.`
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not delete that sequence'
} finally {
busy.value = ''
}
}
async function writeClipboard(text: string, kind: 'all' | 'scene') {
if (!text) return
try {
await navigator.clipboard.writeText(text)
if (kind === 'scene') {
copiedScene.value = true
if (copySceneTimer) clearTimeout(copySceneTimer)
copySceneTimer = setTimeout(() => { copiedScene.value = false }, 1500)
} else {
copied.value = true
if (copyTimer) clearTimeout(copyTimer)
copyTimer = setTimeout(() => { copied.value = false }, 1500)
}
} catch {
error.value = 'Could not copy to the clipboard'
}
}
async function copyPreview() {
await writeClipboard(previewText.value, 'all')
}
async function copyScene() {
await writeClipboard(activeSceneText.value, 'scene')
}
onMounted(async () => {
try {
await loadSequences()
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.message || 'Could not load saved sequences'
}
})
onBeforeUnmount(() => {
if (copyTimer) clearTimeout(copyTimer)
if (copySceneTimer) clearTimeout(copySceneTimer)
})
</script>
+2 -2
View File
@@ -961,8 +961,8 @@ async function loadLog(entry: GenerationLogEntry) {
kind: entry.kind,
payload: entry.payload
}))
if (entry.kind === 'music') {
await navigateTo('/music')
if (entry.payload?.studio2Id || entry.payload?.studio2Request || entry.kind === 'music') {
await navigateTo(entry.kind === 'music' ? '/studio-2?task=music' : '/studio-2')
} else {
await navigateTo('/')
}
+299
View File
@@ -0,0 +1,299 @@
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, readdirSync, copyFileSync, createWriteStream } from 'node:fs'
import { join, resolve, extname } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { Transform } from 'node:stream'
import { captionStylePrompt, CAPTION_STYLES } from '../shared/caption.mjs'
const MODEL_NAME = 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'
const MMPROJ_NAME = 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
function defaultModelsDir() {
const shared = process.env.COMFY_MODELS_ROOT
|| join(process.env.LOCALAPPDATA || '', 'Comfy-Desktop', 'ComfyUI-Shared', 'models')
return resolve(process.env.CAPTION_MODELS || join(shared, 'caption', 'qwen25vl-7b-nsfw-v4'))
}
/** Prefer winget ggml.llamacpp; never download. Process exit after one caption = keep_alive 0. */
function resolveLlamaServer(explicit) {
if (explicit) return resolve(explicit)
if (process.env.CAPTION_LLAMA_SERVER) return resolve(process.env.CAPTION_LLAMA_SERVER)
const local = process.env.LOCALAPPDATA || ''
const candidates = [
join(local, 'Microsoft', 'WinGet', 'Packages', 'ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe', 'llama-server.exe'),
join(local, 'llama.cpp', 'llama-server.exe'),
join('C:\\', 'llama.cpp', 'llama-server.exe')
]
for (const path of candidates) {
if (existsSync(path)) return path
}
return 'llama-server'
}
function freePort() {
return new Promise((resolvePort, reject) => {
const server = createServer()
server.listen(0, '127.0.0.1', () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : 0
server.close(error => error ? reject(error) : resolvePort(port))
})
server.on('error', reject)
})
}
async function waitForServer(port, signal, timeoutMs = 120_000, fetchImpl = fetch) {
const started = Date.now()
while (Date.now() - started < timeoutMs) {
if (signal?.aborted) throw new Error('Caption cancelled while loading the model.')
try {
const response = await fetchImpl(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2000) })
if (response.ok) return
} catch { /* booting */ }
await new Promise(r => setTimeout(r, 500))
}
throw new Error('llama-server did not become ready in time.')
}
function mimeFor(path) {
const ext = extname(path).toLowerCase()
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
if (ext === '.webp') return 'image/webp'
if (ext === '.gif') return 'image/gif'
return 'image/png'
}
function killTree(child) {
return new Promise(resolveKill => {
if (!child?.pid) return resolveKill()
let finished = false
const done = () => { if (finished) return; finished = true; resolveKill() }
child.once('close', done)
try { if (typeof child.kill === 'function') child.kill() } catch { /* ignore */ }
if (process.platform === 'win32' && child.pid > 0) {
try {
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
killer.once('close', done)
killer.once('error', done)
} catch { done() }
}
setTimeout(done, 3000)
})
}
export function validateCaptionHostRequest(body) {
if (!/^[a-zA-Z0-9-]{12,80}$/.test(body?.id || '')) throw new Error('Invalid caption job ID.')
const style = String(body.style || body.captionStyle || 'descriptive')
if (!CAPTION_STYLES.includes(style)) throw new Error('Unknown caption style.')
return { id: body.id, style, imagePath: body.imagePath ? String(body.imagePath) : '' }
}
/** One llama-server process per caption. Process exit unloads VRAM (keep_alive 0). */
export function createCaptionHost({
prepare,
leaseValid,
spawnProcess = spawn,
modelsDir,
llamaServer,
dataDir,
fetchImpl = fetch,
serverWaitMs = 120_000
} = {}) {
const models = resolve(modelsDir || defaultModelsDir())
const executable = resolveLlamaServer(llamaServer)
const data = resolve(dataDir || process.env.CAPTION_JOBS_DIR || join(models, 'aigen-jobs'))
let active = null
let holdUntil = 0
if (existsSync(data)) {
for (const id of readdirSync(data)) {
try {
const path = join(data, id, 'status.json')
if (!existsSync(path)) continue
const state = JSON.parse(readFileSync(path, 'utf8'))
if (['running', 'starting', 'cancelling'].includes(state.status)) {
holdUntil = Date.now() + 10000
state.status = 'error'
state.error = 'Caption host restarted. GPU model unloaded.'
writeFileSync(path + '.tmp', JSON.stringify(state))
renameSync(path + '.tmp', path)
}
} catch { /* ignore */ }
}
}
const dir = id => {
if (!/^[a-zA-Z0-9-]{12,80}$/.test(id || '')) throw new Error('Invalid caption job ID.')
return join(data, id)
}
const persist = job => {
const target = join(dir(job.id), 'status.json')
writeFileSync(target + '.tmp', JSON.stringify(job))
renameSync(target + '.tmp', target)
}
const read = id => {
if (active?.job.id === id) return { ...active.job }
const path = join(dir(id), 'status.json')
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null
}
const modelPath = () => join(models, MODEL_NAME)
const mmprojPath = () => join(models, MMPROJ_NAME)
const configured = () => {
try {
if (!existsSync(modelPath()) || !existsSync(mmprojPath())) return false
if (executable.includes('\\') || executable.includes('/')) return existsSync(executable)
return true
} catch { return false }
}
return {
busy: () => Boolean(active) || Date.now() < holdUntil,
configured,
modelsDir: () => models,
read,
async upload(id, stream) {
if (read(id)?.status === 'complete') throw new Error('This caption job has already finished.')
const jobDir = dir(id)
mkdirSync(jobDir, { recursive: true })
const dest = join(jobDir, 'input.upload')
let bytes = 0
await pipeline(stream, new Transform({
transform(chunk, _, callback) {
bytes += chunk.length
callback(bytes > 40 * 1024 * 1024 ? new Error('Image exceeds the 40 MB upload limit.') : null, chunk)
}
}), createWriteStream(dest, { flags: 'w' }))
if (bytes < 32) throw new Error('Image file is empty.')
renameSync(dest, join(jobDir, 'input.png'))
},
async start(body, lease) {
const request = validateCaptionHostRequest(body)
const previous = read(request.id)
if (previous) return previous
if (active || Date.now() < holdUntil) throw Object.assign(new Error('Caption is already running or releasing VRAM.'), { statusCode: 409 })
if (!configured()) throw new Error(`Caption model missing under ${models}. Run scripts/setup-caption.ps1.`)
if (!leaseValid(lease)) throw new Error('GPU reservation expired.')
let imagePath = request.imagePath
if (imagePath) {
if (!existsSync(imagePath)) throw new Error('imagePath does not exist on the GPU host.')
mkdirSync(dir(request.id), { recursive: true })
const dest = join(dir(request.id), `input${extname(imagePath) || '.png'}`)
copyFileSync(imagePath, dest)
imagePath = dest
} else {
imagePath = join(dir(request.id), 'input.png')
if (!existsSync(imagePath)) {
const alt = existsSync(dir(request.id)) && readdirSync(dir(request.id)).find(name => /^input\./i.test(name))
if (!alt) throw new Error('Upload an image first or pass imagePath.')
imagePath = join(dir(request.id), alt)
}
}
const job = {
id: request.id, status: 'starting', message: 'Preparing GPU for caption', progress: 0,
style: request.style, startedAt: Date.now(), checkedAt: Date.now()
}
active = { job, child: null, cancelled: false, abort: new AbortController() }
const run = active
mkdirSync(dir(job.id), { recursive: true })
writeFileSync(join(dir(job.id), 'request.json'), JSON.stringify({ ...request, imagePath, prompt: captionStylePrompt(request.style) }))
persist(job)
try {
if (prepare) await prepare()
if (run.cancelled || !leaseValid(lease)) throw new Error('Caption start cancelled or GPU reservation expired.')
job.status = 'running'; job.message = 'Loading Qwen2.5-VL caption model'; job.progress = 5; persist(job)
const port = await freePort()
// One caption per process. killTree after response unloads VRAM (keep_alive 0).
const child = spawnProcess(executable, [
'-m', modelPath(), '--mmproj', mmprojPath(),
'--host', '127.0.0.1', '--port', String(port),
'-ngl', String(process.env.CAPTION_NGL || '99'),
'-c', String(process.env.CAPTION_CTX || '4096'),
'--jinja'
], { windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } })
run.child = child
const log = chunk => appendFileSync(join(dir(job.id), 'worker.log'), chunk)
child.stdout.on('data', log)
child.stderr.on('data', log)
const watchdog = setInterval(() => {
if (!leaseValid(lease)) { job.error = 'GPU reservation expired; caption stopped.'; run.abort.abort(); void killTree(child) }
}, 5000)
try {
await waitForServer(port, run.abort.signal, serverWaitMs, fetchImpl)
if (run.cancelled) throw new Error('Cancelled')
job.message = 'Captioning'; job.progress = 40; persist(job)
const bytes = readFileSync(imagePath)
const dataUrl = `data:${mimeFor(imagePath)};base64,${bytes.toString('base64')}`
const response = await fetchImpl(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: run.abort.signal.aborted ? run.abort.signal : AbortSignal.timeout(Number(process.env.CAPTION_TIMEOUT_MS || 180_000)),
body: JSON.stringify({
temperature: 0.2,
max_tokens: 512,
messages: [{
role: 'user',
content: [
{ type: 'text', text: captionStylePrompt(request.style) },
{ type: 'image_url', image_url: { url: dataUrl } }
]
}]
})
})
if (run.abort.signal.aborted) throw new Error('Cancelled')
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`llama-server caption failed (${response.status}): ${detail.slice(0, 400)}`)
}
const payload = await response.json()
const text = String(payload?.choices?.[0]?.message?.content || '').trim()
if (!text) throw new Error('Caption model returned empty text.')
job.text = text
job.status = run.cancelled ? 'cancelled' : 'complete'
job.message = job.status === 'complete' ? 'Caption ready' : 'Cancelled'
job.progress = 100
job.checkedAt = Date.now()
persist(job)
return { ...job }
} finally {
clearInterval(watchdog)
await killTree(child)
holdUntil = Date.now() + 2000
if (active === run) active = null
}
} catch (error) {
job.status = run.cancelled ? 'cancelled' : 'error'
job.error = error.message || String(error)
job.message = job.error
job.checkedAt = Date.now()
persist(job)
if (run.child) await killTree(run.child)
if (active === run) active = null
holdUntil = Date.now() + 2000
throw error
}
},
async cancel(id) {
if (active?.job.id !== id) return read(id)
const run = active
run.cancelled = true
run.job.status = 'cancelling'
run.job.message = 'Cancelling'
persist(run.job)
run.abort.abort()
if (run.child) await killTree(run.child)
return read(id)
},
async captionOnce(body, lease) {
const id = body.id || `caption-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
const result = await this.start({ ...body, id }, lease)
if (result.status === 'complete') return { text: result.text, id: result.id, style: result.style }
throw new Error(result.error || 'Caption failed.')
}
}
}
export const captionModelFiles = { model: MODEL_NAME, mmproj: MMPROJ_NAME }
+49 -10
View File
@@ -4,6 +4,7 @@ import { stableMemoryArgs } from './comfy-memory-policy.mjs'
import { createGpuReservation } from './gpu-reservation.mjs'
import { createGpuProxy } from './gpu-proxy.mjs'
import { createYue2Host } from './yue2-host.mjs'
import { createCaptionHost } from './caption-host.mjs'
import http from 'node:http'
import net from 'node:net'
import { execFile, spawn } from 'node:child_process'
@@ -147,7 +148,7 @@ let proxyTarget = 0
function ensureProxyListening() {
if (proxyServer) return
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() })
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() || caption.busy() })
proxyServer.on('error', (error) => {
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || error) }))
})
@@ -819,7 +820,7 @@ function purgeDesktopFiles(body) {
}
const gpuReservation = createGpuReservation({ idle: async () => {
if (yue2.busy() || upscale.busy()) return false
if (yue2.busy() || upscale.busy() || caption.busy()) return false
if ((await trainingLock()).busy) return false
const healthy = await syncProxy()
if (healthy) {
@@ -830,29 +831,65 @@ const gpuReservation = createGpuReservation({ idle: async () => {
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
} })
const yue2 = createYue2Host({
leaseValid: lease => gpuReservation.isOwner(lease),
prepare: async () => {
async function prepareExclusiveGpu(label) {
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
if (label !== 'YuE2' && yue2.busy()) throw new Error('YuE2 is using the GPU.')
if (label !== 'caption' && caption.busy()) throw new Error('Caption is using the GPU.')
if (upscale.busy()) throw new Error('Local upscale is using the GPU.')
const healthy = await syncProxy()
if (healthy) {
const queue = await fetchLocalQueue(healthy)
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuE2 cannot start.')
if (!queue.ok || queue.running || queue.pending) throw new Error(`Comfy is busy; ${label} cannot start.`)
}
// Stop Comfy and refuse to launch while its Python still owns VRAM.
if (healthy || await processUp() || await pythonMainUp().catch(() => false)) {
await stopComfyProcesses()
markAsleep()
}
if (await pythonMainUp()) throw new Error('Comfy has not stopped; retry after the GPU is free.')
}
})
}
const upscale = createUpscaleHost({ leaseValid: token => gpuReservation.isOwner(token) })
const yue2 = createYue2Host({
leaseValid: lease => gpuReservation.isOwner(lease),
prepare: async () => prepareExclusiveGpu('YuE2')
})
const caption = createCaptionHost({
leaseValid: lease => gpuReservation.isOwner(lease),
prepare: async () => prepareExclusiveGpu('caption')
})
async function handleControl(req, res) {
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
const url = new URL(req.url || '/', 'http://localhost')
if (url.pathname.startsWith('/caption')) {
const match = url.pathname.match(/^\/caption\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/cancel)?$/)
if (req.method === 'GET' && url.pathname === '/caption/status') {
return json(res, 200, { configured: caption.configured(), busy: caption.busy(), backend: 'llama.cpp', modelsDir: caption.modelsDir() })
}
if (req.method === 'POST' && url.pathname === '/caption') {
try {
const body = await readJson(req)
return json(res, 200, await caption.captionOnce(body, String(req.headers['x-aigen-gpu-lease'] || '')))
} catch (error) {
return json(res, error.statusCode || 400, { error: error.message || 'Caption failed' })
}
}
if (req.method === 'POST' && url.pathname === '/caption/jobs') {
return json(res, 200, await caption.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
}
if (match && req.method === 'PUT' && match[2] === '/input') {
await caption.upload(match[1], req)
return json(res, 200, { uploaded: true })
}
if (match && req.method === 'POST' && match[2] === '/cancel') return json(res, 200, await caption.cancel(match[1]))
if (match && req.method === 'GET' && !match[2]) {
const job = caption.read(match[1])
return json(res, job ? 200 : 404, job || { error: 'Caption job not found' })
}
return json(res, 404, { error: 'Unknown caption endpoint' })
}
if (url.pathname.startsWith('/upscale/')) {
const match = url.pathname.match(/^\/upscale\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/video|\/cancel)?$/)
if (req.method === 'POST' && url.pathname === '/upscale/jobs') return json(res, 200, upscale.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
@@ -909,12 +946,14 @@ async function handleControl(req, res) {
gpu: gpuReservation.availability(),
training: { busy: lastTraining.busy },
yue2: { busy: yue2.busy(), configured: yue2.configured() },
caption: { busy: caption.busy(), configured: caption.configured() },
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
})
}
if (req.method === 'POST' && url.pathname === '/start') {
if (upscale.busy()) return json(res, 409, { message: 'Local upscale is using the GPU.' })
if (yue2.busy()) return json(res, 409, { message: 'YuE2 is using the GPU.' })
if (caption.busy()) return json(res, 409, { message: 'Caption is using the GPU.' })
const training = await trainingLock()
if (training.busy) {
return json(res, 409, {
@@ -1016,7 +1055,7 @@ const server = http.createServer(async (req, res) => {
} else await handleControl(req, res)
} catch (error) {
req.resume()
if ((String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Music host request failed' })
if ((String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/') || String(req.url || '').startsWith('/caption')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Host request failed' })
if (!res.headersSent) json(res, error.statusCode || 400, { ok: false, message: error.statusCode === 409 ? 'GPU is in use. Waiting for availability.' : 'GPU coordination request failed.' })
}
})
+54
View File
@@ -0,0 +1,54 @@
# Caption (Qwen2.5-VL NSFW Caption V4 GGUF) — verify host files only.
# Models are already on the 5080 host. Do not download. Do not search Hugging Face.
$ErrorActionPreference = 'Stop'
function Resolve-ModelsRoot {
if ($env:COMFY_MODELS_ROOT) { return $env:COMFY_MODELS_ROOT }
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
if (Test-Path $shared) { return $shared }
throw 'Set COMFY_MODELS_ROOT or install Comfy Desktop Shared models.'
}
$root = Join-Path (Resolve-ModelsRoot) 'caption\qwen25vl-7b-nsfw-v4'
if (-not (Test-Path $root)) {
throw "Caption models folder missing: $root"
}
$files = @(
'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf',
'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
)
foreach ($name in $files) {
$path = Join-Path $root $name
if (-not (Test-Path $path)) {
throw "Missing $name under $root. Do not download; use the files already on this host."
}
Write-Host "OK $name"
}
$llama = $null
if ($env:CAPTION_LLAMA_SERVER -and (Test-Path $env:CAPTION_LLAMA_SERVER)) {
$llama = Get-Item $env:CAPTION_LLAMA_SERVER
} else {
$winget = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe\llama-server.exe'
if (Test-Path $winget) { $llama = Get-Item $winget }
else { $llama = Get-Command llama-server -ErrorAction SilentlyContinue }
}
if (-not $llama) {
throw 'llama-server not found. Install with: winget install ggml.llamacpp'
}
$llamaPath = if ($llama.PSObject.Properties['FullName'] -and $llama.FullName) { $llama.FullName } elseif ($llama.PSObject.Properties['Source'] -and $llama.Source) { $llama.Source } else { [string]$llama }
Write-Host "llama-server: $llamaPath"
$ready = @{
model = 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'
mmproj = 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
modelsDir = $root
llamaServer = $llamaPath
readyAt = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json
Set-Content -Path (Join-Path $root 'aigen-ready.json') -Value $ready -Encoding utf8
Write-Host "Caption ready: $root"
+244
View File
@@ -0,0 +1,244 @@
# Download Qwen Image 2.1 GGUF + companions into the live Comfy Desktop Shared models tree.
# Also ensures city96/ComfyUI-GGUF is present on the ComfyUI (1) install.
#
# Example:
# powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
# powershell -File scripts\setup-qwen21.ps1 -SkipDownload
# powershell -File scripts\setup-qwen21.ps1 -ComfyRoot "C:\...\ComfyUI (1)\ComfyUI"
[CmdletBinding()]
param(
[string]$ComfyRoot = '',
[string]$ModelsRoot = '',
[switch]$SkipDownload,
[switch]$SkipNode,
[switch]$Force
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'Continue'
function Write-Step([string]$Message) {
Write-Host ""
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Write-Ok([string]$Message) {
Write-Host " $Message" -ForegroundColor Green
}
function Write-Warn([string]$Message) {
Write-Host " warn $Message" -ForegroundColor Yellow
}
function Resolve-ComfyRoot([string]$Hint) {
$candidates = @(
$Hint,
$env:COMFY_ROOT,
$env:COMFYUI_ROOT,
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI'),
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI\ComfyUI'),
'C:\ComfyUI',
(Join-Path $env:USERPROFILE 'ComfyUI')
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
foreach ($path in $candidates) {
if (Test-Path (Join-Path $path 'main.py')) { return (Resolve-Path $path).Path }
if (Test-Path (Join-Path $path 'ComfyUI\main.py')) { return (Resolve-Path (Join-Path $path 'ComfyUI')).Path }
}
throw "Could not find ComfyUI root (main.py). Pass -ComfyRoot."
}
function Resolve-ModelsRoot([string]$Hint, [string]$ComfyRootPath) {
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
$candidates = @(
$Hint,
$env:COMFY_MODELS_ROOT,
$shared,
(Join-Path $ComfyRootPath 'models')
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
foreach ($path in $candidates) {
if (Test-Path $path) { return (Resolve-Path $path).Path }
}
New-Item -ItemType Directory -Force -Path $shared | Out-Null
return (Resolve-Path $shared).Path
}
function Find-HfCli {
foreach ($name in @('hf', 'huggingface-cli')) {
$cmd = Get-Command $name -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
}
throw "Hugging Face CLI not found (hf / huggingface-cli). Install with: pip install -U huggingface_hub"
}
function Ensure-Dir([string]$Path) {
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Force -Path $Path | Out-Null
}
}
function Assert-ModelFile([string]$Path, [long]$MinBytes) {
if (-not (Test-Path $Path)) { throw "Missing model file: $Path" }
$item = Get-Item $Path
if ($item.Length -lt $MinBytes) {
throw "Suspicious size for $Path ($($item.Length) bytes). Expected >= $MinBytes. Delete and re-run."
}
Write-Ok ("{0} {1:N0} bytes" -f $item.FullName, $item.Length)
}
function Flatten-IfNested([string]$DestDir, [string]$FileName) {
$nested = Get-ChildItem $DestDir -Recurse -File -Filter $FileName -ErrorAction SilentlyContinue |
Where-Object { $_.DirectoryName -ne $DestDir } |
Select-Object -First 1
if ($nested) {
$target = Join-Path $DestDir $FileName
if ((Test-Path $target) -and -not $Force) {
Write-Warn "Nested copy found but destination already exists: $target"
return
}
Move-Item -Force $nested.FullName $target
Write-Ok "Flattened $($nested.FullName) -> $target"
}
}
function Invoke-HfFile {
param(
[string]$Cli,
[string]$Repo,
[string]$File,
[string]$LocalDir
)
Ensure-Dir $LocalDir
$dest = Join-Path $LocalDir (Split-Path $File -Leaf)
if ((Test-Path $dest) -and -not $Force) {
$len = (Get-Item $dest).Length
if ($len -gt 1MB) {
Write-Ok "Already present: $dest ($len bytes)"
return
}
}
Write-Step "hf download $Repo $File"
& $Cli download $Repo $File --local-dir $LocalDir
if ($LASTEXITCODE -ne 0) { throw "hf download failed for $Repo / $File (exit $LASTEXITCODE)" }
Flatten-IfNested -DestDir $LocalDir -FileName (Split-Path $File -Leaf)
}
# --- resolve paths ---
Write-Step "Resolve ComfyUI root"
$root = Resolve-ComfyRoot $ComfyRoot
Write-Ok $root
Write-Step "Resolve models root (Desktop Shared preferred)"
$models = Resolve-ModelsRoot $ModelsRoot $root
Write-Ok $models
$Diff = Join-Path $models 'diffusion_models'
$TE = Join-Path $models 'text_encoders'
$VAE = Join-Path $models 'vae'
Ensure-Dir $Diff
Ensure-Dir $TE
Ensure-Dir $VAE
if (-not $SkipDownload) {
$cli = Find-HfCli
Write-Ok "HF CLI: $cli"
# DiT GGUF only (no Q6/Q5/Q4, no full bf16 DiT)
# Exact invocation required on this host:
# hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
# That form writes into the Hugging Face hub cache (not --local-dir).
Write-Step "hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf"
$ditName = 'qwen-image-2.1-Q8_0.gguf'
$ditDest = Join-Path $Diff $ditName
if ((Test-Path $ditDest) -and -not $Force -and (Get-Item $ditDest).Length -gt 1MB) {
Write-Ok "Already present: $ditDest ($((Get-Item $ditDest).Length) bytes)"
} else {
& $cli download "hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/$ditName"
if ($LASTEXITCODE -ne 0) { throw "hf download failed for DiT GGUF (exit $LASTEXITCODE)" }
$cacheRoot = Join-Path $env:USERPROFILE '.cache\huggingface\hub\models--abenzerps--Qwen-Image-2.1-Uncensored-GGUF'
$found = Get-ChildItem $cacheRoot -Recurse -File -Filter $ditName -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $found) { throw "hf download finished but $ditName was not found under $cacheRoot" }
Ensure-Dir $Diff
Copy-Item -Force $found.FullName $ditDest
Write-Ok "Copied $($found.FullName) -> $ditDest ($((Get-Item $ditDest).Length) bytes)"
}
# abenzerps ships kv_count=0 + Q8_0 1D RMSNorms (packed 136 vs logical 128).
# Tag architecture=qwen_image and promote small/1D tensors to F32 for ComfyUI-GGUF.
Write-Step "Tag DiT GGUF (architecture + F32 norms)"
$tagger = Join-Path $PSScriptRoot 'tag-qwen21-gguf.py'
$pyCandidates = @(
(Join-Path $root '.venv\Scripts\python.exe'),
(Join-Path (Split-Path $root) 'standalone-env\python.exe'),
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)
) | Where-Object { $_ -and (Test-Path $_) }
$py = $pyCandidates | Select-Object -First 1
if (-not $py) { throw "No Python found to run $tagger" }
$fixed = Join-Path $Diff 'qwen-image-2.1-Q8_0.fixed.gguf'
& $py $tagger $ditDest $fixed
if ($LASTEXITCODE -ne 0) { throw "tag-qwen21-gguf.py failed (exit $LASTEXITCODE)" }
Copy-Item -Force $fixed $ditDest
Write-Ok "Installed tagged GGUF at $ditDest"
# TE + VAE companions from Comfy-Org (INT8 TE for 16 GB VRAM)
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'text_encoders/qwen3vl_8b_int8_convrot.safetensors' -LocalDir $TE
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'vae/qwen_image_2.1_vae_bf16.safetensors' -LocalDir $VAE
}
Write-Step "Verify weights"
Assert-ModelFile (Join-Path $Diff 'qwen-image-2.1-Q8_0.gguf') 7000000000
Assert-ModelFile (Join-Path $TE 'qwen3vl_8b_int8_convrot.safetensors') 1000000000
Assert-ModelFile (Join-Path $VAE 'qwen_image_2.1_vae_bf16.safetensors') 100000000
if (-not $SkipNode) {
Write-Step "city96 ComfyUI-GGUF"
$nodes = Join-Path $root 'custom_nodes\ComfyUI-GGUF'
if (-not (Test-Path $nodes)) {
git clone https://github.com/city96/ComfyUI-GGUF $nodes
if ($LASTEXITCODE -ne 0) { throw "git clone ComfyUI-GGUF failed" }
Write-Ok "Cloned $nodes"
} else {
Write-Ok "Already present: $nodes"
}
$py = Join-Path $root '.venv\Scripts\python.exe'
if (-not (Test-Path $py)) {
$py = Join-Path (Split-Path $root) 'standalone-env\python.exe'
}
if (Test-Path $py) {
Write-Step "Ensure gguf package in Comfy python ($py)"
& $py -m pip install -q gguf
if ($LASTEXITCODE -ne 0) { Write-Warn "pip install gguf returned $LASTEXITCODE" }
else { Write-Ok "gguf importable" }
} else {
Write-Warn "Comfy python not found; install gguf into the interpreter that runs main.py"
}
}
# PE system prompts (official steps + Adult appendix). Re-copy after the node pack is re-cloned.
Write-Step "Install Qwen PE system prompts (host/qwen21-pe-prompts)"
$peSrc = Join-Path (Split-Path $PSScriptRoot -Parent) 'host\qwen21-pe-prompts'
$peDest = Join-Path $root 'custom_nodes\ComfyUI-Qwen-Image-2.1-Prompt-Enhancer\prompts'
if (-not (Test-Path $peSrc)) {
Write-Warn "Missing $peSrc — skip PE prompt install"
} elseif (-not (Test-Path (Join-Path $root 'custom_nodes\ComfyUI-Qwen-Image-2.1-Prompt-Enhancer'))) {
Write-Warn "PE node pack not installed at $peDest — skip prompt copy"
} else {
Ensure-Dir $peDest
foreach ($name in @('system_prompt_t2i.txt', 'system_prompt_edit.txt')) {
$from = Join-Path $peSrc $name
$to = Join-Path $peDest $name
if (-not (Test-Path $from)) { throw "Missing PE prompt source: $from" }
Copy-Item -Force $from $to
Write-Ok "Copied $name -> $to"
}
}
Write-Host ""
Write-Host "Done. Restart Comfy only when /status shows gpu.busy=false." -ForegroundColor Cyan
Write-Host "ComfyRoot=$root"
Write-Host "ModelsRoot=$models"
+7
View File
@@ -18,6 +18,12 @@ Get-NetTCPConnection -LocalPort 8199 -State Listen -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 400
# Caption VLM — point at existing host GGUFs (no download).
$captionModels = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models\caption\qwen25vl-7b-nsfw-v4'
$llamaServer = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe\llama-server.exe'
if (Test-Path $captionModels) { $env:CAPTION_MODELS = $captionModels }
if (Test-Path $llamaServer) { $env:CAPTION_LLAMA_SERVER = $llamaServer }
if ($Wait) {
Set-Location $root
& $node $agent
@@ -31,4 +37,5 @@ $psi.WorkingDirectory = $root
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
# CAPTION_* already set on this process; child inherits when UseShellExecute is false
[void][System.Diagnostics.Process]::Start($psi)
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Make abenzerps Qwen Image 2.1 DiT GGUF loadable in city96 ComfyUI-GGUF.
abenzerps/Qwen-Image-2.1-Uncensored-GGUF ships Q8_0 with:
- kv_count=0 (no general.architecture)
- 1D RMSNorm weights quantized to Q8_0 (logical 128 -> packed 136),
which breaks Comfy's fused rms_rope path
This rewrite:
1. Adds general.architecture=qwen_image
2. Dequantizes small / 1D tensors to F32 (city96 convert keeps them hiprec)
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
# Match city96 ComfyUI-GGUF/tools/convert.py QUANTIZATION_THRESHOLD
QUANTIZATION_THRESHOLD = 1024
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("src", type=Path)
ap.add_argument("dst", type=Path, nargs="?", default=None)
ap.add_argument("--arch", default="qwen_image")
ap.add_argument("--name", default="qwen-image-2.1")
ap.add_argument("--inplace", action="store_true", help="Replace src after a successful tag")
args = ap.parse_args()
import gguf
import numpy as np
src = args.src.resolve()
if not src.is_file():
print(f"missing source: {src}", file=sys.stderr)
return 1
dst = (args.dst.resolve() if args.dst else src.with_name(src.stem + ".tagged.gguf"))
reader = gguf.GGUFReader(str(src))
def get_field(name: str):
field = reader.fields.get(name)
if field is None:
return None
try:
return field.contents()
except Exception:
return None
if dst == src:
dst = src.with_name(src.stem + ".retag.gguf")
f32 = gguf.GGMLQuantizationType.F32
compat = {gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16, gguf.GGMLQuantizationType.BF16}
print(f"rewriting {src} -> {dst} (arch={args.arch}, tensors={len(reader.tensors)})")
writer = gguf.GGUFWriter(str(dst), arch=args.arch, use_temp_file=True)
writer.add_name(args.name)
try:
writer.add_type("model")
except Exception:
pass
promoted = 0
for tensor in reader.tensors:
logical = tuple(int(x) for x in tensor.shape)
# GGUF stores dims reversed vs torch; logical numel is what matters
numel = 1
for d in logical:
numel *= d
qtype = tensor.tensor_type
data = tensor.data
if hasattr(data, "copy"):
data = data.copy()
needs_f32 = qtype not in compat and (len(logical) <= 1 or numel <= QUANTIZATION_THRESHOLD)
if needs_f32:
# Dequantize packed blocks -> float32 with logical shape (GGUF order)
dequant = gguf.quants.dequantize(np.asarray(data), qtype).astype(np.float32, copy=False)
expected = numel
if dequant.size != expected:
print(
f"dequant size mismatch {tensor.name}: got {dequant.size} want {expected} "
f"shape={logical} qtype={qtype}",
file=sys.stderr,
)
return 3
data = dequant.reshape(logical)
qtype = f32
promoted += 1
writer.add_tensor(tensor.name, data, raw_dtype=qtype)
writer.write_header_to_file()
writer.write_kv_data_to_file()
writer.write_tensors_to_file(progress=True)
writer.close()
check = gguf.GGUFReader(str(dst))
field = check.fields.get("general.architecture")
got = field.contents() if field is not None else None
print(
f"verified architecture={got!r} tensors={len(check.tensors)} "
f"promoted_f32={promoted} size={dst.stat().st_size}"
)
if got != args.arch or len(check.tensors) != len(reader.tensors):
print("tag failed", file=sys.stderr)
return 2
# Spot-check a known norm weight is F32 @ 128
sample = next((t for t in check.tensors if t.name.endswith("attn.norm_q.weight")), None)
if sample is not None:
print(
f"sample {sample.name}: shape={tuple(int(x) for x in sample.shape)} "
f"type={sample.tensor_type.name} data_shape={tuple(sample.data.shape)}"
)
if args.inplace:
bak = src.with_suffix(src.suffix + ".untagged.bak")
if bak.exists():
bak.unlink()
src.replace(bak)
dst.replace(src)
print(f"inplace: {src} (backup {bak})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+50
View File
@@ -0,0 +1,50 @@
import { validateCaptionBody } from '~/shared/caption.mjs'
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '~/server/utils/studioQueue'
import { getStill } from '~/server/utils/library'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const raw = await readBody(event)
let body: ReturnType<typeof validateCaptionBody>
try {
body = validateCaptionBody(raw)
} catch (e: any) {
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message })
}
assertFolderAccess(event, body.folderId)
const still = getStill(owner, body.stillId)
assertFolderAccess(event, still.folderId)
const row = await addStudioJob({
ownerKey: owner,
kind: 'caption',
familyId: crypto.randomUUID(),
payload: {
prompt: `Describe · ${body.captionStyle}`,
name: `Describe · ${body.captionStyle}`,
folderId: body.folderId,
stillId: still.id,
captionStyle: body.captionStyle,
aspect: 'auto',
width: 0,
height: 0,
steps: 1,
turbo: true,
seed: 0,
cfg: 0,
fps: 24,
samplerName: 'euler',
scheduler: 'simple',
duration: 0,
sound: false,
workflow: 'minimax',
useIdentityRefs: false,
hideThumbnail: false,
referenceStillIds: [],
extensions: [],
queueAutoRun: true
} as StudioJobPayload
})
kickStudioQueue()
return { id: row.id, liveJobId: row.liveJobId, captionStyle: body.captionStyle, stillId: still.id }
})
+6 -1
View File
@@ -48,7 +48,7 @@ export default defineEventHandler(async () => {
host: comfy.host || '',
queueRunning: queue.running,
queuePending: queue.pending,
asleep: Boolean(control?.asleep) && !comfyReachable && !control?.yue2?.busy,
asleep: Boolean(control?.asleep) && !comfyReachable && !control?.yue2?.busy && !control?.caption?.busy,
processUp: Boolean(control?.process ?? control?.processUp),
lastActivityAt: control?.lastActivityAt || null,
stats: comfy.ok ? comfy.stats : undefined
@@ -58,6 +58,11 @@ export default defineEventHandler(async () => {
busy: Boolean(control?.yue2?.busy),
backend: control?.yue2?.backend || 'yue2'
},
caption: {
configured: Boolean(control?.caption?.configured),
busy: Boolean(control?.caption?.busy),
backend: control?.caption?.backend || 'llama.cpp'
},
imageComfy: {
configured: imageConfigured,
ok: imageOk,
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
}
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'private, max-age=86400, stale-while-revalidate=604800')
setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
+21 -8
View File
@@ -1,10 +1,23 @@
import { publicLibrary, publicLibraryPage } from '~/server/utils/library'
export default defineEventHandler((event) => {
const light = String(getQuery(event).light || '') === '1'
const library = publicLibrary(event, { light })
try {
const { owner } = assertLibraryOwner(event)
const folderId = library.folders.find(folder => folder.unlocked)?.id
void maintainLibrary(owner, folderId).catch(() => null)
} catch { /* locked or anonymous — still return what we can */ }
return library
const query = getQuery(event)
const light = String(query.light || '') === '1'
const full = String(query.full || '') === '1'
const limitRaw = query.limit
const hasLimit = limitRaw != null && String(limitRaw) !== ''
// Default GET /api/library → page helper (limit 50). Opt into full catalog with full=1 or legacy light=1.
const wantsFull = full || (light && !hasLimit)
if (!wantsFull) {
return publicLibraryPage(event, {
limit: hasLimit ? Number(limitRaw) || 50 : 50,
cursor: String(query.cursor || ''),
folderId: String(query.folderId || query.folder || ''),
type: String(query.type || query.filter || 'all')
})
}
// Full catalog path — no thumb prewarm on the request (thumbs build on /thumb hit)
return publicLibrary(event, { light: true })
})
+29 -8
View File
@@ -1,23 +1,44 @@
import { existsSync, createReadStream } from 'node:fs'
import { fileEtag, stillPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
import { fileEtag, stillPath, existingStillThumbPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
export default defineEventHandler(async (event) => {
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id)
assertFolderAccess(event, still.folderId)
const wantThumb = String(getQuery(event).thumb || '') === '1'
const path = wantThumb ? await stillThumbPath(owner, id) : stillPath(owner, still.id)
if (!path || !existsSync(path)) {
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
}
const original = stillPath(owner, still.id)
if (wantThumb) {
const path = existingStillThumbPath(owner, id)
if (path && path !== original && existsSync(path)) {
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', wantThumb ? 'image/jpeg' : sniffImageType(path))
setHeader(event, 'Content-Type', 'image/jpeg')
return sendStream(event, createReadStream(path))
}
void stillThumbPath(owner, id).catch(() => null)
setHeader(event, 'Cache-Control', 'no-store')
setResponseStatus(event, 204)
return null
}
if (!existsSync(original)) {
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
}
const etag = fileEtag(original)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'private, max-age=3600')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', sniffImageType(original))
return sendStream(event, createReadStream(original))
})
@@ -0,0 +1,30 @@
import { existsSync, createReadStream } from 'node:fs'
import { fileEtag, stillPath, stillPreviewPath, existingStillPreviewPath } from '~/server/utils/library'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id)
assertFolderAccess(event, still.folderId)
const path = existingStillPreviewPath(owner, id)
const original = stillPath(owner, still.id)
// Ready preview → stream. Never await ffmpeg; never stream the archive master.
if (path && existsSync(path) && path !== original) {
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', 'image/jpeg')
return sendStream(event, createReadStream(path))
}
// Missing → answer now; build .preview.jpg in the background
void stillPreviewPath(owner, id).catch(() => null)
setHeader(event, 'Cache-Control', 'no-store')
setResponseStatus(event, 204)
return null
})
@@ -0,0 +1,30 @@
import { existsSync, createReadStream } from 'node:fs'
import { fileEtag, stillPath, stillThumbPath, existingStillThumbPath } from '~/server/utils/library'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id)
assertFolderAccess(event, still.folderId)
const path = existingStillThumbPath(owner, id)
const original = stillPath(owner, still.id)
// Ready thumb → stream. Never await ffmpeg on the request path.
if (path && existsSync(path) && path !== original) {
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', 'image/jpeg')
return sendStream(event, createReadStream(path))
}
// Missing → answer now; build .thumb.jpg in the background (job save / upload also kick this)
void stillThumbPath(owner, id).catch(() => null)
setHeader(event, 'Cache-Control', 'no-store')
setResponseStatus(event, 204)
return null
})
+8
View File
@@ -0,0 +1,8 @@
import { listPromptSequences } from '~/server/utils/promptSequences'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
return {
sequences: listPromptSequences(owner)
}
})
+25
View File
@@ -0,0 +1,25 @@
import { listPromptSequences, savePromptSequence } from '~/server/utils/promptSequences'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const body = await readBody<{
name?: string
sectionOrder?: unknown
characters?: unknown
sets?: unknown
scenes?: unknown
overwrite?: boolean
}>(event).catch(() => ({}))
const sequence = await savePromptSequence(owner, {
name: body?.name || '',
sectionOrder: body?.sectionOrder,
characters: body?.characters,
sets: body?.sets,
scenes: body?.scenes,
overwrite: body?.overwrite === true
})
return {
sequence,
sequences: listPromptSequences(owner)
}
})
@@ -0,0 +1,11 @@
import { deletePromptSequence, listPromptSequences } from '~/server/utils/promptSequences'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
await deletePromptSequence(owner, id)
return {
ok: true,
sequences: listPromptSequences(owner)
}
})
+9
View File
@@ -0,0 +1,9 @@
import { getPromptSequence } from '~/server/utils/promptSequences'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
return {
sequence: getPromptSequence(owner, id)
}
})
+24
View File
@@ -0,0 +1,24 @@
import { listPromptSequences, updatePromptSequence } from '~/server/utils/promptSequences'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{
name?: string
sectionOrder?: unknown
characters?: unknown
sets?: unknown
scenes?: unknown
}>(event).catch(() => ({}))
const sequence = await updatePromptSequence(owner, id, {
name: body?.name,
sectionOrder: body?.sectionOrder,
characters: body?.characters,
sets: body?.sets,
scenes: body?.scenes
})
return {
sequence,
sequences: listPromptSequences(owner)
}
})
+37 -12
View File
@@ -1,15 +1,40 @@
import { records, saveRecord } from '../../utils/studio2/store';
import { listStudioJobs } from '../../utils/studioQueue';
export default defineEventHandler(event => {
const { owner } = assertLibraryOwner(event), queue=listStudioJobs(owner);
let cleared=0;
import { records, saveRecord } from '../../utils/studio2/store'
import { dismissFinishedStudioJobs, dismissStudioJob, listStudioJobs } from '../../utils/studioQueue'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const body = await readBody<{ id?: string }>(event).catch(() => ({} as { id?: string }))
const queue = listStudioJobs(owner)
let cleared = 0
if (body?.id) {
const row = queue.find(job => job.id === body.id)
if (!row) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
try { assertFolderAccess(event, row.payload.folderId) } catch {
throw createError({ statusCode: 403, statusMessage: 'Folder locked' })
}
await dismissStudioJob(owner, body.id)
return { cleared: 1 }
}
for (const r of records(owner)) {
try { assertFolderAccess(event,r.request.folderId); } catch { continue; }
const status=queue.find(q=>q.id===r.queueId)?.status;
if (['complete','failed','cancelled'].includes(r.state) || ['cancelled','error'].includes(status || '')) {
r.dismissed=true; saveRecord(r); cleared++;
try { assertFolderAccess(event, r.request.folderId) } catch { continue }
const status = queue.find(q => q.id === r.queueId)?.status
if (['complete', 'failed', 'cancelled'].includes(r.state) || ['cancelled', 'error'].includes(status || '')) {
r.dismissed = true
saveRecord(r)
cleared++
}
}
// Retain internal manifests for cleanup retries; remove these records from the visible jobs list.
return {cleared};
});
// Caption (and other finished studio-queue) cards — hide from Queue, keep resultText on the row
const finishedIds = queue
.filter(job => !job.dismissed && ['complete', 'error', 'cancelled'].includes(job.status))
.filter(job => {
try { assertFolderAccess(event, job.payload.folderId); return true } catch { return false }
})
.map(job => job.id)
cleared += await dismissFinishedStudioJobs(owner, finishedIds)
return { cleared }
})
+14 -5
View File
@@ -4,30 +4,39 @@ import { resolveRequestSize } from '../../utils/studio2/size';
import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras';
import { saveRecord, readRecord } from '../../utils/studio2/store';
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue';
import { isXaigenStudio } from '~/utils/videoModels';
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event), raw = await readBody(event);
const xaigen = isXaigenStudio();
let request: any;
try {
request = validateRequest(raw, useRuntimeConfig().public.studio === 'xaigen');
request = validateRequest(raw, xaigen);
}
catch (e: any) {
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
}
const video = ['video', 'extend'].includes(request.mode);
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen');
if (!video)
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, xaigen);
if (!video && request.engine !== 'qwen21')
assertImageV2LoraStack(request.settings.loraStack, request.engine);
assertFolderAccess(event, request.folderId);
for (const id of [request.identityStillId, request.imageAId, request.imageBId, request.maskId, request.endStillId, ...request.guides.map((g: any) => g.stillId)].filter(Boolean))
assertFolderAccess(event, getStill(owner, id).folderId);
if (request.startClipId)
assertFolderAccess(event, getClip(owner, request.startClipId).folderId);
// Header-only size probe — never decode the archive PNG on Generate click
await resolveRequestSize(owner,request);
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
saveRecord(record);
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
const ack = { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
// Enqueue + kick after the client has the id — do not block the response on the worker wake path
void (async () => {
try {
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, studio2Request: structuredClone(request), prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
saveRecord({ ...readRecord(owner, id), queueId: row.id });
} catch { /* poll / resume will pick up waiting studio2 records */ }
kickStudioQueue();
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
})();
return ack;
});
+15 -9
View File
@@ -1,9 +1,15 @@
import { existsSync } from 'node:fs';
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog';
import { publicLibrary, stillPath, clipVideoPath } from '../../utils/library';
export default defineEventHandler(event => {
const { owner } = assertLibraryOwner(event);
const library = publicLibrary(event);
const catalog=studio2LoraCatalog(owner);
return {...library,loraAliases:catalog.aliases,loraTriggers:catalog.triggers, stills:library.stills.filter(s=>existsSync(stillPath(owner,s.id))), clips:library.clips.filter(c=>existsSync(clipVideoPath(owner,c.id))), tracks:library.tracks || []};
});
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
import { publicLibrary } from '../../utils/library'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
// light:true — metadata only; never existsSync every still/clip on the volume
const library = publicLibrary(event, { light: true })
const catalog = studio2LoraCatalog(owner)
return {
...library,
loraAliases: catalog.aliases,
loraTriggers: catalog.triggers,
tracks: library.tracks || []
}
})
+4 -4
View File
@@ -1,19 +1,19 @@
import { readFileSync,writeFileSync,renameSync } from 'node:fs'
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
import { join } from 'node:path'
import { cachedLoras } from '~/shared/studio2/lora-cache.mjs'
import { listStudioLoras } from '../../utils/loras'
import { studio2Root } from '../../utils/studio2/store'
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
export default defineEventHandler(async event => {
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const path = join(studio2Root(), `loras-${owner}.cache`)
// Same live Comfy scan as Settings (/api/loras?fresh=1). Always fresh so CACHE_MS cannot freeze adds.
const fresh = String(getQuery(event).fresh || '') === '1'
const listed = await cachedLoras({
read: () => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null } },
write: value => { writeFileSync(path + '.tmp', JSON.stringify(value)); renameSync(path + '.tmp', path) },
live: () => listStudioLoras({ fresh: true }),
fresh: true
fresh
})
return { ...listed, ...studio2LoraCatalog(owner) }
})
+4 -1
View File
@@ -5,18 +5,21 @@ export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const full = String(getQuery(event).full || '') === '1'
const active = listStudioJobs(owner)
.filter(job => !job.dismissed)
.filter(job => (
job.status === 'waiting'
|| job.status === 'running'
|| job.status === 'held'
|| (job.status === 'error' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|| (job.kind === 'caption' && job.status === 'complete' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|| (job.kind === 'caption' && job.status === 'cancelled' && Date.now() - job.updatedAt < 60 * 60 * 1000)
))
const claimed = new Set(active.map(job => job.shotQueueId).filter(Boolean) as string[])
const queues = listShotQueues(owner)
const byId = new Map(queues.map(queue => [queue.id, queue]))
const jobs = active.map((job) => {
const shots = job.shotQueueId ? byId.get(job.shotQueueId) || null : null
const kind = job.kind === 'edit' ? 'edit' : job.kind === 'music' ? 'music' : 'video'
const kind = job.kind === 'edit' ? 'edit' : job.kind === 'music' ? 'music' : job.kind === 'caption' ? 'caption' : 'video'
const shotSummary = shots ? summarizeQueue(shots) : null
const plannedShots = shotSummary
? undefined
+85
View File
@@ -0,0 +1,85 @@
{
"4": {
"inputs": {
"unet_name": "qwen-image-2.1-Q8_0.gguf"
},
"class_type": "UnetLoaderGGUF",
"_meta": { "title": "Load Qwen 2.1 GGUF" }
},
"8": {
"inputs": {
"device": "auto",
"dtype": "int8",
"model": ["4", 0]
},
"class_type": "QwenImage21Cache",
"_meta": { "title": "Qwen 2.1 Cache" }
},
"5": {
"inputs": {
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": { "title": "Load Qwen 2.1 CLIP" }
},
"6": {
"inputs": {
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
},
"class_type": "VAELoader",
"_meta": { "title": "Load Qwen 2.1 VAE" }
},
"10": {
"inputs": {
"image": "qwen21_edit_source.png"
},
"class_type": "LoadImage",
"_meta": { "title": "image_1 source" }
},
"9": {
"inputs": {
"clip": ["5", 0],
"vae": ["6", 0],
"prompt": "",
"negative_prompt": "",
"resolution": 1024,
"images.image_1": ["10", 0]
},
"class_type": "TextEncodeQwenImage21",
"_meta": { "title": "Text Encode Qwen Image 2.1 Edit" }
},
"15": {
"inputs": {
"seed": 1,
"steps": 25,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"model": ["8", 0],
"positive": ["9", 0],
"negative": ["9", 1],
"latent_image": ["9", 2]
},
"class_type": "KSampler",
"_meta": { "title": "KSampler" }
},
"20": {
"inputs": {
"samples": ["15", 0],
"vae": ["6", 0]
},
"class_type": "VAEDecode",
"_meta": { "title": "VAE Decode" }
},
"21": {
"inputs": {
"filename_prefix": "studio2-qwen21-edit",
"images": ["20", 0]
},
"class_type": "SaveImage",
"_meta": { "title": "Save Image" }
}
}
@@ -0,0 +1,119 @@
{
"4": {
"inputs": {
"unet_name": "qwen-image-2.1-Q8_0.gguf"
},
"class_type": "UnetLoaderGGUF",
"_meta": { "title": "Load Qwen 2.1 GGUF" }
},
"7": {
"inputs": {
"model": ["4", 0],
"lora_name": "Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors",
"strength": 1.0
},
"class_type": "ViggleTurboLora",
"_meta": { "title": "Viggle Turbo LoRA (unmerged)" }
},
"8": {
"inputs": {
"device": "auto",
"dtype": "int8",
"model": ["7", 0]
},
"class_type": "QwenImage21Cache",
"_meta": { "title": "Qwen 2.1 Cache" }
},
"5": {
"inputs": {
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": { "title": "Load Qwen 2.1 CLIP" }
},
"6": {
"inputs": {
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
},
"class_type": "VAELoader",
"_meta": { "title": "Load Qwen 2.1 VAE" }
},
"10": {
"inputs": {
"image": "qwen21_edit_source.png"
},
"class_type": "LoadImage",
"_meta": { "title": "image_1 source" }
},
"9": {
"inputs": {
"clip": ["5", 0],
"vae": ["6", 0],
"prompt": "",
"negative_prompt": "",
"resolution": 1024,
"images.image_1": ["10", 0]
},
"class_type": "TextEncodeQwenImage21",
"_meta": { "title": "Text Encode Qwen Image 2.1 Edit" }
},
"30": {
"inputs": {
"model": ["8", 0],
"conditioning": ["9", 0]
},
"class_type": "BasicGuider",
"_meta": { "title": "CFG off (Turbo)" }
},
"31": {
"inputs": {
"noise_seed": 1
},
"class_type": "RandomNoise",
"_meta": { "title": "Noise" }
},
"32": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": { "title": "euler" }
},
"33": {
"inputs": {
"latent": ["9", 2],
"nodes": "1.0, 0.9375, 0.875, 0.75, 0.5, 0.25"
},
"class_type": "ViggleTurboSigmas",
"_meta": { "title": "Viggle Turbo Sigmas" }
},
"34": {
"inputs": {
"noise": ["31", 0],
"guider": ["30", 0],
"sampler": ["32", 0],
"sigmas": ["33", 0],
"latent_image": ["9", 2]
},
"class_type": "SamplerCustomAdvanced",
"_meta": { "title": "Turbo Sampler" }
},
"35": {
"inputs": {
"samples": ["34", 0],
"vae": ["6", 0]
},
"class_type": "VAEDecode",
"_meta": { "title": "VAE Decode" }
},
"36": {
"inputs": {
"filename_prefix": "studio2-qwen21-edit-turbo",
"images": ["35", 0]
},
"class_type": "SaveImage",
"_meta": { "title": "Save Image" }
}
}
+57
View File
@@ -0,0 +1,57 @@
{
"5": {
"inputs": {
"clip_name": "qwen3.5_9b_qwen_image_2.1_pe_i2i.int8_convrot.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": { "title": "Load Qwen 2.1 PE Edit CLIP" }
},
"10": {
"inputs": {
"image": "qwen21_pe_edit_source.png"
},
"class_type": "LoadImage",
"_meta": { "title": "image_1 source" }
},
"9": {
"inputs": {
"clip": ["5", 0],
"prompt": "",
"image_1": ["10", 0],
"temperature": 1.0,
"top_p": 0.95,
"presence_penalty": 0,
"max_length": 24000,
"seed": 42
},
"class_type": "QwenImage21_EditPromptRewrite",
"_meta": { "title": "Qwen 2.1 Edit Prompt Rewrite" }
},
"30": {
"inputs": { "source": ["9", 0] },
"class_type": "PreviewAny",
"_meta": { "title": "PE positive_prompt" }
},
"31": {
"inputs": { "source": ["9", 2] },
"class_type": "PreviewAny",
"_meta": { "title": "PE wh_ratio" }
},
"32": {
"inputs": { "source": ["9", 3] },
"class_type": "PreviewAny",
"_meta": { "title": "PE ratio_follow" }
},
"33": {
"inputs": { "source": ["9", 4] },
"class_type": "PreviewAny",
"_meta": { "title": "PE thinking" }
},
"34": {
"inputs": { "source": ["9", 5] },
"class_type": "PreviewAny",
"_meta": { "title": "PE parse_ok" }
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"5": {
"inputs": {
"clip_name": "qwen3.5_9b_qwen_image_2.1_pe_t2i.int8_convrot.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": { "title": "Load Qwen 2.1 PE T2I CLIP" }
},
"9": {
"inputs": {
"clip": ["5", 0],
"prompt": "",
"temperature": 1.0,
"top_p": 0.95,
"top_k": 20,
"presence_penalty": 1.5,
"max_new_tokens": 16256,
"seed": 42
},
"class_type": "QwenImage21_T2IPromptRewrite",
"_meta": { "title": "Qwen 2.1 T2I Prompt Rewrite" }
},
"30": {
"inputs": { "source": ["9", 0] },
"class_type": "PreviewAny",
"_meta": { "title": "PE positive_prompt" }
},
"31": {
"inputs": { "source": ["9", 2] },
"class_type": "PreviewAny",
"_meta": { "title": "PE wh_ratio" }
},
"32": {
"inputs": { "source": ["9", 3] },
"class_type": "PreviewAny",
"_meta": { "title": "PE thinking" }
},
"33": {
"inputs": { "source": ["9", 4] },
"class_type": "PreviewAny",
"_meta": { "title": "PE parse_ok" }
}
}
+84
View File
@@ -0,0 +1,84 @@
{
"4": {
"inputs": {
"unet_name": "qwen-image-2.1-Q8_0.gguf"
},
"class_type": "UnetLoaderGGUF",
"_meta": { "title": "Load Qwen 2.1 GGUF" }
},
"3": {
"inputs": {
"shift": 3.1,
"model": ["4", 0]
},
"class_type": "ModelSamplingAuraFlow",
"_meta": { "title": "Qwen 2.1 shift" }
},
"5": {
"inputs": {
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": { "title": "Load Qwen 2.1 CLIP" }
},
"6": {
"inputs": {
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
},
"class_type": "VAELoader",
"_meta": { "title": "Load Qwen 2.1 VAE" }
},
"9": {
"inputs": {
"clip": ["5", 0],
"prompt": "",
"negative_prompt": "",
"resolution": 1024
},
"class_type": "TextEncodeQwenImage21",
"_meta": { "title": "Text Encode Qwen Image 2.1" }
},
"16": {
"inputs": {
"width": 1024,
"height": 1024,
"batch_size": 1
},
"class_type": "EmptyLatentImage",
"_meta": { "title": "Qwen 2.1 canvas" }
},
"15": {
"inputs": {
"seed": 1,
"steps": 25,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"model": ["3", 0],
"positive": ["9", 0],
"negative": ["9", 1],
"latent_image": ["16", 0]
},
"class_type": "KSampler",
"_meta": { "title": "KSampler" }
},
"20": {
"inputs": {
"samples": ["15", 0],
"vae": ["6", 0]
},
"class_type": "VAEDecode",
"_meta": { "title": "VAE Decode" }
},
"21": {
"inputs": {
"filename_prefix": "studio2-qwen21",
"images": ["20", 0]
},
"class_type": "SaveImage",
"_meta": { "title": "Save Image" }
}
}
+110
View File
@@ -0,0 +1,110 @@
{
"4": {
"inputs": {
"unet_name": "qwen-image-2.1-Q8_0.gguf"
},
"class_type": "UnetLoaderGGUF",
"_meta": { "title": "Load Qwen 2.1 GGUF" }
},
"7": {
"inputs": {
"model": ["4", 0],
"lora_name": "Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors",
"strength": 1.0
},
"class_type": "ViggleTurboLora",
"_meta": { "title": "Viggle Turbo LoRA (unmerged)" }
},
"5": {
"inputs": {
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": { "title": "Load Qwen 2.1 CLIP" }
},
"6": {
"inputs": {
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
},
"class_type": "VAELoader",
"_meta": { "title": "Load Qwen 2.1 VAE" }
},
"9": {
"inputs": {
"clip": ["5", 0],
"prompt": "",
"negative_prompt": "",
"resolution": 1024
},
"class_type": "TextEncodeQwenImage21",
"_meta": { "title": "Text Encode Qwen Image 2.1" }
},
"16": {
"inputs": {
"width": 1024,
"height": 1024,
"batch_size": 1
},
"class_type": "EmptyLatentImage",
"_meta": { "title": "Qwen 2.1 canvas" }
},
"30": {
"inputs": {
"model": ["7", 0],
"conditioning": ["9", 0]
},
"class_type": "BasicGuider",
"_meta": { "title": "CFG off (Turbo)" }
},
"31": {
"inputs": {
"noise_seed": 1
},
"class_type": "RandomNoise",
"_meta": { "title": "Noise" }
},
"32": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": { "title": "euler" }
},
"33": {
"inputs": {
"latent": ["16", 0],
"nodes": "1.0, 0.9375, 0.875, 0.75, 0.5, 0.25"
},
"class_type": "ViggleTurboSigmas",
"_meta": { "title": "Viggle Turbo Sigmas" }
},
"34": {
"inputs": {
"noise": ["31", 0],
"guider": ["30", 0],
"sampler": ["32", 0],
"sigmas": ["33", 0],
"latent_image": ["16", 0]
},
"class_type": "SamplerCustomAdvanced",
"_meta": { "title": "Turbo Sampler" }
},
"35": {
"inputs": {
"samples": ["34", 0],
"vae": ["6", 0]
},
"class_type": "VAEDecode",
"_meta": { "title": "VAE Decode" }
},
"36": {
"inputs": {
"filename_prefix": "studio2-qwen21-turbo",
"images": ["35", 0]
},
"class_type": "SaveImage",
"_meta": { "title": "Save Image" }
}
}
+3
View File
@@ -0,0 +1,3 @@
import { resumeCaptionJobs } from '~/server/utils/caption'
export default defineNitroPlugin(() => { resumeCaptionJobs() })
+232
View File
@@ -0,0 +1,232 @@
import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
import { createJob, emitJob, getJob, restoreJob, type Job } from './jobs'
import { getStill, stillPath } from './library'
import { sharedGpuHeaders } from './sharedGpu'
import { CAPTION_STYLES } from '~/shared/caption.mjs'
function settings() {
const config = useRuntimeConfig()
return {
url: String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, ''),
token: String(config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '')
}
}
export function captionConfigured() { return Boolean(settings().url) }
function pendingRoot() {
return join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'caption-pending')
}
export function captionPendingAlive(jobId: string) {
return Boolean(jobId) && existsSync(join(pendingRoot(), `${jobId}.json`))
}
async function request(path: string, method = 'GET', body?: unknown) {
const { url, token } = settings()
if (!url) throw new Error('Caption host is not configured. Set COMFY_CONTROL_URL.')
const response = await fetch(`${url}/caption/${path}`, {
method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(method === 'GET' ? {} : sharedGpuHeaders()),
...(method === 'POST' ? { 'Content-Type': 'application/json' } : {})
},
body: method === 'POST' ? JSON.stringify(body) : body as BodyInit | undefined,
...(method === 'PUT' ? { duplex: 'half' as const } : {}),
// Caption loads a 7B VLM; allow the host POST to finish or fall through to watch
signal: AbortSignal.timeout(method === 'PUT' ? 120_000 : method === 'POST' ? 300_000 : 60_000)
} as RequestInit)
if (!response.ok) {
const detail = await response.json().catch(() => ({})) as { message?: string; error?: string }
throw Object.assign(new Error(detail.message || detail.error || `Caption host returned ${response.status}`), { statusCode: response.status })
}
return response
}
function persist(record: Record<string, unknown>) {
mkdirSync(pendingRoot(), { recursive: true })
const path = join(pendingRoot(), `${record.id}.json`)
writeFileSync(path + '.tmp', JSON.stringify(record))
renameSync(path + '.tmp', path)
}
function clearPending(id: string) {
const path = join(pendingRoot(), `${id}.json`)
if (existsSync(path)) unlinkSync(path)
}
function writeSidecar(owner: string, stillId: string, text: string) {
try {
const image = stillPath(owner, stillId)
if (!existsSync(image)) return
writeFileSync(`${image}.txt`, text, 'utf8')
} catch { /* optional */ }
}
export async function cancelCaptionJob(job: Job) {
await request(`jobs/${job.id}/cancel`, 'POST', {})
job.status = 'cancelled'
emitJob(job, { type: 'error', error: 'Cancelled', message: 'Cancelled' })
}
async function settle(job: Job) {
const { onLiveVideoSettled } = await import('./studioQueue')
await onLiveVideoSettled(job)
clearPending(job.id)
}
async function watch(job: Job, record: {
id: string
owner: string
stillId: string
folderId: string
captionStyle: string
liveId: string
}) {
let failures = 0
while (job.status !== 'cancelled') {
try {
const state = await (await request(`jobs/${job.id}`)).json() as {
status: string; message?: string; error?: string; progress?: number; text?: string
}
if (job.status === 'cancelled') { await settle(job); return }
if (state.status === 'error' || state.status === 'cancelled') {
job.status = state.status === 'cancelled' ? 'cancelled' : 'error'
job.error = state.error || state.message || 'Caption failed'
emitJob(job, { type: 'error', error: job.error, message: job.error })
await settle(job)
return
}
if (state.status === 'complete') {
const text = String(state.text || '').trim()
job.message = 'Caption ready'
job.progress = 100
job.status = 'complete'
job.resultText = text
writeSidecar(record.owner, record.stillId, text)
persist({ ...record, resultText: text, status: 'complete' })
emitJob(job, { type: 'complete', message: 'Caption ready', progress: 100 })
await settle(job)
return
}
failures = 0
job.status = 'running'
emitJob(job, {
type: 'progress',
message: state.message || 'Captioning',
progress: Math.min(95, Math.max(1, Number(state.progress || 10)))
})
} catch (error) {
failures++
emitJob(job, { type: 'status', message: `Caption host check failed; retrying: ${error instanceof Error ? error.message : String(error)}` })
if (failures >= 10) {
job.status = 'error'
job.error = 'Caption host unreachable.'
emitJob(job, { type: 'error', error: job.error, message: job.error })
await settle(job)
return
}
}
await new Promise(resolve => setTimeout(resolve, 1500))
}
await settle(job)
}
export async function startCaptionJob(params: {
ownerKey: string
folderId: string
stillId: string
captionStyle: string
name?: string
}) {
if (!CAPTION_STYLES.includes(params.captionStyle)) throw new Error('Unknown caption style.')
getStill(params.ownerKey, params.stillId)
const job = createJob('caption')
job.caption = true
job.library = {
ownerKey: params.ownerKey,
folderId: params.folderId,
hideThumbnail: false,
name: params.name || `Describe · ${params.captionStyle}`,
prompt: params.captionStyle,
aspect: 'image',
width: 0,
height: 0,
steps: 1,
turbo: true,
seed: 0,
stillId: params.stillId,
engine: 'caption'
}
const record = {
id: job.id,
liveId: job.id,
owner: params.ownerKey,
stillId: params.stillId,
folderId: params.folderId,
captionStyle: params.captionStyle,
createdAt: Date.now(),
status: 'running'
}
persist(record)
void (async () => {
try {
if (job.status === 'cancelled') { await settle(job); return }
await request(`jobs/${job.id}/input`, 'PUT', createReadStream(stillPath(params.ownerKey, params.stillId)))
if (job.status === 'cancelled') { await settle(job); return }
try {
await request('jobs', 'POST', { id: job.id, style: params.captionStyle })
} catch { /* confirm by ID on timeout */ }
await watch(job, record)
} catch (error) {
const statusCode = (error as { statusCode?: number })?.statusCode
if (statusCode && statusCode >= 400 && statusCode < 500) {
job.status = 'error'
job.error = error instanceof Error ? error.message : String(error)
emitJob(job, { type: 'error', error: job.error, message: job.error })
await settle(job)
return
}
emitJob(job, { type: 'status', message: `Checking caption submission: ${error instanceof Error ? error.message : String(error)}` })
await watch(job, record)
}
})()
return job
}
export function resumeCaptionJobs() {
if (!existsSync(pendingRoot())) return
for (const file of readdirSync(pendingRoot()).filter(file => /^[a-zA-Z0-9-]+\.json$/.test(file))) {
try {
const record = JSON.parse(readFileSync(join(pendingRoot(), file), 'utf8'))
if (getJob(record.id)) continue
const job = restoreJob({
id: record.liveId || record.id,
clientId: record.clientId || crypto.randomUUID(),
promptId: '',
startedAt: record.createdAt || Date.now(),
library: {
ownerKey: record.owner,
folderId: record.folderId,
hideThumbnail: false,
prompt: record.captionStyle || 'descriptive',
aspect: 'image',
width: 0,
height: 0,
steps: 1,
turbo: true,
seed: 0,
stillId: record.stillId,
engine: 'caption'
}
})
job.caption = true
job.kind = 'caption'
if (record.resultText) job.resultText = record.resultText
job.message = 'Reconnecting to caption host'
void watch(job, record)
} catch { /* keep bad records */ }
}
}
+34 -1
View File
@@ -139,11 +139,44 @@ export async function updateGenerationLogByStudioJob(
export async function requeueGenerationLog(owner: string, id: string) {
const entry = getGenerationLogEntry(owner, id)
const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue')
let payload = structuredClone(entry.payload)
if (payload?.studio2Id || payload?.studio2Request) {
const { saveRecord, readRecord } = await import('~/server/utils/studio2/store')
let request = payload.studio2Request
if (!request && payload.studio2Id) {
try { request = structuredClone(readRecord(owner, payload.studio2Id).request) }
catch { request = null }
}
if (!request) throw createError({ statusCode: 400, statusMessage: 'That Studio 2 job has no saved request to re-queue.' })
const studio2Id = crypto.randomUUID()
saveRecord({
id: studio2Id,
owner,
request: structuredClone(request),
state: 'waiting',
queuedAt: Date.now(),
familyId: crypto.randomUUID(),
outputs: [],
purgeResult: 'Not yet saved'
})
payload = {
...payload,
...structuredClone(request.settings || {}),
studio2Id,
studio2Request: structuredClone(request),
prompt: request.compiledPrompt || payload.prompt,
folderId: request.folderId || payload.folderId,
extensions: [],
referenceStillIds: [],
useIdentityRefs: false,
queueAutoRun: true
}
}
const studio = await addStudioJob({
ownerKey: owner,
familyId: crypto.randomUUID(),
kind: entry.kind,
payload: structuredClone(entry.payload)
payload
})
await kickStudioQueue()
return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) }
+4 -1
View File
@@ -36,9 +36,11 @@ export interface Job {
upscale?: boolean
yueGp?: boolean
yue2?: boolean
caption?: boolean
resultText?: string
musicActivity?: { checkedAt: number; running: boolean }
id: string
kind?: 'video' | 'edit' | 'music'
kind?: 'video' | 'edit' | 'music' | 'caption'
promptId?: string
clientId: string
status: JobStatus
@@ -222,6 +224,7 @@ export function jobSnapshot(job: Job) {
clipId: job.clipId,
stillId: job.stillId,
trackId: job.trackId,
resultText: job.resultText,
hideThumbnail: job.hideThumbnail,
error: job.error,
folderLocked: job.library?.folderLocked,
+260 -9
View File
@@ -108,6 +108,7 @@ export interface LibraryStill {
role: StillRole
name?: string
prompt?: string
promptRaw?: string
promptPre?: string
promptPost?: string
familyId?: string
@@ -186,6 +187,7 @@ interface Catalog {
}
const writeChains = new Map<string, Promise<unknown>>()
const catalogCache = new Map<string, { catalog: Catalog; mtimeMs: number }>()
export function libraryRoot() {
const config = useRuntimeConfig()
@@ -354,6 +356,13 @@ function ensureOwner(owner: string) {
function readCatalog(owner: string): Catalog {
ensureOwner(owner)
const path = catalogPath(owner)
let mtimeMs = 0
try {
mtimeMs = existsSync(path) ? statSync(path).mtimeMs : 0
} catch { mtimeMs = 0 }
const hit = catalogCache.get(owner)
if (hit && hit.mtimeMs === mtimeMs && mtimeMs > 0) return hit.catalog
let catalog: Catalog
try {
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
@@ -363,8 +372,11 @@ function readCatalog(owner: string): Catalog {
}
catalog = emptyCatalog()
writeFileSync(path, JSON.stringify(catalog, null, 2))
try { mtimeMs = statSync(path).mtimeMs } catch { mtimeMs = Date.now() }
catalogCache.set(owner, { catalog, mtimeMs })
return catalog
}
catalogCache.set(owner, { catalog, mtimeMs })
return catalog
}
@@ -373,6 +385,14 @@ function writeCatalog(owner: string, catalog: Catalog) {
const tmp = catalogPath(owner) + '.tmp'
writeFileSync(tmp, JSON.stringify(catalog, null, 2))
renameSync(tmp, catalogPath(owner))
let mtimeMs = Date.now()
try { mtimeMs = statSync(catalogPath(owner)).mtimeMs } catch { /* ignore */ }
catalogCache.set(owner, { catalog, mtimeMs })
}
function invalidateCatalogCache(owner?: string) {
if (owner) catalogCache.delete(owner)
else catalogCache.clear()
}
function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
@@ -614,6 +634,164 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
}
}
/** Paginated metadata page — reads catalog rows only; no existsSync / full publicLibrary map. */
export function publicLibraryPage(
event: H3Event,
opts: { limit?: number; cursor?: string; folderId?: string; type?: string } = {}
) {
const limit = Math.min(Math.max(Number(opts.limit) || 50, 1), 60)
const cursor = String(opts.cursor || '')
const folderId = String(opts.folderId || '')
const typeFilter = String(opts.type || 'all')
const owner = libraryOwnerKey(event)
const catalog = readCatalog(owner)
const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase()))
const clipCounts = new Map<string, number>()
const imageCounts = new Map<string, number>()
const trackCounts = new Map<string, number>()
for (const clip of catalog.clips) {
clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1)
}
for (const still of catalog.stills) {
if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue
imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1)
}
for (const track of catalog.tracks || []) {
trackCounts.set(track.folderId, (trackCounts.get(track.folderId) || 0) + 1)
}
const folders = catalog.folders.map(folder => publicFolder(
event,
folder,
clipCounts.get(folder.id) || 0,
imageCounts.get(folder.id) || 0,
trackCounts.get(folder.id) || 0
))
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
type Row = {
id: string
name: string
type: 'still' | 'clip' | 'track'
createdAt: number
thumbUrl: string
folderId: string
prompt?: string
promptPre?: string
promptPost?: string
width?: number
height?: number
role?: string
hideInput?: boolean
filename?: string
duration?: number
ext?: string
}
const rows: Row[] = []
if (typeFilter !== 'videos' && typeFilter !== 'music') {
for (const still of catalog.stills) {
if (!unlockedIds.has(still.folderId)) continue
if (folderId && folderId !== 'all' && still.folderId !== folderId) continue
if (isPipelineStill(catalog, still, clipIds)) continue
rows.push({
id: still.id,
name: still.name || still.filename || still.id,
type: 'still',
createdAt: still.createdAt || 0,
thumbUrl: `/api/library/stills/${still.id}/thumb`,
folderId: still.folderId,
prompt: still.prompt,
promptPre: still.promptPre,
promptPost: still.promptPost,
width: still.width,
height: still.height,
role: stillRole(still),
hideInput: still.hideInput,
filename: still.filename
})
}
}
if (typeFilter !== 'images' && typeFilter !== 'music') {
for (const clip of catalog.clips) {
if (!unlockedIds.has(clip.folderId)) continue
if (folderId && folderId !== 'all' && clip.folderId !== folderId) continue
rows.push({
id: clip.id,
name: clip.name || clip.id,
type: 'clip',
createdAt: clip.createdAt || 0,
thumbUrl: `/api/library/clips/${clip.id}/thumb`,
folderId: clip.folderId,
prompt: clip.prompt,
promptPre: clip.promptPre,
promptPost: clip.promptPost,
width: clip.width,
height: clip.height,
duration: clip.duration
})
}
}
if (typeFilter !== 'images' && typeFilter !== 'videos') {
for (const track of catalog.tracks || []) {
if (!unlockedIds.has(track.folderId)) continue
if (folderId && folderId !== 'all' && track.folderId !== folderId) continue
rows.push({
id: track.id,
name: track.name || track.id,
type: 'track',
createdAt: track.createdAt || 0,
thumbUrl: '',
folderId: track.folderId,
duration: track.duration,
ext: track.ext
})
}
}
rows.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0) || String(b.id).localeCompare(String(a.id)))
let start = 0
if (cursor) {
if (cursor.startsWith('offset:')) {
start = Math.max(0, Math.min(rows.length, Number(cursor.slice(7)) || 0))
} else {
const idx = rows.findIndex(row => `${row.type}:${row.id}` === cursor)
// Unknown cursor → end (never restart at 0 or the client loops the first page forever)
start = idx >= 0 ? idx + 1 : rows.length
}
}
const page = rows.slice(start, start + limit)
const nextStart = start + page.length
const hasMore = nextStart < rows.length
const nextCursor = hasMore ? `offset:${nextStart}` : null
const stillIds = new Set(page.filter(r => r.type === 'still').map(r => r.id))
const clipIdPage = new Set(page.filter(r => r.type === 'clip').map(r => r.id))
const trackIds = new Set(page.filter(r => r.type === 'track').map(r => r.id))
return {
folders,
items: page,
nextCursor,
hasMore,
limit,
stills: catalog.stills
.filter(s => stillIds.has(s.id))
.map(still => ({ ...still, role: stillRole(still) })),
clips: catalog.clips
.filter(c => clipIdPage.has(c.id))
.map(clip => ({ ...clip, name: clip.name || clip.id, hasThumbnail: !clip.hideThumbnail, hasVideo: true })),
tracks: (catalog.tracks || [])
.filter(t => trackIds.has(t.id))
.map(track => ({ ...track, hasAudio: true })),
drafts: catalog.drafts,
loraAliases: catalog.loraAliases || {},
loraTriggers: catalog.loraTriggers || {},
keepPrompts: catalog.keepPrompts || emptyKeepPrompts(),
total: rows.length
}
}
export function assertLibraryOwner(event: H3Event) {
const owner = libraryOwnerKey(event)
return { owner, catalog: readCatalog(owner) }
@@ -829,7 +1007,7 @@ export async function ensureScaledThumb(source: string, dest: string) {
if (pending) return pending
const build = withThumbSlot(async () => {
const ok = await buildStillThumb(source, dest)
return ok ? dest : source
return ok ? dest : ''
}).finally(() => {
thumbBuilds.delete(key)
})
@@ -1100,14 +1278,42 @@ export function stillThumbFile(owner: string, id: string) {
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
}
export function stillPreviewFile(owner: string, id: string) {
return join(ownerRoot(owner), 'stills', `${id}.preview.jpg`)
}
/** Sync check — never kicks ffmpeg. Empty string when thumb is missing or stale. */
export function existingStillThumbPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillThumbFile(owner, id)
if (!existsSync(source) || !existsSync(dest)) return ''
try {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { return '' }
return ''
}
/** Sync check for stage preview — never kicks ffmpeg. */
export function existingStillPreviewPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillPreviewFile(owner, id)
if (!existsSync(source) || !existsSync(dest)) return ''
try {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { return '' }
return ''
}
function removeStillFiles(owner: string, id: string) {
rmSync(stillPath(owner, id), { force: true })
rmSync(stillThumbFile(owner, id), { force: true })
rmSync(stillPreviewFile(owner, id), { force: true })
}
let thumbSlots = 0
const thumbWaiters: Array<() => void> = []
const thumbBuilds = new Map<string, Promise<string>>()
const previewBuilds = new Map<string, Promise<string>>()
function withThumbSlot<T>(fn: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
@@ -1124,14 +1330,14 @@ function withThumbSlot<T>(fn: () => Promise<T>) {
})
}
function buildStillThumb(source: string, dest: string) {
function buildStillJpeg(source: string, dest: string, scale: string, quality: string) {
return new Promise<boolean>((resolve) => {
const proc = spawn('ffmpeg', [
'-y',
'-i', source,
'-vf', 'scale=320:-2',
'-vf', scale,
'-frames:v', '1',
'-q:v', '5',
'-q:v', quality,
dest
], { stdio: 'ignore' })
const timer = setTimeout(() => {
@@ -1149,6 +1355,14 @@ function buildStillThumb(source: string, dest: string) {
})
}
function buildStillThumb(source: string, dest: string) {
return buildStillJpeg(source, dest, 'scale=384:-2', '5')
}
function buildStillPreview(source: string, dest: string) {
return buildStillJpeg(source, dest, 'scale=1600:-2', '3')
}
export async function stillThumbPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillThumbFile(owner, id)
@@ -1158,12 +1372,13 @@ export async function stillThumbPath(owner: string, id: string) {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { /* rebuild */ }
}
const key = `${owner}:${id}`
const key = `${owner}:${id}:thumb`
const pending = thumbBuilds.get(key)
if (pending) return pending
const build = withThumbSlot(async () => {
const ok = await buildStillThumb(source, dest)
return ok ? dest : source
// Never return the archive master — callers must not stream originals as thumbs
return ok ? dest : ''
}).finally(() => {
thumbBuilds.delete(key)
})
@@ -1171,11 +1386,40 @@ export async function stillThumbPath(owner: string, id: string) {
return build
}
export async function ensureStillThumbnails(owner: string) {
export async function stillPreviewPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillPreviewFile(owner, id)
if (!existsSync(source)) return ''
if (existsSync(dest)) {
try {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { /* rebuild */ }
}
const key = `${owner}:${id}:preview`
const pending = previewBuilds.get(key)
if (pending) return pending
const build = withThumbSlot(async () => {
const ok = await buildStillPreview(source, dest)
// Never return the archive master as a stage preview
return ok ? dest : ''
}).finally(() => {
previewBuilds.delete(key)
})
previewBuilds.set(key, build)
return build
}
export async function ensureStillThumbnails(owner: string, opts?: { limit?: number }) {
const catalog = readCatalog(owner)
const cap = Math.max(0, Math.min(Number(opts?.limit) || 2, 8))
let built = 0
for (const still of catalog.stills) {
if (built >= cap) break
if (isPipelineStill(catalog, still)) continue
const dest = stillThumbFile(owner, still.id)
if (existsSync(dest)) continue
await stillThumbPath(owner, still.id)
built++
}
}
@@ -1185,7 +1429,8 @@ export async function maintainLibrary(owner: string, folderId?: string) {
const prev = lastMaintain.get(owner) || 0
if (Date.now() - prev < 20000) return
lastMaintain.set(owner, Date.now())
void ensureStillThumbnails(owner).catch(() => null)
// At most 2 missing thumbs per tick — never walk the whole catalog on list
void ensureStillThumbnails(owner, { limit: 2 }).catch(() => null)
const live = await fetchLiveQueue().catch(() => null)
const busy = Boolean(live && live.running > 0)
if (!busy) {
@@ -1333,6 +1578,7 @@ export async function saveStill(params: {
role?: StillRole
name?: string
prompt?: string
promptRaw?: string
promptPre?: string
promptPost?: string
familyId?: string
@@ -1354,8 +1600,9 @@ export async function saveStill(params: {
const hideInput = typeof params.hideInput === 'boolean' ? params.hideInput === true : existing.hideInput
const name = typeof params.name === 'string' ? params.name.trim().slice(0, 80) : existing.name
const prompt = typeof params.prompt === 'string' ? params.prompt : existing.prompt
const promptRaw = typeof params.promptRaw === 'string' ? params.promptRaw : existing.promptRaw
const nextSettings = params.settings ? normalizeStillSettings(params.settings) : undefined
if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt || nextSettings) {
if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt || existing.promptRaw !== promptRaw || nextSettings) {
await mutate(params.ownerKey, (next) => {
const item = next.stills.find(still => still.id === existing.id)
if (!item) return
@@ -1363,6 +1610,7 @@ export async function saveStill(params: {
item.role = role
if (name) item.name = name
if (prompt) item.prompt = prompt
if (params.promptRaw != null) item.promptRaw = params.promptRaw || undefined
if (params.promptPre != null) item.promptPre = params.promptPre || undefined
if (params.promptPost != null) item.promptPost = params.promptPost || undefined
if (params.familyId) item.familyId = params.familyId
@@ -1374,6 +1622,7 @@ export async function saveStill(params: {
existing.role = role
if (name) existing.name = name
if (prompt) existing.prompt = prompt
if (params.promptRaw != null) existing.promptRaw = params.promptRaw || undefined
if (params.promptPre != null) existing.promptPre = params.promptPre || undefined
if (params.promptPost != null) existing.promptPost = params.promptPost || undefined
if (nextSettings) existing.settings = nextSettings
@@ -1395,6 +1644,7 @@ export async function saveStill(params: {
role,
name: params.name?.trim().slice(0, 80) || undefined,
prompt: params.prompt || undefined,
promptRaw: params.promptRaw || undefined,
promptPre: params.promptPre || undefined,
promptPost: params.promptPost || undefined,
familyId: params.familyId,
@@ -1408,6 +1658,7 @@ export async function saveStill(params: {
next.stills.unshift(still)
})
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
void stillPreviewPath(params.ownerKey, still.id).catch(() => null)
return still
}
+192
View File
@@ -0,0 +1,192 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { ownerRoot } from '~/server/utils/library'
import {
normalizePromptSequence,
normalizePromptSequenceName,
normalizeCharacter,
normalizeSet,
normalizeScene,
normalizeSectionOrder,
emptyCharacter,
emptyScene,
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
type PromptSequence,
type PromptSequenceCharacter,
type PromptSequenceSectionKey,
type PromptSequenceSet,
type PromptSequenceScene
} from '~/utils/promptSequences'
const writeChains = new Map<string, Promise<unknown>>()
function sequencesPath(owner: string) {
return join(ownerRoot(owner), 'prompt-sequences.json')
}
function ensureOwner(owner: string) {
mkdirSync(ownerRoot(owner), { recursive: true })
}
function readSequences(owner: string): PromptSequence[] {
ensureOwner(owner)
const path = sequencesPath(owner)
if (!existsSync(path)) return []
try {
const parsed = JSON.parse(readFileSync(path, 'utf8'))
return Array.isArray(parsed)
? parsed.map(normalizePromptSequence).filter((item): item is PromptSequence => Boolean(item))
: []
} catch {
return []
}
}
function writeSequences(owner: string, sequences: PromptSequence[]) {
ensureOwner(owner)
const path = sequencesPath(owner)
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(sequences, null, 2))
renameSync(tmp, path)
}
function mutate<T>(owner: string, fn: (sequences: PromptSequence[]) => T): Promise<T> {
const prev = writeChains.get(owner) || Promise.resolve()
const run = prev.then(() => {
const sequences = readSequences(owner)
const result = fn(sequences)
writeSequences(owner, sequences)
return result
})
writeChains.set(owner, run.then(() => undefined, () => undefined))
return run
}
function normalizeCharactersInput(raw: unknown): PromptSequenceCharacter[] {
if (!Array.isArray(raw) || !raw.length) return [emptyCharacter()]
return raw.map(normalizeCharacter)
}
function normalizeSetsInput(raw: unknown): PromptSequenceSet[] {
if (!Array.isArray(raw)) return []
return raw.map(normalizeSet)
}
function normalizeScenesInput(
raw: unknown,
characters: PromptSequenceCharacter[],
sets: PromptSequenceSet[]
): PromptSequenceScene[] {
if (!Array.isArray(raw) || !raw.length) return [emptyScene(1, characters, sets)]
return raw.map((scene, index) => normalizeScene(scene, index, characters, sets))
}
export function listPromptSequences(owner: string) {
return readSequences(owner).sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name))
}
export function getPromptSequence(owner: string, id: string) {
const wanted = String(id || '').trim()
const sequence = readSequences(owner).find(item => item.id === wanted)
if (!sequence) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
return sequence
}
export async function savePromptSequence(owner: string, input: {
name: string
sectionOrder?: unknown
characters?: unknown
sets?: unknown
scenes?: unknown
overwrite?: boolean
}) {
const name = normalizePromptSequenceName(input.name)
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
const characters = normalizeCharactersInput(input.characters)
const sets = normalizeSetsInput(input.sets)
const scenes = normalizeScenesInput(input.scenes, characters, sets)
const sectionOrder = input.sectionOrder !== undefined
? normalizeSectionOrder(input.sectionOrder)
: [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]
const now = Date.now()
return mutate(owner, (sequences) => {
const existing = sequences.find(item => item.name.toLowerCase() === name.toLowerCase())
if (existing && !input.overwrite) {
throw createError({ statusCode: 409, statusMessage: `Overwrite sequence “${existing.name}”?` })
}
if (existing) {
existing.sectionOrder = sectionOrder
existing.characters = characters
existing.sets = sets
existing.scenes = scenes
existing.updatedAt = now
return existing
}
const created: PromptSequence = {
id: crypto.randomUUID(),
name,
sectionOrder,
characters,
sets,
scenes,
createdAt: now,
updatedAt: now
}
sequences.unshift(created)
return created
})
}
export async function updatePromptSequence(owner: string, id: string, input: {
name?: string
sectionOrder?: unknown
characters?: unknown
sets?: unknown
scenes?: unknown
}) {
const wanted = String(id || '').trim()
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
return mutate(owner, (sequences) => {
const existing = sequences.find(item => item.id === wanted)
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
if (input.name !== undefined) {
const name = normalizePromptSequenceName(input.name)
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
const clash = sequences.find(item => item.id !== wanted && item.name.toLowerCase() === name.toLowerCase())
if (clash) throw createError({ statusCode: 409, statusMessage: `A sequence named “${clash.name}” already exists` })
existing.name = name
}
if (input.sectionOrder !== undefined) {
existing.sectionOrder = normalizeSectionOrder(input.sectionOrder)
} else if (!existing.sectionOrder?.length) {
existing.sectionOrder = [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER] as PromptSequenceSectionKey[]
}
if (input.characters !== undefined || input.sets !== undefined || input.scenes !== undefined) {
const characters = input.characters !== undefined
? normalizeCharactersInput(input.characters)
: existing.characters
const sets = input.sets !== undefined
? normalizeSetsInput(input.sets)
: (existing.sets || [])
const scenes = input.scenes !== undefined
? normalizeScenesInput(input.scenes, characters, sets)
: existing.scenes.map((scene, index) => normalizeScene(scene, index, characters, sets))
existing.characters = characters
existing.sets = sets
existing.scenes = scenes
}
existing.updatedAt = Date.now()
return existing
})
}
export async function deletePromptSequence(owner: string, id: string) {
const wanted = String(id || '').trim()
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
return mutate(owner, (sequences) => {
const index = sequences.findIndex(item => item.id === wanted)
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
const [removed] = sequences.splice(index, 1)
return removed
})
}
+324 -43
View File
@@ -8,13 +8,24 @@ import { queueSeeds } from '~/shared/studio2/seed.mjs';
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import template from '../../assets/studio2_minimax_native.json';
import qwen21Template from '../../assets/studio2_qwen21_t2i.json'
import qwen21EditTemplate from '../../assets/studio2_qwen21_edit.json'
import qwen21T2iTurboTemplate from '../../assets/studio2_qwen21_t2i_turbo.json'
import qwen21EditTurboTemplate from '../../assets/studio2_qwen21_edit_turbo.json'
import qwen21PeT2iTemplate from '../../assets/studio2_qwen21_pe_t2i.json'
import qwen21PeEditTemplate from '../../assets/studio2_qwen21_pe_edit.json';
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
import { resolveQwen21Size } from '~/shared/studio2/qwen21-size.mjs';
import { ensureQwen21EditPrompt, stitchQwen21PeEditPrompt } from '~/shared/studio2/qwen21-edit.mjs';
import { applyPhotosToRequest, injectQwenEditMentionTags } from '~/shared/studio2/photos.mjs';
import { qwen21PeRefusal } from '~/shared/studio2/qwen21-pe.mjs';
import { QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from '~/shared/studio2/qwen21-turbo.mjs';
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
import { readRecord, saveRecord, studio2Root, records } from './store';
import { ensureComfyReady } from '../comfyLifecycle';
import { comfyFetch, queuePrompt, fetchHistory, extractVideo } from '../comfy';
import { comfyFetch, queuePrompt, fetchHistory, extractVideo, freeComfyVram } from '../comfy';
import { extractEditedImage } from '../imageComfy';
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
@@ -34,10 +45,177 @@ function update(r: any, state: string) { r.state = state; r.updatedAt = Date.now
// extend/video start frame (which is either the exact anchor PNG or an already-canvas-sized
// extracted last frame). Fitting these every hop is what destroys identity across chained extends.
const NEVER_FIT = new Set(['hero', 'start']);
function previewAnyText(outputs: Record<string, any> | undefined, nodeId: string): string {
const node = outputs?.[nodeId]
if (!node || typeof node !== 'object') return ''
const raw = node.text ?? node.STRING ?? node.source
if (Array.isArray(raw)) return String(raw[0] ?? '')
if (raw == null) return ''
return String(raw)
}
function harvestQwen21Pe(history: Record<string, any> | null | undefined, promptId: string, edit: boolean) {
const outputs = history?.[promptId]?.outputs as Record<string, any> | undefined
const positive_prompt = previewAnyText(outputs, '30').trim()
const wh_ratio = previewAnyText(outputs, '31').trim()
const ratio_follow = edit ? previewAnyText(outputs, '32').trim() : ''
const thinking = previewAnyText(outputs, edit ? '33' : '32').trim()
const parseRaw = previewAnyText(outputs, edit ? '34' : '33').trim()
const parse_ok = /^(true|1|yes)$/i.test(parseRaw)
return { positive_prompt, wh_ratio, ratio_follow, thinking, parse_ok }
}
async function waitPromptHistory(r: any, job: Job, promptId: string) {
let history: any = null, missingSince = 0
for (;;) {
try { history = await fetchHistory(promptId) }
catch { history = null }
const e = history?.[promptId]
if (job.status === 'cancelled') throw new Error('Cancelled')
if (['error', 'interrupted'].includes(e?.status?.status_str))
throw new Error('Comfy generation failed or was interrupted; inspect the host error log.')
if (!e) {
try {
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) })
if (response.ok) {
const q = await response.json() as any
const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === promptId)
if (present) missingSince = 0
else if (!missingSince) missingSince = Date.now()
else if (Date.now() - missingSince > 60000) throw new Error('PROMPT_MISSING')
}
} catch (error: any) {
if (error.message === 'PROMPT_MISSING')
throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.')
}
}
if (e?.status?.completed || e?.status?.status_str === 'success') break
if (job.status === 'cancelled') throw new Error('Cancelled')
await new Promise(resolve => setTimeout(resolve, 1500))
}
return history
}
function finalizeQwen21EditSample(rawPrompt: string, request: any) {
const photos = Array.isArray(request?.photos) ? request.photos : []
const tagged = injectQwenEditMentionTags(String(rawPrompt || ''), photos)
return ensureQwen21EditPrompt(tagged, { hasImage2: !!request?.imageBId })
}
async function runQwen21PromptEnhance(r: any, job: Job) {
const q = r.request, s = q.settings
if (!q.enhancePrompt || q.engine !== 'qwen21' || !['generate', 'edit'].includes(q.mode)) return
const edit = q.mode === 'edit'
if (Array.isArray(q.photos) && q.photos.length) {
const bound = applyPhotosToRequest(q)
q.imageAId = bound.imageAId
q.imageBId = bound.imageBId
q.identityStillId = bound.identityStillId
q.photos = bound.photos
}
// Edit → PE-I2I only (pe_i2i). Generate → PE-T2I only. Never cross-feed.
if (edit && !q.imageAId)
throw new Error('Add a photo and mark it Photo to change.')
update(r, 'enhancing')
emitJob(job, { type: 'status', message: 'Enhancing prompt…' })
// promptRaw = exactly what they typed (compiled from sections before PE).
const userPrompt = String(q.compiledPrompt || '')
r.request.promptRaw = userPrompt
// Edit PE gets the raw edit instruction + canvas still — not the keep stanza.
const peInput = userPrompt
const graph = structuredClone(edit ? qwen21PeEditTemplate : qwen21PeT2iTemplate)
graph['9'].inputs.prompt = peInput
graph['9'].inputs.seed = s.seed
if (edit) {
// Photo to change → image_1. Outfit/extra → image_2 when present.
const a = await upload(r, 'pe-source', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageAId).id)))
graph['10'].inputs.image = a
graph['9'].inputs.image_1 = ['10', 0]
if (q.imageBId) {
const b = await upload(r, 'pe-compose', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageBId).id)))
graph['11'] = {
inputs: { image: b },
class_type: 'LoadImage',
_meta: { title: 'image_2 optional' }
}
graph['9'].inputs.image_2 = ['11', 0]
}
}
r.peGraphId = edit ? 'studio2_qwen21_pe_edit.json' : 'studio2_qwen21_pe_t2i.json'
// PE is stage 1 of the same studio job — not a complete/requeue.
r.progress = null
saveRecord(r)
const queued = await queuePrompt(graph, job.clientId)
r.pePromptId = queued.prompt_id
// watchProgress listens on promptId; point it at the PE Comfy prompt for this stage.
r.promptId = queued.prompt_id
job.promptId = queued.prompt_id
saveRecord(r)
const history = await waitPromptHistory(r, job, queued.prompt_id)
const result = harvestQwen21Pe(history, queued.prompt_id, edit)
const refusal = qwen21PeRefusal(userPrompt, result)
if (refusal.refused) {
// Fail open: keep the job, send the raw brief (Edit keep-identity stanza still applies).
const compiled = edit ? finalizeQwen21EditSample(userPrompt, q) : userPrompt
r.request.compiledPrompt = compiled
r.request.prompt = compiled
r.request.enhance = {
used: false,
wh_ratio: '',
...(edit ? { ratio_follow: '' } : {}),
parse_ok: !!result.parse_ok,
refused: true,
skipReason: refusal.reason,
thinking: result.thinking || '',
comfyPromptId: queued.prompt_id,
}
} else if (edit) {
// Edit only: stanza + raw first; PE chunk only if it is an edit directive (never PE-alone).
// Generate keeps the long observer paragraph as the product — no describe sanitize.
const stitched = stitchQwen21PeEditPrompt(userPrompt, result.positive_prompt)
const compiled = finalizeQwen21EditSample(stitched.prompt, q)
r.request.compiledPrompt = compiled
r.request.prompt = compiled
r.request.enhance = {
used: !stitched.skippedAsDescribe,
wh_ratio: result.wh_ratio || '',
ratio_follow: result.ratio_follow || '',
parse_ok: !!result.parse_ok,
refused: false,
...(stitched.skippedAsDescribe ? { skippedAsDescribe: true } : {}),
thinking: result.thinking || '',
comfyPromptId: queued.prompt_id,
}
} else {
r.request.compiledPrompt = result.positive_prompt
r.request.prompt = result.positive_prompt
r.request.enhance = {
used: true,
wh_ratio: result.wh_ratio || '',
parse_ok: !!result.parse_ok,
refused: false,
thinking: result.thinking || '',
comfyPromptId: queued.prompt_id,
}
}
if (edit || q.mode === 'generate') r.request.task = q.mode
// Stage flip PE → generate: clear PE prompt id and reset the bar (no leftover 100%).
r.pePromptId = ''
r.promptId = ''
job.promptId = ''
r.progress = null
saveRecord(r)
await freeComfyVram()
// Same job continues into generate — do not complete or requeue.
update(r, 'submitting')
emitJob(job, { type: 'status', message: 'Generating…' })
}
async function upload(r: any, name: string, data: Buffer): Promise<string> {
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
let fitted = false;
if (!NEVER_FIT.has(name) && needsFit(data, r.request.settings.width, r.request.settings.height)) {
if (!NEVER_FIT.has(name) && r.request?.engine !== 'qwen21' && needsFit(data, r.request.settings.width, r.request.settings.height)) {
data = await fitStill(data, r.request.settings.width, r.request.settings.height);
fitted = true;
}
@@ -60,10 +238,21 @@ async function upload(r: any, name: string, data: Buffer): Promise<string> {
}
async function prepareGraph(r: any) {
const q = r.request, s = q.settings, prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
// Photos roles → graph sockets (imageAId / imageBId / identityStillId). Payload is source of truth.
if (Array.isArray(q.photos) && q.photos.length) {
const bound = applyPhotosToRequest(q)
q.imageAId = bound.imageAId
q.imageBId = bound.imageBId
q.identityStillId = bound.identityStillId
q.photos = bound.photos
}
const load = async (id: string, label: string) => id ? upload(r, label, readFileSync(stillPath(r.owner, getStill(r.owner, id).id))) : '';
const video=['video','extend'].includes(q.mode);
const identityPlan=kleinIdentityPlan(q);
const sourceId=identityPlan ? identityPlan.sourceId : q.imageAId;
// Qwen Edit: Photo to change (imageAId) → image_1. Never Klein hero-ref.
const sourceId = q.engine === 'qwen21'
? (q.imageAId || '')
: (identityPlan ? identityPlan.sourceId : q.imageAId);
await resolveRequestSize(r.owner,identityPlan ? {...q,imageAId:sourceId} : q);
saveRecord(r);
const a=await load(sourceId,'source'),b=await load(q.imageBId,'compose'),mask=await load(q.maskId,'mask');
@@ -126,6 +315,100 @@ async function prepareGraph(r: any) {
r.graphId = 'studio2_minimax_native.json';
}
}
else if (q.engine === 'qwen21') {
if (!['generate', 'edit'].includes(q.mode))
throw new Error('Qwen 2.1 supports Generate and Edit.')
const turbo = !!q.turbo
if (turbo) {
q.lora = QWEN21_TURBO_LORA
q.sigmas = QWEN21_TURBO_SIGMAS
s.steps = QWEN21_TURBO_STEPS
s.cfg = 1
}
const negative = turbo ? '' : stylePrompt(q.imageStyles, true)
if (q.mode === 'edit') {
// Photo to change → image_1. Outfit/extra → image_2. Never T2I / EmptyLatent.
if (!q.imageAId || !a)
throw new Error('Add a photo and mark it Photo to change.')
// promptRaw stays typed; prompt/compiledPrompt = what TextEncode samples (tags injected silently).
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
const prompt = finalizeQwen21EditSample(String(q.compiledPrompt || ''), q)
q.compiledPrompt = prompt
q.prompt = prompt
q.task = 'edit'
graph = structuredClone(turbo ? qwen21EditTurboTemplate : qwen21EditTemplate)
graph['10'].inputs.image = a
graph['9'].inputs['images.image_1'] = ['10', 0]
if (b) {
graph['11'] = {
inputs: { image: b },
class_type: 'LoadImage',
_meta: { title: 'image_2 optional' }
}
graph['9'].inputs['images.image_2'] = ['11', 0]
}
graph['9'].inputs.prompt = prompt
graph['9'].inputs.negative_prompt = negative
// Official edit: sampler latent comes from TextEncode (sized from image_1).
graph['9'].inputs.resolution = 1024
if (turbo) {
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
graph['7'].inputs.strength = 1.0
graph['31'].inputs.noise_seed = s.seed
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
graph['36'].inputs.filename_prefix = prefix + '/image'
r.sampleLatent = 'qwen21 edit latent'
r.graphId = 'studio2_qwen21_edit_turbo.json'
} else {
graph['15'].inputs.seed = s.seed
graph['15'].inputs.steps = s.steps || 25
graph['15'].inputs.cfg = s.cfg ?? 1
graph['15'].inputs.sampler_name = 'euler'
graph['15'].inputs.scheduler = 'simple'
graph['21'].inputs.filename_prefix = prefix + '/image'
r.sampleLatent = 'qwen21 edit latent'
r.graphId = 'studio2_qwen21_edit.json'
}
r.sampleDenoise = null
r.heroReferenceAttached = false
} else {
// Bench aspect is law for Generate. PE wh_ratio is advisory only (never sizes the canvas).
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
q.prompt = String(q.compiledPrompt || '')
q.task = 'generate'
const size = resolveQwen21Size(s.aspect)
s.width = size.width
s.height = size.height
graph = structuredClone(turbo ? qwen21T2iTurboTemplate : qwen21Template)
graph['9'].inputs.prompt = q.compiledPrompt
graph['9'].inputs.negative_prompt = negative
graph['9'].inputs.resolution = 1024
graph['16'].inputs.width = size.width
graph['16'].inputs.height = size.height
if (turbo) {
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
graph['7'].inputs.strength = 1.0
graph['31'].inputs.noise_seed = s.seed
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
graph['36'].inputs.filename_prefix = prefix + '/image'
r.sampleLatent = 'qwen21 empty latent'
r.graphId = 'studio2_qwen21_t2i_turbo.json'
} else {
graph['15'].inputs.seed = s.seed
graph['15'].inputs.steps = s.steps || 25
graph['15'].inputs.cfg = s.cfg ?? 1
graph['15'].inputs.sampler_name = 'euler'
graph['15'].inputs.scheduler = 'simple'
graph['21'].inputs.filename_prefix = prefix + '/image'
r.sampleLatent = 'qwen21 empty latent'
r.graphId = 'studio2_qwen21_t2i.json'
}
r.sampleDenoise = null
r.heroReferenceAttached = false
}
await ensureComfyLoraNames('image')
applyUserLoraToGraph(graph, s.loraStack)
}
else {
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
@@ -169,58 +452,37 @@ async function run(r: any, job: Job) {
r.progress = null;
r.request.promptSections = prompts[r.index];
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
// Typed brief before PE / TextEncode — Copy raw / Load input raw need this even when Enhance is off.
r.request.promptRaw = String(r.request.compiledPrompt || '');
update(r, 'waking');
await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
if (job.status === 'cancelled')
throw new Error('Cancelled');
await runQwen21PromptEnhance(r, job);
if (job.status === 'cancelled')
throw new Error('Cancelled');
const graph = await prepareGraph(r);
if (job.status === 'cancelled')
throw new Error('Cancelled');
update(r, 'submitting');
const queued = await queuePrompt(graph, job.clientId);
emitJob(job, { type: 'status', message: 'Generating…' });
let queued: { prompt_id: string; number?: number }
try {
queued = await queuePrompt(graph, job.clientId);
} catch (error: any) {
const msg = String(error?.statusMessage || error?.message || error)
if (r.request?.turbo && /lora|ViggleTurbo|not in list|value_not_in_list/i.test(msg))
throw new Error(`Qwen Turbo LoRA missing on host (${QWEN21_TURBO_LORA}). Install the r128 file under models/loras and restart Comfy.`)
throw error
}
job.promptId = queued.prompt_id;
r.promptId = queued.prompt_id;
r.generateComfyPromptId = queued.prompt_id;
r.progress = null;
r.renderStartedAt = Date.now();
update(r, 'rendering');
}
let history: any, missingSince = 0;
for (;;) {
try {
history = await fetchHistory(r.promptId);
}
catch {
history = null;
}
const e = history?.[r.promptId];
if (job.status === 'cancelled')
throw new Error('Cancelled');
if (['error', 'interrupted'].includes(e?.status?.status_str))
throw new Error('Comfy generation failed or was interrupted; inspect the host error log.');
if (!e) {
try {
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) });
if (response.ok) {
const q = await response.json() as any;
const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === r.promptId);
if (present)
missingSince = 0;
else if (!missingSince)
missingSince = Date.now();
else if (Date.now() - missingSince > 60000)
throw new Error('PROMPT_MISSING');
}
}
catch (error: any) {
if (error.message === 'PROMPT_MISSING')
throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.');
}
}
if (e?.status?.completed || e?.status?.status_str === 'success')
break;
if (job.status === 'cancelled')
throw new Error('Cancelled');
await new Promise(resolve => setTimeout(resolve, 1500));
}
const history = await waitPromptHistory(r, job, r.promptId);
const messages = history[r.promptId]?.status?.messages || [];
const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp;
const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp;
@@ -255,7 +517,21 @@ async function run(r: any, job: Job) {
}
else if (!asset) {
const data = await downloadComfyImage(file), size = imageDimensions(data) || { width: s.width, height: s.height };
asset = await saveStill({ ownerKey: r.owner, folderId: q.folderId, filename: file.filename, data, ...size, role: 'output', prompt: q.compiledPrompt, familyId: r.familyId, chainIndex: r.index, settings: s });
// prompt = TextEncode string (after PE + stitch); promptRaw = typed brief.
const samplePrompt = String(q.prompt || q.compiledPrompt || '')
asset = await saveStill({
ownerKey: r.owner,
folderId: q.folderId,
filename: file.filename,
data,
...size,
role: 'output',
prompt: samplePrompt,
promptRaw: q.promptRaw != null ? String(q.promptRaw) : undefined,
familyId: r.familyId,
chainIndex: r.index,
settings: s,
});
}
if (!asset)
throw new Error('Library did not save the output');
@@ -263,6 +539,11 @@ async function run(r: any, job: Job) {
saveRecord(r);
r.savedAt = Date.now();
r.wallTime = (r.savedAt - r.startedAt) / 1000;
if (q.engine === 'qwen21' && ['generate', 'edit'].includes(q.mode)) {
q.task = q.mode
q.prompt = String(q.prompt || q.compiledPrompt || '')
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
}
const metadata = { ...structuredClone(q), id: r.id, sourceStillId:r.sourceStillId,heroReferenceAttached:r.heroReferenceAttached,sampleLatent:r.sampleLatent,sampleDenoise:r.sampleDenoise,kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host', handoffSource: r.handoffSource || null, fitted: video && q.startClipId ? !!r.handoffFitted : null };
await attachStudio2Metadata(r.owner, asset.id, metadata);
if (!r.outputs.some((o:any)=>o.id===asset.id)) r.outputs.push({id:asset.id,kind:video?'clip':'still',studio2:metadata});
+31 -10
View File
@@ -1,25 +1,46 @@
import { readFileSync } from 'node:fs'
import { openSync, readSync, closeSync } from 'node:fs'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { getClip, getStill, clipVideoPath, stillPath } from '../library'
import { imageDimensions } from '../resolution'
import { resolveSize } from '~/shared/studio2/input.mjs'
const exec = promisify(execFile)
function readImageHeader(path: string, maxBytes = 65536) {
const fd = openSync(path, 'r')
try {
const buf = Buffer.alloc(maxBytes)
const n = readSync(fd, buf, 0, maxBytes, 0)
return buf.subarray(0, n)
} finally {
closeSync(fd)
}
}
export async function resolveRequestSize(owner: string, request: any) {
const video=['video','extend'].includes(request.mode)
let dimensions:any
const video = ['video', 'extend'].includes(request.mode)
let dimensions: any
if (video && request.startClipId) {
getClip(owner,request.startClipId)
const result=await exec('ffprobe',['-v','error','-select_streams','v:0','-show_entries','stream=width,height','-of','json',clipVideoPath(owner,request.startClipId)],{windowsHide:true,timeout:15000})
dimensions=JSON.parse(result.stdout).streams?.[0]
const clip = getClip(owner, request.startClipId)
if (clip.width && clip.height) {
dimensions = { width: clip.width, height: clip.height }
} else {
const id=request.imageAId || request.identityStillId
const result = await exec('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'json', clipVideoPath(owner, request.startClipId)], { windowsHide: true, timeout: 15000 })
dimensions = JSON.parse(result.stdout).streams?.[0]
}
} else {
const id = request.imageAId || request.identityStillId
if (id) {
getStill(owner,id)
dimensions=imageDimensions(readFileSync(stillPath(owner,id)))
const still = getStill(owner, id)
if (still.width && still.height) {
dimensions = { width: still.width, height: still.height }
} else {
// Header only — never read the full archive PNG into memory on Generate click
dimensions = imageDimensions(readImageHeader(stillPath(owner, id)))
}
if (!dimensions) throw new Error('Cannot measure the source still.')
}
}
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions))
Object.assign(request.settings, resolveSize(request.settings.aspect, dimensions, undefined, request.engine))
}
+99 -9
View File
@@ -13,10 +13,12 @@ import { imageV2StackSpecials } from '~/utils/imageV2'
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
export type StudioJobKind = 'video' | 'edit' | 'music'
export type StudioJobKind = 'video' | 'edit' | 'music' | 'caption'
export interface StudioJobPayload {
studio2Id?: string
/** Full Studio 2 validateRequest snapshot for Load / Re-queue. */
studio2Request?: Record<string, any>
upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string }
prompt: string
promptMid?: string
@@ -40,6 +42,7 @@ export interface StudioJobPayload {
useIdentityRefs: boolean
stillId?: string
stillFilename?: string
captionStyle?: string
hideThumbnail: boolean
hideInput?: boolean
folderLocked?: boolean
@@ -103,6 +106,9 @@ export interface StudioJob {
resumeAutoRun?: boolean
lastError?: string
waitReason?: string
resultText?: string
/** Hidden from Queue UI; keeps resultText on the stored row. */
dismissed?: boolean
}
type StudioQueueStore = {
@@ -224,6 +230,7 @@ export function listStudioJobs(owner: string) {
export function studioJobKind(job: Pick<StudioJob, 'kind'> | { kind?: string }) {
if (job.kind === 'edit') return 'edit'
if (job.kind === 'music') return 'music'
if (job.kind === 'caption') return 'caption'
return 'video'
}
@@ -243,6 +250,8 @@ export function summarizeStudioJob(job: StudioJob) {
folderId: job.payload.folderId,
musicEngine: job.payload.musicEngine,
stillId: job.payload.stillId,
captionStyle: job.payload.captionStyle,
resultText: job.resultText,
workflow: job.payload.workflow,
imagePipeline: job.payload.imagePipeline || 'v1',
duration: job.payload.duration,
@@ -288,7 +297,7 @@ function failZombieLiveJob(job: Job, error: string) {
function sweepStaleLiveJobs() {
const now = Date.now()
for (const job of listJobs()) {
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
if (job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) continue
if (job.saving) continue
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
failZombieLiveJob(job, 'Job never started')
@@ -308,12 +317,12 @@ async function reapZombieLiveJobs() {
const { fetchHistory } = await import('~/server/utils/comfy')
for (const job of listJobs()) {
// Never interrupt download/stitch/library save — Comfy is idle then by design.
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
if (job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) continue
if (job.saving) continue
if (job.library?.chainContinuing) continue
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
if (jobIsLocallySubmitting(job)) continue
const zombieMs = (job.kind === 'edit' || job.kind === 'music') ? 45_000 : ZOMBIE_EMPTY_COMFY_MS
const zombieMs = (job.kind === 'edit' || job.kind === 'music' || job.kind === 'caption') ? 45_000 : ZOMBIE_EMPTY_COMFY_MS
if (jobAgeMs(job) < zombieMs) continue
if (!job.promptId) {
if (jobAgeMs(job) >= QUEUED_GRACE_MS) failZombieLiveJob(job, 'Job never started on ComfyUI')
@@ -353,7 +362,7 @@ async function reapZombieLiveJobs() {
}
function liveJobOwnsGpu(job: Job) {
if ((job.studio2 || job.yueGp || job.yue2 || job.upscale) && ['running', 'queued', 'uploading'].includes(job.status)) return true
if ((job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) && ['running', 'queued', 'uploading'].includes(job.status)) return true
if (job.library?.stopAfterCurrent) return false
if (job.library?.chainContinuing) return true
if (job.saving) return true
@@ -373,7 +382,7 @@ function liveJobOwnsGpu(job: Job) {
*/
function clearDeadGpuClaimsForForceStart() {
for (const live of listJobs()) {
if (live.studio2 || live.yueGp || live.yue2 || live.upscale) continue
if (live.studio2 || live.yueGp || live.yue2 || live.upscale || live.caption) continue
if (live.saving) continue
if (jobIsLocallySubmitting(live)) continue
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
@@ -467,8 +476,11 @@ export async function addStudioJob(params: {
kind?: StudioJobKind
}) {
const now = Date.now()
const kind = params.kind === 'edit' ? 'edit' : params.kind === 'music' ? 'music' : 'video'
const shotCount = kind === 'music'
const kind = params.kind === 'edit' ? 'edit'
: params.kind === 'music' ? 'music'
: params.kind === 'caption' ? 'caption'
: 'video'
const shotCount = kind === 'music' || kind === 'caption'
? 1
: kind === 'edit'
? 1 + (params.payload.passes?.length || 0)
@@ -525,6 +537,40 @@ function canCancelStudioStatus(status: StudioJobStatus) {
return status === 'waiting' || status === 'held' || status === 'running' || status === 'error'
}
function isFinishedStudioStatus(status: StudioJobStatus) {
return status === 'complete' || status === 'error' || status === 'cancelled'
}
/** Hide a finished studio-queue row from the Queue panel (keeps resultText). */
export async function dismissStudioJob(owner: string, id: string) {
return mutate(owner, (jobs) => {
const job = jobs.find(item => item.id === id)
if (!job) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
if (!isFinishedStudioStatus(job.status)) {
throw createError({ statusCode: 409, statusMessage: 'Only finished jobs can be dismissed' })
}
job.dismissed = true
job.updatedAt = Date.now()
return structuredClone(job)
})
}
/** Dismiss finished studio-queue rows (caption complete cards, errors, etc.). */
export async function dismissFinishedStudioJobs(owner: string, ids?: string[]) {
return mutate(owner, (jobs) => {
let cleared = 0
for (const job of jobs) {
if (ids?.length && !ids.includes(job.id)) continue
if (job.dismissed) continue
if (!isFinishedStudioStatus(job.status)) continue
job.dismissed = true
job.updatedAt = Date.now()
cleared += 1
}
return cleared
})
}
export async function cancelStudioJob(owner: string, id: string) {
const before = readJobs(owner).find(item => item.id === id)
if (!before) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
@@ -604,6 +650,10 @@ export async function clearStuckStudioWork(owner: string) {
const { cancelYue2Job } = await import('./yue2')
await cancelYue2Job(job)
}
if (job.caption) {
const { cancelCaptionJob } = await import('./caption')
await cancelCaptionJob(job)
}
job.status = 'cancelled'
job.error = 'Cleared by force reset'
if (job.library) {
@@ -676,6 +726,11 @@ async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
await cancelYue2Job(live)
return
}
if (live?.caption) {
const { cancelCaptionJob } = await import('./caption')
await cancelCaptionJob(live)
return
}
if (live) {
live.status = 'cancelled'
if (live.library) {
@@ -887,6 +942,10 @@ function pendingAlive(job: StudioJob) {
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yue2-pending', `${job.liveJobId}.json`)
if (existsSync(root)) return true
}
if (job.kind === 'caption') {
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'caption-pending', `${job.liveJobId}.json`)
if (existsSync(root)) return true
}
}
if (!job.shotQueueId) return false
return listPendingJobs().some(pending => (
@@ -1395,6 +1454,32 @@ async function startStudioExtendJob(item: StudioJob) {
}
}
async function startStudioCaptionJob(item: StudioJob) {
let live: import('~/server/utils/jobs').Job | undefined
try {
const { startCaptionJob } = await import('./caption')
const style = String(item.payload.captionStyle || 'descriptive')
const stillId = String(item.payload.stillId || '')
if (!stillId) throw new Error('Caption requires a still.')
live = await startCaptionJob({
ownerKey: item.ownerKey,
folderId: item.payload.folderId,
stillId,
captionStyle: style,
name: item.payload.name
})
await markStudioLive(item.ownerKey, item.id, live.id)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')) {
live.status = 'error'
live.error = message
}
await parkStudioOnStartFailure(item.ownerKey, item.id, message)
kickStudioQueue()
}
}
async function startStudioMusicJob(item: StudioJob) {
let live: import('~/server/utils/jobs').Job | undefined
try {
@@ -1437,6 +1522,10 @@ export async function startStudioJob(item: StudioJob) {
}
async function startStudioJobReserved(item: StudioJob) {
if (studioJobKind(item) === 'caption') {
await startStudioCaptionJob(item)
return
}
if (item.payload.studio2Id) {
try {
const { startStudio2Job } = await import('./studio2/runner')
@@ -1701,7 +1790,7 @@ export async function onLiveVideoSettled(job: Job) {
return
}
const remaining = remainingStudioShots(job)
const wakeFail = !job.studio2 && !job.yueGp && !job.yue2 && !job.upscale && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
const wakeFail = !job.studio2 && !job.yueGp && !job.yue2 && !job.upscale && !job.caption && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail
await mutateStore(owner, (store) => {
@@ -1775,6 +1864,7 @@ export async function onLiveVideoSettled(job: Job) {
: failed
? 'error'
: 'complete'
if (job.caption && job.resultText) row.resultText = job.resultText
clearStudioRowSlot(row, status, failed ? job.error : undefined)
syncPausedFlag(store)
})
+30
View File
@@ -0,0 +1,30 @@
export const CAPTION_STYLES = ['descriptive', 'klein_prompt', 'delta', 'tags']
export const DELTA_LOCKED_LOOK =
'short light-brown bob, pink tank, light blue shorts, open green Slytherin robe with red-gold trim, barefoot'
export function captionStylePrompt(style) {
switch (style) {
case 'klein_prompt':
return 'Write one self-contained image generation prompt for Flux Klein. Include subject, pose, clothing, setting, lighting, and camera. No preamble or quotes.'
case 'delta':
return `The character's locked baseline look is: ${DELTA_LOCKED_LOOK}. Describe ONLY what changed from that baseline in this image. Do not restate the locked look.`
case 'tags':
return 'List comma-separated descriptive tags for this image. Tags only, no sentences.'
case 'descriptive':
default:
return 'Describe this image in clear, detailed prose. Cover subject, appearance, clothing, pose, setting, and lighting.'
}
}
export function validateCaptionBody(raw) {
const fail = message => { throw Object.assign(new Error(message), { statusCode: 400 }) }
const body = raw || {}
const style = String(body.captionStyle || body.style || 'descriptive')
if (!CAPTION_STYLES.includes(style)) fail('Unknown caption style.')
const folderId = String(body.folderId || '')
if (!folderId) fail('Choose a folder.')
const stillId = String(body.stillId || body.sourcePath || '')
if (!stillId) fail('Choose a still to describe.')
return { folderId, stillId, captionStyle: style }
}
+33 -12
View File
@@ -1,6 +1,9 @@
import {normalizeStyles,stylePrompt} from './styles.mjs'
import { queueSeeds } from './seed.mjs'
import { sectionsFrom, compileSections } from './prompt.mjs'
import { QWEN21_ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
import { QWEN21_TURBO_CFG, QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from './qwen21-turbo.mjs'
import { applyPhotosToRequest } from './photos.mjs'
export { sectionsFrom } from './prompt.mjs'
export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right']
export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.'
@@ -13,7 +16,7 @@ export function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
export function compilePrompt(value, video = false, locks = {}) {
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
const hasImage=!!(locks.imageAId || locks.identityStillId)
const attached=hasImage && locks.engine !== 'krea'
const attached=hasImage && locks.engine !== 'krea' && locks.engine !== 'qwen21'
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
@@ -26,28 +29,39 @@ export function restoreInput(item) {
return { ...saved, lockFace:saved.lockFace !== false, lockOutfit:saved.lockOutfit !== false, hideInput:!!saved.hideInput, settings:structuredClone(saved.settings || {}), promptSections: sectionsFrom(saved.promptSections || { action: item.prompt || saved.compiledPrompt || '' }) }
}
export function validateRequest(raw, xaigen = false) {
raw = raw || {}
raw = applyPhotosToRequest(raw || {})
const fail = message => { throw Object.assign(new Error(message), {statusCode:400}) }
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
const video = ['video','extend'].includes(mode)
if (!(video ? ['minimax','ltx'] : ['flux','krea']).includes(engine)) fail('Engine does not support this task.')
if (!(video ? ['minimax','ltx'] : ['flux','krea','qwen21']).includes(engine)) fail('Engine does not support this task.')
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
if (engine === 'qwen21' && !['generate', 'edit'].includes(mode)) fail('Qwen 2.1 supports Generate and Edit.')
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
if (!raw.folderId) fail('Choose a folder.')
if ((video || ['edit','compose','iterate'].includes(mode)) && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) {
if (engine === 'qwen21') fail('Add a photo and mark it Photo to change.')
fail('Add a photo to change.')
}
if ((video || ['edit','compose','iterate'].includes(mode)) && engine !== 'qwen21' && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Add a Person to keep photo.')
if (mode === 'compose' && !raw.imageBId) fail('Compose needs a second photo.')
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) fail('Choose the source still.')
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip or Previous clip.')
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip.')
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
const promptSections = video ? allSections : {action:allSections.action}
if (!promptSections.action.trim()) fail('Enter an action prompt.')
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
const qwen = engine === 'qwen21'
const defaultSteps = video ? 8 : qwen ? 25 : 24
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
const aspect = QWEN21_ASPECT_IDS.has(raw.settings?.aspect) ? raw.settings.aspect : 'auto'
const qwenSize = qwen && mode === 'generate' ? resolveQwen21Size(aspect) : null
const defaultWidth = qwenSize ? qwenSize.width : qwen ? 1024 : 1344
const defaultHeight = qwenSize ? qwenSize.height : qwen ? 1024 : 768
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?defaultSteps:1,0,2147483647))) fail(`Invalid ${key}.`)
for (const key of ['width','height']) if (number(key,key==='width'?defaultWidth:defaultHeight,256,4096)%32) fail('Dimensions must be multiples of 32.')
const duration=number('duration',5,1,15)
if (video) {
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
@@ -55,10 +69,17 @@ export function validateRequest(raw, xaigen = false) {
}
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
const enhancePrompt = qwen && !video && !!raw.enhancePrompt
const turbo = qwen && !video && !!raw.turbo
const width = qwenSize ? qwenSize.width : number('width',defaultWidth,256,4096)
const height = qwenSize ? qwenSize.height : number('height',defaultHeight,256,4096)
const steps = turbo ? QWEN21_TURBO_STEPS : number('steps',defaultSteps,1,100)
const cfg = turbo ? QWEN21_TURBO_CFG : number('cfg',defaultCfg,0,20)
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, enhancePrompt, turbo, ...(turbo ? { lora: QWEN21_TURBO_LORA, sigmas: QWEN21_TURBO_SIGMAS } : {}), lockFace:qwen ? false : raw.lockFace !== false, lockOutfit:qwen ? false : raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:qwen ? '' : (raw.identityStillId || ''), imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
photos: Array.isArray(raw.photos) ? raw.photos : [],
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
settings: { aspect, fps:number('fps',24,1,60), width, height, steps, cfg, seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
}
+92 -6
View File
@@ -1,19 +1,39 @@
import {restoreLoras,loraUse} from './lora-memory.mjs'
import { restoreInput } from './contracts.mjs'
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) {
import { restoreInput, sectionsFrom } from './contracts.mjs'
import { normalizeStyles } from './styles.mjs'
import { QWEN21_ASPECT_IDS as ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
export { resolveQwen21Size, QWEN21_SIZE_TABLE } from './qwen21-size.mjs'
export const aspectOptions = [
{id:'auto',label:'Automatic',hint:'Match start or hero still'},
{id:'16:9',label:'16:9',hint:'Widescreen'},
{id:'9:16',label:'9:16',hint:'Tall'},
{id:'1:1',label:'1:1',hint:'Square'},
{id:'4:3',label:'4:3',hint:'Standard'},
{id:'3:4',label:'3:4',hint:'Portrait standard'},
{id:'3:2',label:'3:2',hint:'Photo'},
{id:'2:3',label:'2:3',hint:'Portrait photo'},
{id:'21:9',label:'21:9',hint:'Ultrawide'},
{id:'9:21',label:'9:21',hint:'Ultra-tall'}
]
export function resolveSize(aspect, source, fallback = {width:1344,height:768}, engine = '') {
if (engine === 'qwen21') return resolveQwen21Size(aspect, source)
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
const factor = Math.min(1344/Math.max(source.width,source.height),768/Math.min(source.width,source.height))
return {width:Math.max(256,Math.round(source.width*factor/32)*32),height:Math.max(256,Math.round(source.height*factor/32)*32)}
}
export { ASPECT_IDS }
export function bindClip(form, clip, xaigen = false, memory) {
if (!clip?.id) return form
const saved = clip.studio2 || clip.job || {}
const settings = saved.settings || clip.settings || {}
const next = {...form, startClipId:clip.id, mode:'extend', settings:{...form.settings}}
next.settings.aspect=['auto','16:9','9:16','1:1'].includes(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto')
next.settings.aspect=ASPECT_IDS.has(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto')
for (const key of ['duration','width','height','fps','loraStack','turbo','steps','cfg']) {
if (settings[key] == null) continue
try { next.settings[key] = typeof structuredClone === 'function' ? structuredClone(settings[key]) : JSON.parse(JSON.stringify(settings[key])) }
@@ -35,7 +55,7 @@ export function restoreAsset(form, item, xaigen = false, memory) {
const restored = restoreInput(item)
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
next.settings.seedMode='random'
next.engine=restored.engine==='krea'?'krea':'flux'
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
next.imageAId=item.id
next.startClipId=''
@@ -58,11 +78,77 @@ export function changeSource(form) {
export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
export function useAsSource(form, item) { return {...form,imageAId:item.id} }
/** Hydrate the live bench from a saved Studio 2 request (Load). Does not enqueue. */
export function hydrateStudio2Job(form, saved, { stillIds = new Set(), clipIds = new Set(), xaigen = false, memory } = {}) {
const src = structuredClone(saved?.studio2 || saved || {})
const missing = []
const keepStill = (id, label) => {
const value = id || ''
if (!value) return ''
if (stillIds.has(value)) return value
missing.push(`${label} missing from library`)
return ''
}
const keepClip = (id, label) => {
const value = id || ''
if (!value) return ''
if (clipIds.has(value)) return value
missing.push(`${label} missing from library`)
return ''
}
const modeRaw = src.mode === 'iterate' ? 'edit' : src.mode
const mode = ['generate','edit','compose','refine','video','extend','music'].includes(modeRaw) ? modeRaw : 'edit'
let engine = src.engine === 'qwen21' ? 'qwen21' : src.engine === 'krea' ? 'krea' : src.engine === 'ltx' ? 'ltx' : src.engine === 'minimax' ? 'minimax' : 'flux'
if (['video','extend'].includes(mode)) engine = xaigen && engine === 'ltx' ? 'ltx' : 'minimax'
else if (engine === 'minimax' || engine === 'ltx') engine = 'flux'
const settings = structuredClone(src.settings || {})
const aspect = ASPECT_IDS.has(settings.aspect) ? settings.aspect : 'auto'
const loraStack = Array.isArray(settings.loraStack) ? settings.loraStack.map(item => ({
name: item.name,
strengthModel: Number(item.strengthModel ?? item.strength ?? 1),
strengthClip: Number(item.strengthClip ?? item.strengthModel ?? item.strength ?? 1)
})) : []
const guides = (Array.isArray(src.guides) ? src.guides : []).map(g => {
const stillId = keepStill(g.stillId, 'guide still')
return stillId ? { stillId, frame: g.frame } : null
}).filter(Boolean)
const next = {
...form,
mode,
engine,
enhancePrompt: engine === 'qwen21' && !!src.enhancePrompt,
turbo: engine === 'qwen21' && !!src.turbo,
lockFace: src.lockFace !== false,
lockOutfit: src.lockOutfit !== false,
hideInput: !!src.hideInput,
folderId: src.folderId || form.folderId,
identityStillId: keepStill(src.identityStillId, 'hero'),
imageAId: keepStill(src.imageAId, 'start'),
imageBId: keepStill(src.imageBId, 'second still'),
maskId: keepStill(src.maskId, 'mask'),
startClipId: keepClip(src.startClipId, 'start clip'),
endStillId: keepStill(src.endStillId, 'end still'),
guides,
imageStyles: normalizeStyles(src.imageStyles || { positive: [], negative: [] }),
promptSections: sectionsFrom(src.promptSections || { action: src.compiledPrompt || src.prompt || '' }),
settings: {
...form.settings,
...settings,
aspect,
seed: settings.seed ?? form.settings?.seed ?? null,
seedMode: 'locked',
loraStack
}
}
return { form: restoreLoras(next, loraStack, xaigen, memory), missing: missing[0] || '', warnings: missing }
}
export function restoreAll(form,item,xaigen=false,memory) {
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
next.engine=restored.engine==='krea'?'krea':'flux'
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
next.imageAId=item.id
next.startClipId=''
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
+3 -1
View File
@@ -2,7 +2,9 @@ import {filterLoraStackForUse} from '../../utils/loras.ts'
export function loraUse(form) {
const video=['video','extend'].includes(form.mode)
return video ? (form.engine==='ltx'?'ltx':'minimax') : (form.engine==='krea'?'krea':'flux')
if (video) return form.engine==='ltx'?'ltx':'minimax'
if (form.engine==='qwen21') return 'qwen21'
return form.engine==='krea'?'krea':'flux'
}
// One controller per bench. Storage is optional (SSR/private browsing still works).
+368
View File
@@ -0,0 +1,368 @@
/**
* Inspector Photos: role cards (plain English). The runner maps roles → graph sockets.
* UI never teaches <imageN> / Hero / Edit source.
*/
export const PHOTO_ROLE = {
person: 'person',
change: 'change',
outfit: 'outfit',
look: 'look',
first: 'first',
extra: 'extra'
}
export const PHOTO_ROLE_LABEL = {
person: 'Person to keep',
change: 'Photo to change',
outfit: 'Outfit / object',
look: 'Look / style',
first: 'First frame',
extra: 'Extra photo'
}
/** Human phrase inserted by Mention in prompt (not a Comfy token). */
export function mentionPhrase(role, index = 0) {
const r = String(role || '')
if (r === PHOTO_ROLE.change) return 'this photo'
if (r === PHOTO_ROLE.outfit) return 'the outfit photo'
if (r === PHOTO_ROLE.person) return 'the face photo'
if (r === PHOTO_ROLE.look) return 'the look photo'
if (r === PHOTO_ROLE.first) return 'the first frame'
return `Photo ${Number(index) + 1}`
}
export function insertMention(text, phrase, selectionStart = 0, selectionEnd = 0) {
const value = String(text || '')
const chip = String(phrase || '')
if (!chip) return { text: value, selectionStart: 0, selectionEnd: 0 }
const existing = value.indexOf(chip)
if (existing >= 0) {
return { text: value, selectionStart: existing, selectionEnd: existing + chip.length }
}
const start = Math.max(0, Math.min(Number(selectionStart) || 0, value.length))
const end = Math.max(start, Math.min(Number(selectionEnd) || start, value.length))
const next = `${value.slice(0, start)}${chip}${value.slice(end)}`
return { text: next, selectionStart: start, selectionEnd: start + chip.length }
}
/**
* @returns {{
* show: boolean,
* max: number,
* roles: string[],
* defaultRole: string,
* emptyHint: string|null,
* handoffReadonly: boolean
* }}
*/
export function photosPlan({ mode, engine } = {}) {
const task = String(mode || 'generate')
const eng = String(engine || 'flux')
if (task === 'music') {
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
}
if (eng === 'qwen21') {
if (task === 'generate') {
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
}
if (task === 'edit') {
return {
show: true,
max: 3,
roles: [PHOTO_ROLE.change, PHOTO_ROLE.outfit, PHOTO_ROLE.person, PHOTO_ROLE.extra],
defaultRole: PHOTO_ROLE.change,
emptyHint: 'Add a photo, then say what it is.',
handoffReadonly: false
}
}
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
}
if (eng === 'krea') {
if (task === 'generate') {
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
}
if (task === 'refine') {
return {
show: true,
max: 1,
roles: [PHOTO_ROLE.change],
defaultRole: PHOTO_ROLE.change,
emptyHint: 'Add a photo, then say what it is.',
handoffReadonly: false
}
}
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
}
if (task === 'extend') {
return {
show: true,
max: 0,
roles: [],
defaultRole: '',
emptyHint: null,
handoffReadonly: true
}
}
if (task === 'video') {
if (eng === 'minimax') {
return {
show: true,
max: 2,
roles: [PHOTO_ROLE.first, PHOTO_ROLE.person, PHOTO_ROLE.look],
defaultRole: PHOTO_ROLE.first,
emptyHint: 'Add a photo, then say what it is.',
handoffReadonly: false
}
}
// LTX: first frame only — existing start binding
return {
show: true,
max: 1,
roles: [PHOTO_ROLE.first],
defaultRole: PHOTO_ROLE.first,
emptyHint: 'Add a photo, then say what it is.',
handoffReadonly: false
}
}
// Klein (flux)
if (task === 'generate') {
return {
show: true,
max: 1,
roles: [PHOTO_ROLE.person],
defaultRole: PHOTO_ROLE.person,
emptyHint: 'Add a photo, then say what it is.',
handoffReadonly: false
}
}
if (task === 'edit' || task === 'iterate') {
return {
show: true,
max: 2,
roles: [PHOTO_ROLE.change, PHOTO_ROLE.person],
defaultRole: PHOTO_ROLE.change,
emptyHint: 'Add a photo, then say what it is.',
handoffReadonly: false
}
}
if (task === 'compose') {
return {
show: true,
max: 3,
roles: [PHOTO_ROLE.change, PHOTO_ROLE.person, PHOTO_ROLE.extra],
defaultRole: PHOTO_ROLE.change,
emptyHint: 'Add a photo, then say what it is.',
handoffReadonly: false
}
}
if (task === 'refine') {
return {
show: true,
max: 1,
roles: [PHOTO_ROLE.change],
defaultRole: PHOTO_ROLE.change,
emptyHint: 'Add a photo, then say what it is.',
handoffReadonly: false
}
}
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
}
export function normalizePhotos(raw, plan) {
const list = Array.isArray(raw) ? raw : []
const roles = new Set(plan?.roles || [])
const max = Math.max(0, Number(plan?.max) || 0)
const out = []
for (const row of list) {
if (out.length >= max) break
const stillId = String(row?.stillId || row?.id || '').trim()
if (!stillId) continue
let role = String(row?.role || plan?.defaultRole || '').trim()
if (!roles.has(role)) role = plan?.defaultRole || [...roles][0] || PHOTO_ROLE.extra
out.push({ stillId, role })
}
return out
}
/** First card wins when two share a role. Returns duplicate role label or null. */
export function duplicatePhotoRole(photos) {
const seen = new Set()
for (const row of photos || []) {
const role = String(row?.role || '')
if (!role) continue
if (seen.has(role)) return PHOTO_ROLE_LABEL[role] || role
seen.add(role)
}
return null
}
/**
* Map Photos cards → legacy request fields the graphs already consume.
* Qwen Edit: change→imageAId, outfit|extra→imageBId, person→imageBId if empty.
* Klein: person→identityStillId, change→imageAId.
*/
export function bindPhotosToRequest(photos, { mode, engine } = {}) {
const task = String(mode || 'generate')
const eng = String(engine || 'flux')
const list = Array.isArray(photos) ? photos.filter(p => p?.stillId) : []
const find = (...roles) => list.find(p => roles.includes(p.role))
let imageAId = ''
let imageBId = ''
let identityStillId = ''
if (eng === 'qwen21' && task === 'edit') {
let change = find(PHOTO_ROLE.change)
if (!change && list[0]) change = { ...list[0], role: PHOTO_ROLE.change }
const outfit = find(PHOTO_ROLE.outfit, PHOTO_ROLE.extra)
const person = find(PHOTO_ROLE.person)
imageAId = change?.stillId || ''
imageBId = outfit?.stillId || ''
if (!imageBId && person) imageBId = person.stillId
// Person with outfit already in image_2: leave as imageBId only (2-input graph).
identityStillId = ''
return { imageAId, imageBId, identityStillId, photos: list }
}
if (eng === 'krea' && task === 'refine') {
imageAId = find(PHOTO_ROLE.change)?.stillId || list[0]?.stillId || ''
return { imageAId, imageBId: '', identityStillId: '', photos: list }
}
if (task === 'video' || (task === 'extend' && eng === 'minimax')) {
imageAId = find(PHOTO_ROLE.first)?.stillId || list[0]?.stillId || ''
identityStillId = find(PHOTO_ROLE.person, PHOTO_ROLE.look)?.stillId || ''
return { imageAId, imageBId: '', identityStillId, photos: list }
}
if (eng === 'ltx' && task === 'video') {
imageAId = find(PHOTO_ROLE.first)?.stillId || list[0]?.stillId || ''
return { imageAId, imageBId: '', identityStillId: '', photos: list }
}
// Klein / flux
if (task === 'generate') {
identityStillId = find(PHOTO_ROLE.person)?.stillId || list[0]?.stillId || ''
return { imageAId: '', imageBId: '', identityStillId, photos: list }
}
if (task === 'edit' || task === 'iterate' || task === 'compose' || task === 'refine') {
imageAId = find(PHOTO_ROLE.change)?.stillId || list[0]?.stillId || ''
identityStillId = find(PHOTO_ROLE.person)?.stillId || ''
imageBId = find(PHOTO_ROLE.extra)?.stillId || ''
return { imageAId, imageBId, identityStillId, photos: list }
}
return { imageAId, imageBId, identityStillId, photos: list }
}
/** Hydrate Photos cards from legacy hero/source fields when loading a job. */
export function photosFromRequest(request = {}) {
if (Array.isArray(request.photos) && request.photos.length) {
return request.photos.map(p => ({
stillId: String(p.stillId || p.id || ''),
role: String(p.role || PHOTO_ROLE.change)
})).filter(p => p.stillId)
}
const mode = String(request.mode || 'generate')
const engine = String(request.engine || 'flux')
const out = []
if (engine === 'qwen21' && mode === 'edit') {
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.change })
if (request.imageBId) out.push({ stillId: request.imageBId, role: PHOTO_ROLE.outfit })
return out
}
if (mode === 'generate' && engine === 'flux' && request.identityStillId) {
out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
return out
}
if (['edit', 'iterate', 'compose', 'refine'].includes(mode)) {
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.change })
if (request.identityStillId && request.identityStillId !== request.imageAId) {
out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
}
if (request.imageBId) out.push({ stillId: request.imageBId, role: PHOTO_ROLE.extra })
return out
}
if (mode === 'video') {
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.first })
if (request.identityStillId) out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
}
return out
}
/**
* Apply Photos → request fields. Call before validate / queue.
* If Edit+Qwen has cards but no Photo to change, promote the first card.
* When `photos` is an explicit array (even empty), it is source of truth —
* do not resurrect cards from leftover imageAId / identityStillId.
*/
export function applyPhotosToRequest(raw = {}) {
const next = { ...raw }
const plan = photosPlan({ mode: next.mode, engine: next.engine })
if (!plan.show || plan.handoffReadonly) {
if (!Array.isArray(next.photos)) next.photos = []
return next
}
const photosProvided = Array.isArray(raw.photos)
let photos = normalizePhotos(next.photos, plan)
if (!photosProvided && !photos.length && (next.imageAId || next.identityStillId || next.imageBId)) {
photos = normalizePhotos(photosFromRequest(next), plan)
}
// Promote first card to Photo to change when Edit needs it.
if (
plan.roles.includes(PHOTO_ROLE.change)
&& photos.length
&& !photos.some(p => p.role === PHOTO_ROLE.change)
) {
photos = [{ ...photos[0], role: PHOTO_ROLE.change }, ...photos.slice(1)]
}
const bound = bindPhotosToRequest(photos, { mode: next.mode, engine: next.engine })
next.photos = bound.photos
next.imageAId = bound.imageAId
next.imageBId = bound.imageBId
next.identityStillId = bound.identityStillId
return next
}
/**
* Qwen Edit: map mention phrases → <imageN> for TextEncode.
* Photo to change → <image1>; outfit/extra/person-on-2 → <image2>.
*/
export function injectQwenEditMentionTags(prompt, photos = []) {
let text = String(prompt || '')
const list = Array.isArray(photos) ? photos.filter(p => p?.stillId) : []
let change = list.find(p => p.role === PHOTO_ROLE.change)
if (!change && list[0]) change = { ...list[0], role: PHOTO_ROLE.change }
const outfit = list.find(p => p.role === PHOTO_ROLE.outfit || p.role === PHOTO_ROLE.extra)
const person = list.find(p => p.role === PHOTO_ROLE.person)
const image2 = outfit || (!outfit ? person : null)
const pairs = []
if (change) {
const idx = list.indexOf(list.find(p => p.stillId === change.stillId && p.role === change.role) || change)
pairs.push({ phrase: mentionPhrase(PHOTO_ROLE.change, Math.max(0, idx)), tag: '<image1>' })
// Also map generic Photo 1 if they used chip style
pairs.push({ phrase: 'Photo 1', tag: '<image1>' })
}
if (image2) {
const idx = list.findIndex(p => p.stillId === image2.stillId)
pairs.push({ phrase: mentionPhrase(image2.role, Math.max(0, idx)), tag: '<image2>' })
pairs.push({ phrase: 'Photo 2', tag: '<image2>' })
}
// Longer phrases first so "the outfit photo" wins over fragments.
pairs.sort((a, b) => b.phrase.length - a.phrase.length)
for (const { phrase, tag } of pairs) {
if (!phrase || text.includes(tag) && !text.includes(phrase)) continue
if (text.includes(phrase)) text = text.split(phrase).join(tag)
}
return text
}
+5 -1
View File
@@ -28,7 +28,11 @@ export function sectionsFrom(value = {}) {
export function compileSections(value) {
const s = sectionsFrom(value)
const camera = s.cameraBeats.length ? ['One continuous take.', ...s.cameraBeats.map((b,i) => {
const move = b.moves[0] === 'hold' ? 'hold the camera locked on a tripod' : b.moves.map(m=>m.replaceAll('-',' ')).join(' and ') + (b.moves.length > 1 ? ' simultaneously' : '')
const hold = b.moves[0] === 'hold' || b.moves.includes('hold')
if (hold) {
return `${i ? 'Then' : 'First'}, hold the camera locked on a tripod.`
}
const move = b.moves.map(m=>m.replaceAll('-',' ')).join(' and ') + (b.moves.length > 1 ? ' simultaneously' : '')
const timing = b.durationSec == null ? 'through the end of the remaining clip' : i === 0 ? `for the first ${b.durationSec} seconds` : `for ${b.durationSec} seconds`
return `${i ? 'Then' : 'First'}, ${move}, ${{subtle:'small',moderate:'medium',large:'large'}[b.amplitude]} amplitude, ${b.speed} speed, ${timing}.`
}), 'No cuts. No new setup.'].join(' ') : s.camera
+30 -1
View File
@@ -1,5 +1,6 @@
const terminal = new Set(['complete','failed','cancelled'])
const running = new Set(['waking','submitting','rendering','saving'])
// Include enhancing so PE is the same queue row as generate (not a vanished "Queue 0").
const running = new Set(['waking','enhancing','submitting','rendering','saving'])
export function queueJobs(jobs) {
return jobs.filter(j=>running.has(j.state)||['waiting','queued','held'].includes(j.state)).sort((a,b)=>Number(running.has(b.state))-Number(running.has(a.state)) || (a.queuedAt||0)-(b.queuedAt||0))
}
@@ -14,3 +15,31 @@ export function elapsedSeconds(job, now) {
const end=terminal.has(job.state)?job.finishedAt || job.savedAt || job.updatedAt || start:now
return Number.isFinite(start)&&Number.isFinite(end)?Math.max(0,Math.floor((end-start)/1000)):0
}
/** Stage label shown on the queue row (header pill uses status.mjs). */
export function jobStageLabel(state) {
const labels={
waiting:'Queued',queued:'Queued',held:'Held',
waking:'Waking up',enhancing:'Enhancing prompt…',
submitting:'Generating…',rendering:'Generating…',saving:'Saving',
complete:'Ready',failed:'Failed',cancelled:'Cancelled',
}
return labels[state] || state
}
/** Compact progress for the queue row. Enhancing prefers indeterminate over a fake 0%. */
export function jobProgressView(job) {
const state=job?.state
if(!['enhancing','submitting','rendering','waking','saving'].includes(state)) return null
const p=job?.progress
if(state==='enhancing'){
if(p && Number(p.maxStep)>0 && Number(p.step)>=0)
return {indeterminate:false,percent:Math.max(0,Math.min(100,Number(p.percent)||Math.round(Number(p.step)/Number(p.maxStep)*100)))}
return {indeterminate:true,percent:null}
}
if(state==='rendering' && p && Number(p.maxStep)>0)
return {indeterminate:false,percent:Math.max(0,Math.min(100,Number(p.percent)||Math.round(Number(p.step)/Number(p.maxStep)*100)))}
// waking / submitting / saving / rendering without steps: no fake bar
if(state==='submitting' || state==='waking') return {indeterminate:true,percent:null}
return null
}
+65
View File
@@ -0,0 +1,65 @@
/** Qwen Edit / PE-I2I prompt helpers. Start still = <image1>; Hero is never a slot. */
export const QWEN21_EDIT_KEEP =
'Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.'
export const QWEN21_EDIT_KEEP_CHANGE =
`${QWEN21_EDIT_KEEP}\nApply only this change:`
const EDIT_VERB =
/\b(?:change|replace|put|swap|keep|remove|add|edit|make|turn|convert|transform|modify|update|set|apply|wear|place|swap|switch)\b/i
const DESCRIBE_LEAD =
/^(?:the image is\b|this is a photograph of\b|a vertical photorealistic\b|a photorealistic\b|this (?:image|photo|picture) (?:shows|depicts|features)\b|the (?:photo|picture) (?:shows|depicts|features)\b)/i
/** Fixed identity stanza + the user's raw edit instruction. Always includes <image1>. */
export function buildQwen21EditKeepStanza(rawInstruction) {
const raw = String(rawInstruction || '').trim()
if (!raw) return QWEN21_EDIT_KEEP.trim()
return `${QWEN21_EDIT_KEEP_CHANGE} ${raw}`.trim()
}
/** Enhance off / refusal: ensure the sample prompt is the keep stanza, never PE-only. */
export function ensureQwen21EditPrompt(prompt, options = {}) {
const text = String(prompt || '').trim()
if (!text) return text
let next = /Keep the identity, face, body, pose, and likeness of the person in <image1>/i.test(text)
? text
: buildQwen21EditKeepStanza(text)
if (options.hasImage2 && !/<image2>/i.test(next)) {
next = `${next}\nUse the garment or object in <image2>.`.trim()
}
return next
}
/** PE rewrite looks like a T2I observer caption, not an edit directive. */
export function isQwen21PeEditDescribe(rewritten) {
const text = String(rewritten || '').trim()
if (!text) return true
if (DESCRIBE_LEAD.test(text)) return true
if (!EDIT_VERB.test(text)) return true
return false
}
function retagNaturalImageRefs(text) {
return String(text || '')
.replace(/\bthe woman in the (?:input )?(?:photo|picture|image)\b/gi, 'the person in <image1>')
.replace(/\bthe (?:person|man|subject) in the (?:input )?(?:photo|picture|image)\b/gi, 'the person in <image1>')
.replace(/\bin the (?:input )?(?:photo|picture|image)\b/gi, 'in <image1>')
.replace(/\bthe (?:input )?(?:photo|picture|image)\b/gi, '<image1>')
}
/**
* After PE-I2I: never let the PE chunk be the entire prompt.
* Always lead with the keep/<image1> stanza + raw instruction; append PE only when it is an edit directive.
*/
export function stitchQwen21PeEditPrompt(rawInstruction, rewritten) {
const stanza = buildQwen21EditKeepStanza(rawInstruction)
let pe = retagNaturalImageRefs(String(rewritten || '').trim()).trim()
if (!pe) return { prompt: stanza, skippedAsDescribe: false }
if (isQwen21PeEditDescribe(pe)) {
return { prompt: stanza, skippedAsDescribe: true }
}
// No <image1> in the PE chunk is fine — stanza already has the tag; keep PE as extra detail.
return { prompt: `${stanza}\n\n${pe}`.trim(), skippedAsDescribe: false }
}
+43
View File
@@ -0,0 +1,43 @@
/** Qwen PE refusal / empty-rewrite detection — fail open to promptRaw. */
const REFUSAL_RE =
/i cannot|i can't|i['’]m not able|i am not able|cannot assist|can't assist|won['’]t create|will not create|against my|not appropriate|\bsafety\b|content policy|抱歉|无法|不能协助|不符合/i
const STOP = new Set([
'the', 'and', 'for', 'are', 'was', 'were', 'been', 'being', 'this', 'that', 'with', 'from',
'into', 'your', 'have', 'will', 'would', 'could', 'should', 'about', 'there', 'their', 'them',
'then', 'than', 'when', 'what', 'which', 'while', 'where', 'make', 'made', 'like', 'just',
'only', 'also', 'over', 'under', 'after', 'before', 'between', 'through', 'image', 'photo',
'picture', 'please', 'create', 'generate', 'show', 'want', 'need', 'scene', 'style', 'prompt',
'edit', 'change', 'keep', 'using', 'into', 'onto', 'her', 'him', 'his', 'she', 'they', 'them',
])
/** Concrete tokens from the user brief (latin words ≥3 or CJK runs). */
export function concreteTokens(text) {
const raw = String(text || '').toLowerCase().match(/[a-z][a-z0-9-]{2,}|[\u4e00-\u9fff]{2,}/g) || []
return [...new Set(raw)].filter(w => !STOP.has(w))
}
function isGutted(userPrompt, rewritten) {
const user = String(userPrompt || '').trim()
const out = String(rewritten || '').trim()
if (!user || !out) return !out
if (out.length >= user.length) return false
const nouns = concreteTokens(user)
if (!nouns.length) return false
const lower = out.toLowerCase()
return !nouns.some(n => lower.includes(n))
}
/**
* @returns {{ refused: boolean, reason: string }}
*/
export function qwen21PeRefusal(userPrompt, { positive_prompt, thinking, parse_ok } = {}) {
const positive = String(positive_prompt || '').trim()
const think = String(thinking || '').trim()
if (!parse_ok && !positive) return { refused: true, reason: 'empty rewrite (parse_ok false)' }
if (!positive) return { refused: true, reason: 'empty rewrite' }
if (REFUSAL_RE.test(positive) || REFUSAL_RE.test(think)) return { refused: true, reason: 'model refused' }
if (isGutted(userPrompt, positive)) return { refused: true, reason: 'gutted rewrite' }
return { refused: false, reason: '' }
}
+58
View File
@@ -0,0 +1,58 @@
/** Qwen-safe ~1 MP grid (÷32). Do not use native 2K bins on 16 GB. */
export const QWEN21_SIZE_TABLE = {
'1:1': [1024, 1024],
'16:9': [1536, 864],
'9:16': [864, 1536],
'4:3': [1152, 864],
'3:4': [864, 1152],
'3:2': [1248, 832],
'2:3': [832, 1248],
'21:9': [1536, 672],
'9:21': [672, 1536]
}
export const QWEN21_ASPECT_IDS = new Set(['auto', ...Object.keys(QWEN21_SIZE_TABLE)])
function parseAspectRatio(aspect) {
const m = String(aspect || '').trim().match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/)
if (!m) return null
const w = Number(m[1]), h = Number(m[2])
if (!(w > 0 && h > 0)) return null
return w / h
}
function nearestQwen21Aspect(aspect) {
const target = parseAspectRatio(aspect)
if (target == null) return null
let best = '1:1', bestDiff = Infinity
for (const [key, [w, h]] of Object.entries(QWEN21_SIZE_TABLE)) {
const diff = Math.abs(w / h - target)
if (diff < bestDiff) { bestDiff = diff; best = key }
}
return best
}
export function resolveQwen21Size(aspect, source) {
const key = String(aspect || 'auto').trim()
if (QWEN21_SIZE_TABLE[key]) {
const [width, height] = QWEN21_SIZE_TABLE[key]
return { width, height }
}
if (key === 'auto') {
if (source?.width && source?.height) {
const nearest = nearestQwen21Aspect(`${source.width}:${source.height}`)
if (nearest) {
const [width, height] = QWEN21_SIZE_TABLE[nearest]
return { width, height }
}
}
return { width: 1024, height: 1024 }
}
const nearest = nearestQwen21Aspect(key)
if (nearest) {
const [width, height] = QWEN21_SIZE_TABLE[nearest]
return { width, height }
}
console.warn(`[qwen21] unknown aspect "${key}"; using 1024×1024`)
return { width: 1024, height: 1024 }
}
+5
View File
@@ -0,0 +1,5 @@
/** Viggle DMD turbo LoRA on the existing Qwen 2.1 GGUF (not a separate engine). */
export const QWEN21_TURBO_LORA = 'Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors'
export const QWEN21_TURBO_SIGMAS = '1.0, 0.9375, 0.875, 0.75, 0.5, 0.25'
export const QWEN21_TURBO_STEPS = 6
export const QWEN21_TURBO_CFG = 1
+6 -3
View File
@@ -1,4 +1,4 @@
export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
export function gpuStatus(jobs, health, upscales=[], musicJobs=[], captionJobs=[]) {
if (!health) return {label:'Status unavailable',tone:'unknown',detail:'Checking GPU connection…'}
const upscale=upscales.find(j=>j.status==='running')
if(upscale)return {label:'Upscaling',tone:'busy',detail:upscale.name || 'Video upscale'}
@@ -7,9 +7,12 @@ export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
const stage=music.live?.message || music.live?.stage || music.waitReason || 'YuE2'
return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)}
}
const caption=captionJobs.find(j=>['waiting','running','held'].includes(j.status) || ['queued','running','uploading'].includes(j.live?.status))
if(caption)return {label:caption.status==='waiting'?'Queued':'Describing',tone:'busy',detail:caption.payload?.captionStyle || caption.live?.message || 'Caption'}
if(health.caption?.busy)return {label:'Describing',tone:'busy',detail:'Caption model is using the GPU'}
if(health.yue2?.busy)return {label:'Generating',tone:'busy',detail:'YuE2 is using the GPU'}
const job=jobs.find(j=>['waking','submitting','rendering','saving'].includes(j.state))
if(job)return {label:{waking:'Waking up',submitting:'Submitting',rendering:'Generating',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
const job=jobs.find(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state))
if(job)return {label:{waking:'Waking up',enhancing:'Enhancing prompt…',submitting:'Submitting',rendering:'Generating…',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
if(health.comfy?.busy || health.imageComfy?.busy)return {label:'Busy',tone:'busy',detail:'GPU is working on another job'}
if(health.comfy?.ok || health.imageComfy?.ok)return {label:'Ready',tone:'ready',detail:'No job running'}
if(health.comfy?.asleep)return {label:'Sleeping',tone:'sleeping',detail:'Wakes when a job starts'}
+15 -1
View File
@@ -1,7 +1,21 @@
export const styleGroups={Render:['Photorealistic','Pixar','3D Disney','Comic','Watercolor','Oil painting'],Quality:['Realistic skin','Skin pores','Fine detail','Natural texture'],Lighting:['Golden hour','Soft studio','Window light','Overcast','Cinematic lighting']}
export const defaultNegatives=['blurry','deformed','low quality','text','watermark']
export function normalizeStyles(value={}) { const known=[...Object.values(styleGroups).flat(),...defaultNegatives];const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))];return {positive,negative:[...new Set((value.negative||[]).filter(s=>known.includes(s)&&!positive.includes(s)))]} }
export function normalizeStyles(value={}) {
const known=[...Object.values(styleGroups).flat(),...defaultNegatives]
const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))]
// Freeform negatives are allowed (sticky custom chips); positives stay on the known style list.
const negative=[...new Set((value.negative||[]).map(s=>String(s||'').trim()).filter(s=>s&&!positive.includes(s)))]
return {positive,negative}
}
export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next }
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
/** Single blank line (mobile / video / edit batchText). */
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
/**
* Desktop image Batch toggle: split only on a double blank line (two empty lines → \n\n\n+).
* A single newline or a single blank line stays inside the same prompt.
*/
export function parseBatchDoubleBlank(value){
return String(value||'').split(/\r?\n(?:[ \t]*\r?\n){2,}/).map(s=>s.trim()).filter(Boolean)
}
+5 -2
View File
@@ -6,8 +6,11 @@ export function folderAssets(library, jobs, folderId) {
export function newestOutput(jobs, seen, folderId) {
return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null
}
export function reconcileSelection(selected, assets) { return assets.find(a=>a.id===selected?.id) || assets.at(-1) || null }
export function activeJobs(jobs) { return jobs.filter(j=>['waking','submitting','rendering','saving'].includes(j.state)) }
export function reconcileSelection(selected, assets) {
if (!selected?.id) return assets.at(-1) || null
return assets.find(a => a.id === selected.id) || selected
}
export function activeJobs(jobs) { return jobs.filter(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state)) }
export function previewHidden(asset, revealed, xaigen) {
if (!xaigen || !asset?.id) return false
return !revealed?.has?.(asset.id)
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import { createCaptionHost, validateCaptionHostRequest } from '../scripts/caption-host.mjs'
import { CAPTION_STYLES, captionStylePrompt, DELTA_LOCKED_LOOK, validateCaptionBody } from '../shared/caption.mjs'
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { EventEmitter } from 'node:events'
assert.deepEqual(CAPTION_STYLES, ['descriptive', 'klein_prompt', 'delta', 'tags'])
assert.match(captionStylePrompt('delta'), new RegExp(DELTA_LOCKED_LOOK.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
assert.equal(validateCaptionBody({ folderId: 'f1', stillId: 's1', captionStyle: 'tags' }).captionStyle, 'tags')
assert.throws(() => validateCaptionBody({ folderId: 'f1' }), /still/)
assert.throws(() => validateCaptionHostRequest({ id: 'short', style: 'descriptive' }), /Invalid/)
const root = mkdtempSync(join(tmpdir(), 'caption-test-'))
const models = join(root, 'models')
mkdirSync(models)
writeFileSync(join(models, 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'), 'x')
writeFileSync(join(models, 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'), 'x')
writeFileSync(join(root, 'llama-server.exe'), 'x')
let prepared = 0
const host = createCaptionHost({
modelsDir: models,
llamaServer: join(root, 'llama-server.exe'),
dataDir: join(root, 'jobs'),
leaseValid: () => true,
prepare: async () => { prepared += 1 },
serverWaitMs: 800,
spawnProcess: () => {
const child = new EventEmitter()
child.pid = 4242
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = () => child.emit('close', 0)
setTimeout(() => child.emit('close', 0), 50)
return child
},
fetchImpl: async () => ({ ok: false, status: 503, text: async () => 'down' })
})
assert.equal(host.configured(), true)
assert.equal(host.busy(), false)
const id = 'caption-test-abcdefghijkl'
mkdirSync(join(root, 'jobs', id), { recursive: true })
writeFileSync(join(root, 'jobs', id, 'input.png'), Buffer.alloc(64, 1))
await assert.rejects(() => host.start({ id, style: 'descriptive' }, 'lease'), /llama-server|Caption/)
assert.equal(prepared, 1)
assert.ok(existsSync(join(root, 'jobs', id, 'status.json')))
console.log('caption tests ok')
+288
View File
@@ -0,0 +1,288 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { pathToFileURL } from 'node:url'
import ts from 'typescript'
const src = readFileSync(new URL('../utils/promptSequences.ts', import.meta.url), 'utf8')
const js = ts.transpileModule(src, {
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext
}
}).outputText
const tmp = join(tmpdir(), `prompt-sequences-${process.pid}.mjs`)
writeFileSync(tmp, js)
const {
assembleSceneText,
assembleSequenceText,
formatCharacterLine,
emptyScene,
emptyCharacter,
emptyOutfit,
emptySet,
emptyFraming,
cloneSceneForNext,
normalizePromptSequence,
normalizeSectionOrder,
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
ensureSceneStatesForCharacters
} = await import(pathToFileURL(tmp).href)
unlinkSync(tmp)
test('formatCharacterLine covers name/description and outfit', () => {
assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'Ada: tall')
assert.equal(
formatCharacterLine({ name: 'Ada', description: 'tall' }, { name: 'Work', description: 'blue jacket' }),
'Ada: tall. Outfit: blue jacket'
)
})
test('selecting a framing copies only that framing text; labels never appear', () => {
const wide = { ...emptyFraming('wide'), id: 'f-wide', text: 'open courtyard at dawn' }
const medium = { ...emptyFraming('medium'), id: 'f-med', text: 'courtyard from the arch' }
const narrow = { ...emptyFraming('narrow'), id: 'f-nar', text: 'door latch detail' }
const set = {
...emptySet(),
id: 's1',
name: 'Courtyard',
framings: [wide, medium, narrow]
}
const base = {
style: 'style',
settings: 'settings',
characterStates: [],
prompt: 'prompt'
}
const mediumText = assembleSceneText({
...base,
setStates: [{ setId: 's1', on: true, framingId: 'f-med' }]
}, [], [set])
assert.equal(mediumText, 'style\n\nsettings\n\ncourtyard from the arch\n\nprompt')
assert.ok(!mediumText.includes('open courtyard'))
assert.ok(!mediumText.includes('door latch'))
assert.ok(!mediumText.includes('Courtyard'))
assert.ok(!mediumText.includes('wide'))
assert.ok(!mediumText.includes('medium'))
assert.ok(!mediumText.includes('narrow'))
const narrowText = assembleSceneText({
...base,
setStates: [{ setId: 's1', on: true, framingId: 'f-nar' }]
}, [], [set])
assert.equal(narrowText, 'style\n\nsettings\n\ndoor latch detail\n\nprompt')
assert.ok(!narrowText.includes('open courtyard'))
assert.ok(!narrowText.includes('from the arch'))
const noneText = assembleSceneText({
...base,
setStates: [{ setId: 's1', on: true, framingId: null }]
}, [], [set])
assert.equal(noneText, 'style\n\nsettings\n\nprompt')
})
test('off set is omitted; sequence join is double blank', () => {
const framing = { ...emptyFraming('wide'), id: 'f1', text: 'set text' }
const set = { ...emptySet(), id: 's1', name: 'Place', framings: [framing] }
const scene1 = {
style: 'a',
settings: '',
setStates: [{ setId: 's1', on: true, framingId: 'f1' }],
characterStates: [],
prompt: 'p1'
}
const scene2 = {
style: 'b',
settings: '',
setStates: [{ setId: 's1', on: false, framingId: 'f1' }],
characterStates: [],
prompt: 'p2'
}
assert.equal(assembleSceneText(scene2, [], [set]), 'b\n\np2')
const full = assembleSequenceText({ sets: [set], scenes: [scene1, scene2] })
assert.equal(full, 'a\n\nset text\n\np1\n\n\nb\n\np2')
assert.ok(!full.includes('Place'))
assert.ok(!full.includes('wide'))
})
test('legacy beat-only set still opens as one wide framing', () => {
const legacy = normalizePromptSequence({
id: 'seq1',
name: 'Old beats',
sets: [{
id: 's1',
name: 'Hall',
description: 'stone walls',
beats: [
{ id: 'b1', name: 'Beat1', text: 'first beat', framings: ['wide'] },
{ id: 'b2', name: 'Beat2', text: 'second beat', framings: ['medium'] }
]
}],
scenes: [{
id: 'sc1',
title: 'Scene 1',
style: 'oil',
settings: '',
shotSize: 'wide',
setStates: [{ setId: 's1', on: true }],
prompt: 'walk',
characters: []
}],
characters: [{ id: 'x', name: 'Ada', description: 'tall', outfits: [] }],
createdAt: 1,
updatedAt: 1
})
assert.equal(legacy.sets.length, 1)
assert.equal(legacy.sets[0].framings.length, 1)
assert.equal(legacy.sets[0].framings[0].label, 'wide')
assert.ok(legacy.sets[0].framings[0].text.includes('stone walls'))
assert.ok(legacy.sets[0].framings[0].text.includes('first beat'))
assert.ok(legacy.sets[0].framings[0].text.includes('second beat'))
assert.equal(legacy.scenes[0].setStates[0].framingId, legacy.sets[0].framings[0].id)
const text = assembleSequenceText(legacy)
assert.ok(text.includes('stone walls'))
assert.ok(text.includes('first beat'))
assert.ok(!text.includes('Hall'))
assert.ok(!text.includes('Beat1'))
})
test('legacy sequence without sets still assembles', () => {
const legacy = normalizePromptSequence({
id: 'seq1',
name: 'Old',
scenes: [
{
id: 'sc1',
title: 'Scene 1',
style: 'oil',
settings: '',
prompt: 'walk',
characters: [{ id: 'x', name: 'Ada', description: 'tall' }]
}
],
createdAt: 1,
updatedAt: 1
})
assert.deepEqual(legacy.sets, [])
assert.equal(assembleSequenceText(legacy), 'oil\n\nAda: tall\n\nwalk')
})
test('cloneSceneForNext copies setStates including framingId', () => {
const sets = [emptySet()]
const first = emptyScene(1, [], sets)
first.setStates[0].on = false
first.setStates[0].framingId = sets[0].framings[0].id
const next = cloneSceneForNext(first, 2)
assert.equal(next.setStates[0].on, false)
assert.equal(next.setStates[0].framingId, sets[0].framings[0].id)
assert.equal(next.setStates[0].setId, sets[0].id)
})
test('character outfit and off still work with sets', () => {
const ada = {
...emptyCharacter(),
id: 'c1',
name: 'Ada',
description: 'tall',
outfits: [{ ...emptyOutfit(), id: 'o1', description: 'red dress' }]
}
const text = assembleSceneText({
style: '',
settings: '',
setStates: [],
characterStates: [{ characterId: 'c1', on: true, outfitId: 'o1' }],
prompt: 'hi'
}, [ada], [])
assert.equal(text, 'Ada: tall. Outfit: red dress\n\nhi')
})
test('ensureSceneStatesForCharacters turns new character on only for current scene', () => {
const a = emptyCharacter()
const scenes = [emptyScene(1, [a]), emptyScene(2, [a])]
const b = emptyCharacter()
ensureSceneStatesForCharacters(scenes, [a, b], { newCharacterId: b.id, turnOnSceneIndex: 1 })
assert.equal(scenes[0].characterStates.find(s => s.characterId === b.id).on, false)
assert.equal(scenes[1].characterStates.find(s => s.characterId === b.id).on, true)
})
test('sectionOrder settings then prompt then characters; empty style and set omitted', () => {
const ada = {
...emptyCharacter(),
id: 'c1',
name: 'Ada',
description: 'tall',
outfits: []
}
const set = {
...emptySet(),
id: 's1',
name: 'Courtyard',
framings: [{ ...emptyFraming('wide'), id: 'f1', text: 'open yard' }]
}
const scene = {
style: '',
settings: 'soft light',
setStates: [{ setId: 's1', on: true, framingId: null }],
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
prompt: 'looks up'
}
const order = ['settings', 'prompt', 'characters']
const text = assembleSceneText(scene, [ada], [set], order)
assert.equal(text, 'soft light\n\nlooks up\n\nAda: tall')
assert.ok(!text.includes('open yard'))
assert.ok(!text.includes('Courtyard'))
assert.ok(!text.includes('wide'))
})
test('legacy sequence without sectionOrder keeps default order and \\n\\n\\n join', () => {
const legacy = normalizePromptSequence({
id: 'seq1',
name: 'Old order',
characters: [{ id: 'c1', name: 'Ada', description: 'tall', outfits: [] }],
sets: [{
id: 's1',
name: 'Hall',
framings: [{ id: 'f1', label: 'wide', text: 'stone hall' }]
}],
scenes: [
{
id: 'sc1',
title: 'One',
style: 'oil',
settings: 'day',
setStates: [{ setId: 's1', on: true, framingId: 'f1' }],
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
prompt: 'walk'
},
{
id: 'sc2',
title: 'Two',
style: 'ink',
settings: '',
setStates: [{ setId: 's1', on: false, framingId: 'f1' }],
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
prompt: 'stop'
}
],
createdAt: 1,
updatedAt: 1
})
assert.deepEqual(legacy.sectionOrder, [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
assert.deepEqual(normalizeSectionOrder(undefined), [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
assert.deepEqual(normalizeSectionOrder(['prompt', 'prompt', 'bogus', 'settings']), [
'prompt',
'settings',
'style',
'set',
'characters'
])
const full = assembleSequenceText(legacy)
assert.equal(
full,
'oil\n\nday\n\nstone hall\n\nAda: tall\n\nwalk\n\n\nink\n\nAda: tall\n\nstop'
)
assert.ok(!full.includes('Hall'))
assert.ok(!full.includes('wide'))
})
+157 -3
View File
@@ -3,17 +3,21 @@ import assert from 'node:assert/strict'
import {readFileSync} from 'node:fs'
import ts from 'typescript'
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
import {useAsHero,useAsSource,restoreAll} from '../shared/studio2/input.mjs'
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs'
import {toggleStyle,parseBatch,parseBatchDoubleBlank,stylePrompt} from '../shared/studio2/styles.mjs'
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
import {ensureQwen21EditPrompt,stitchQwen21PeEditPrompt,QWEN21_EDIT_KEEP_CHANGE} from '../shared/studio2/qwen21-edit.mjs'
import {injectQwenEditMentionTags,applyPhotosToRequest} from '../shared/studio2/photos.mjs'
import {qwen21PeRefusal} from '../shared/studio2/qwen21-pe.mjs'
test('deleted library files never return from job outputs; selection falls back or clears',()=>{
const deleted={id:'gone',folderId:'f'},keep={id:'keep',folderId:'f',createdAt:2};const jobs=[{request:{folderId:'f'},outputs:[deleted]}]
let assets=folderAssets({stills:[keep],clips:[]},jobs,'f');assert.deepEqual(assets.map(a=>a.id),['keep']);assert.equal(reconcileSelection(deleted,assets).id,'keep');assert.equal(reconcileSelection(deleted,[]),null)
})
test('bench includes only active execution phases and clear finished retains internal cleanup records',()=>{
assert.deepEqual(activeJobs(['waiting','waking','submitting','rendering','saving','complete','cancelled','failed'].map(state=>({state}))).map(j=>j.state),['waking','submitting','rendering','saving'])
assert.deepEqual(activeJobs(['waiting','waking','enhancing','submitting','rendering','saving','complete','cancelled','failed'].map(state=>({state}))).map(j=>j.state),['waking','enhancing','submitting','rendering','saving'])
const rows=[{id:'done',state:'complete',request:{folderId:'f'},cleanupPending:[{}]},{id:'active',state:'rendering',request:{folderId:'f'}}],saved=[]
const src=readFileSync(new URL('../server/api/studio-2/clear-finished.post.ts',import.meta.url),'utf8').replace(/^import .*;\r?\n/gm,'').replace('export default ','const handler=')
const js=ts.transpileModule(src,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
@@ -28,6 +32,8 @@ test('hidden xAIGen canvas and family render no image or video elements',async()
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
assert.match(src,/aria-label="Current family outputs"/)
assert.match(src,/preview hidden/)
assert.match(src,/Reveal all/)
assert.match(src,/pickerRevealAll/)
assert.doesNotMatch(src,/Hide input stills/)
assert.doesNotMatch(src,/Hide output preview/)
})
@@ -73,4 +79,152 @@ test('image chips compile positive and negative independently; video chips do no
assert.match(q.compiledPrompt,/Photorealistic/);assert.doesNotMatch(q.compiledPrompt,/Comic|pores|tripod|hold|VIDEO ONLY/i);assert.deepEqual(q.imageStyles,styles)
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
assert.deepEqual(
parseBatchDoubleBlank('Prompt 1 starts here\nBal blaglas\nsldkjf lkaj\n\nsldkfjs lkdfj\nsdlfkjsdkljf\n\n\nPrompt 2 starts here flaskjflaskdj'),
['Prompt 1 starts here\nBal blaglas\nsldkjf lkaj\n\nsldkfjs lkdfj\nsdlfkjsdkljf','Prompt 2 starts here flaskjflaskdj']
)
assert.deepEqual(parseBatchDoubleBlank('one\n\ntwo'),['one\n\ntwo'])
assert.deepEqual(parseBatchDoubleBlank('one\n\n\ntwo\n\n\n\nthree\n'),['one','two','three'])
})
test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})
assert.equal(q.engine,'qwen21');assert.equal(q.settings.steps,25);assert.equal(q.settings.cfg,1)
assert.equal(q.settings.width,1024);assert.equal(q.settings.height,1024)
assert.equal(q.lockFace,false);assert.equal(q.lockOutfit,false);assert.deepEqual(q.settings.loraStack,[])
assert.equal(q.enhancePrompt,false)
assert.equal(q.turbo,false)
const wide=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'wide'},settings:{aspect:'16:9'}})
assert.equal(wide.settings.width,1536);assert.equal(wide.settings.height,864)
const tall=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'tall'},settings:{aspect:'9:16'}})
assert.equal(tall.settings.width,864);assert.equal(tall.settings.height,1536)
const turbo=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',turbo:true,promptSections:{action:'fast'},settings:{aspect:'1:1',steps:25,cfg:4}})
assert.equal(turbo.turbo,true);assert.equal(turbo.settings.steps,6);assert.equal(turbo.settings.cfg,1)
assert.match(String(turbo.lora),/viggle-turbo-v0\.2\.1-6step-lora-r128/)
assert.match(String(turbo.sigmas),/0\.9375/)
const enhanced=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',enhancePrompt:true,promptSections:{action:'corgi'},settings:{aspect:'1:1'}})
assert.equal(enhanced.enhancePrompt,true)
assert.equal(validateRequest({mode:'generate',engine:'flux',folderId:'f',enhancePrompt:true,turbo:true,promptSections:{action:'x'},settings:{aspect:'1:1'}}).turbo,false)
const edit=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'change the sky'},settings:{aspect:'auto'}})
assert.equal(edit.mode,'edit');assert.equal(edit.settings.steps,25);assert.equal(edit.settings.cfg,1)
assert.equal(edit.lockFace,false);assert.deepEqual(edit.settings.loraStack,[])
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',promptSections:{action:'x'}}),/Photo to change/)
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',identityStillId:'hero',promptSections:{action:'x'}}),/Photo to change/)
const qwenPhotos=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',photos:[{stillId:'person',role:'change'},{stillId:'dress',role:'outfit'}],promptSections:{action:'Put her in that dress.'},settings:{aspect:'auto'}})
assert.equal(qwenPhotos.imageAId,'person')
assert.equal(qwenPhotos.imageBId,'dress')
assert.equal(qwenPhotos.identityStillId,'')
assert.match(qwenPhotos.compiledPrompt,/Put her in that dress/)
assert.doesNotMatch(String(qwenPhotos.promptSections.action),/<image1>/)
const afterRemove=applyPhotosToRequest({mode:'edit',engine:'qwen21',photos:[],imageAId:'stale-person',imageBId:'stale-dress'})
assert.equal(afterRemove.imageAId,'')
assert.equal(afterRemove.imageBId,'')
assert.deepEqual(afterRemove.photos,[])
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',photos:[],imageAId:'stale',promptSections:{action:'x'}}),/Photo to change/)
assert.throws(()=>validateRequest({mode:'compose',engine:'qwen21',folderId:'f',imageAId:'s',imageBId:'b',promptSections:{action:'x'}}),/Generate and Edit/)
assert.throws(()=>validateRequest({mode:'iterate',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/Generate and Edit/)
assert.equal(ensureQwen21EditPrompt('put her in a red leather jacket'),`${QWEN21_EDIT_KEEP_CHANGE} put her in a red leather jacket`)
assert.match(ensureQwen21EditPrompt('put her in a red leather jacket'),/<image1>/)
assert.match(ensureQwen21EditPrompt('put her in that dress',{hasImage2:true}),/<image2>/)
assert.equal(ensureQwen21EditPrompt('Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.\nApply only this change: x'),'Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.\nApply only this change: x')
assert.match(injectQwenEditMentionTags('Put her in the outfit photo',[{stillId:'a',role:'change'},{stillId:'b',role:'outfit'}]),/<image2>/)
assert.match(injectQwenEditMentionTags('Change this photo',[{stillId:'a',role:'change'}]),/<image1>/)
const stitchedKeep=stitchQwen21PeEditPrompt('put her in a red leather jacket','Change the outfit on the person in <image1> to a red leather jacket')
assert.match(stitchedKeep.prompt,/Keep the identity/)
assert.match(stitchedKeep.prompt,/<image1>/)
assert.match(stitchedKeep.prompt,/red leather jacket/)
assert.equal(stitchedKeep.skippedAsDescribe,false)
assert.match(stitchedKeep.prompt,/Change the outfit/)
const stitchedDescribe=stitchQwen21PeEditPrompt('put her in a red leather jacket','The image is a photograph of a woman standing outdoors in soft light…')
assert.equal(stitchedDescribe.skippedAsDescribe,true)
assert.match(stitchedDescribe.prompt,/Keep the identity/)
assert.match(stitchedDescribe.prompt,/put her in a red leather jacket/)
assert.doesNotMatch(stitchedDescribe.prompt,/The image is a photograph/)
const stitchedNoTag=stitchQwen21PeEditPrompt('put her in a red leather jacket','Change the jacket on the woman in the photo to red leather')
assert.equal(stitchedNoTag.skippedAsDescribe,false)
assert.match(stitchedNoTag.prompt,/<image1>/)
assert.doesNotMatch(stitchedNoTag.prompt,/\bthe photo\b/i)
assert.match(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/Single-image edits still use <image1>/)
assert.match(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/EDIT INSTRUCTION/)
assert.doesNotMatch(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/do NOT use tags/)
assert.doesNotMatch(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_t2i.txt',import.meta.url),'utf8'),/EDIT INSTRUCTION/)
assert.doesNotMatch(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_t2i.txt',import.meta.url),'utf8'),/Single-image edits still use <image1>/)
assert.equal(qwen21PeRefusal('corgi in the rain',{positive_prompt:'A photorealistic photograph of a corgi standing in rain…',parse_ok:true}).refused,false)
assert.equal(qwen21PeRefusal('nude adult portrait',{positive_prompt:"I cannot assist with that request.",thinking:'',parse_ok:true}).refused,true)
assert.equal(qwen21PeRefusal('put her in a red leather jacket',{positive_prompt:'',parse_ok:false}).refused,true)
assert.equal(qwen21PeRefusal('corgi wearing sunglasses on a beach',{positive_prompt:'A soft scene.',parse_ok:true}).refused,true)
assert.match(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_t2i.txt',import.meta.url),'utf8'),/Adult and explicit briefs/)
assert.match(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/Adult and explicit briefs/)
assert.match(readFileSync(new URL('../scripts/setup-qwen21.ps1',import.meta.url),'utf8'),/qwen21-pe-prompts/)
assert.match(readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8'),/qwen21PeRefusal/)
assert.match(readFileSync(new URL('../components/studio2/Details.vue',import.meta.url),'utf8'),/Enhance skipped \(model refused\)/)
})
test('hydrateStudio2Job restores LoRA strengths, locked seed, and clears missing stills',()=>{
const form={mode:'generate',engine:'flux',identityStillId:'',imageAId:'',promptSections:{action:''},imageStyles:{positive:[],negative:[]},settings:{aspect:'auto',seed:null,seedMode:'random',loraStack:[]},guides:[]}
const saved={mode:'edit',engine:'flux',identityStillId:'hero1',imageAId:'gone',lockFace:true,lockOutfit:false,promptSections:{action:'pokemon trainer'},imageStyles:{positive:[],negative:['blurry','custom bad']},settings:{aspect:'16:9',seed:42,steps:24,cfg:4,loraStack:[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}]}}
const {form:next,missing}=hydrateStudio2Job(form,saved,{stillIds:new Set(['hero1']),clipIds:new Set()})
assert.equal(next.mode,'edit');assert.equal(next.identityStillId,'hero1');assert.equal(next.imageAId,'')
assert.equal(next.settings.seed,42);assert.equal(next.settings.seedMode,'locked');assert.equal(next.settings.aspect,'16:9')
assert.deepEqual(next.settings.loraStack,[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}])
assert.deepEqual(next.imageStyles.negative,['blurry','custom bad'])
assert.match(missing,/start missing/)
})
test('custom negative chips survive normalizeStyles and bench exposes Load actions',()=>{
let styles={positive:[],negative:['blurry','my watermark']}
styles=toggleStyle(styles,'deformed',true)
assert.deepEqual(styles.negative,['blurry','my watermark','deformed'])
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
const slot=readFileSync(new URL('../components/studio2/AssetSlot.vue',import.meta.url),'utf8')
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Photos/)
assert.match(src,/photosPlanState\.emptyHint/)
assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/)
assert.match(src,/Enhance prompt/);assert.match(src,/enhancePrompt/)
assert.match(slot,/Mention in prompt/);assert.match(slot,/This photo is/);assert.match(slot,/Replace/)
assert.match(slot,/Choose a photo/)
assert.match(src,/uploadPickerFile/);assert.match(src,/setPhotoStill/);assert.match(src,/discardEmptyPhotoDraft/)
assert.match(src,/Upload from disk/)
assert.doesNotMatch(src,/Hero still/)
assert.doesNotMatch(src,/Edit source/)
assert.doesNotMatch(src,/Identity \/ reference/)
assert.doesNotMatch(src,/Qwen Edit uses Start still/)
assert.doesNotMatch(src,/Hero is Klein-only/)
assert.doesNotMatch(slot,/<image1>/)
assert.match(src,/\bTurbo\b/);assert.match(src,/form\.turbo/)
const photosSrc=readFileSync(new URL('../shared/studio2/photos.mjs',import.meta.url),'utf8')
assert.match(photosSrc,/Photo to change/)
assert.match(photosSrc,/mentionPhrase/)
assert.match(photosSrc,/Add a photo, then say what it is/)
assert.match(readFileSync(new URL('../components/studio2/Details.vue',import.meta.url),'utf8'),/\bTyped\b/)
assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/\bTyped\b/)
assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/promptRaw/)
const peT2i=readFileSync(new URL('../server/assets/studio2_qwen21_pe_t2i.json',import.meta.url),'utf8')
const peEdit=readFileSync(new URL('../server/assets/studio2_qwen21_pe_edit.json',import.meta.url),'utf8')
assert.match(peT2i,/QwenImage21_T2IPromptRewrite/);assert.match(peT2i,/pe_t2i\.int8_convrot/)
assert.match(peEdit,/QwenImage21_EditPromptRewrite/);assert.match(peEdit,/pe_i2i\.int8_convrot/)
assert.doesNotMatch(peT2i,/UnetLoader|KSampler|VAELoader/)
assert.doesNotMatch(peEdit,/UnetLoader|KSampler|VAELoader/)
assert.match(peEdit,/"image_1":\s*\["10",\s*0\]/)
const editGraph=readFileSync(new URL('../server/assets/studio2_qwen21_edit.json',import.meta.url),'utf8')
assert.match(editGraph,/"images\.image_1":\s*\["10",\s*0\]/)
assert.match(editGraph,/"latent_image":\s*\["9",\s*2\]/)
assert.doesNotMatch(editGraph,/EmptyLatentImage/)
const runner=readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8')
assert.match(runner,/ensureQwen21EditPrompt/)
assert.match(runner,/stitchQwen21PeEditPrompt/)
assert.match(runner,/injectQwenEditMentionTags/)
assert.match(runner,/applyPhotosToRequest/)
assert.match(runner,/skippedAsDescribe/)
assert.match(runner,/promptRaw/)
assert.match(runner,/images\.image_1/)
assert.match(runner,/comfyPromptId/)
assert.match(runner,/generateComfyPromptId/)
assert.doesNotMatch(runner,/Keep the subject in <image1>/)
const t2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i.json',import.meta.url),'utf8')
assert.match(t2i,/EmptyLatentImage/);assert.match(t2i,/"latent_image":\s*\["16",\s*0\]/)
const turboT2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i_turbo.json',import.meta.url),'utf8')
const turboEdit=readFileSync(new URL('../server/assets/studio2_qwen21_edit_turbo.json',import.meta.url),'utf8')
assert.match(turboT2i,/ViggleTurboLora/);assert.match(turboT2i,/ViggleTurboSigmas/);assert.match(turboT2i,/SamplerCustomAdvanced/)
assert.match(turboT2i,/0\.9375/);assert.match(turboEdit,/ViggleTurboLora/);assert.match(turboEdit,/"latent":\s*\["9",\s*2\]/)
assert.deepEqual(resolveQwen21Size('16:9'),{width:1536,height:864})
assert.deepEqual(resolveQwen21Size('1:1'),{width:1024,height:1024})
assert.deepEqual(resolveQwen21Size('9:16'),{width:864,height:1536})
})
+16 -1
View File
@@ -1,11 +1,26 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {queueJobs,jobView,elapsedSeconds} from '../shared/studio2/queue-view.mjs'
import {queueJobs,jobView,elapsedSeconds,jobStageLabel,jobProgressView} from '../shared/studio2/queue-view.mjs'
test('queue shows every waiting job in order alongside the running job',()=>{
const jobs=[{id:'late',state:'waiting',queuedAt:30},{id:'done',state:'complete',queuedAt:1},{id:'early',state:'waiting',queuedAt:10},{id:'active',state:'rendering',queuedAt:20},{id:'held',state:'held',queuedAt:40}]
assert.deepEqual(queueJobs(jobs).map(j=>j.id),['active','early','late','held'])
assert.equal(queueJobs(Array.from({length:15},(_,i)=>({id:i,state:'waiting',queuedAt:i}))).length,15)
})
test('enhancing stays on the active queue with the same job id as generate',()=>{
const pe={id:'one',state:'enhancing',queuedAt:10}
const behind={id:'two',state:'waiting',queuedAt:20}
assert.deepEqual(queueJobs([behind,pe]).map(j=>j.id),['one','two'])
assert.equal(jobStageLabel('enhancing'),'Enhancing prompt…')
assert.equal(jobStageLabel('rendering'),'Generating…')
assert.equal(jobStageLabel('submitting'),'Generating…')
assert.deepEqual(jobProgressView(pe),{indeterminate:true,percent:null})
assert.deepEqual(jobProgressView({state:'enhancing',progress:{step:2,maxStep:4,percent:50}}),{indeterminate:false,percent:50})
assert.deepEqual(jobProgressView({state:'rendering',progress:{step:3,maxStep:6,percent:50}}),{indeterminate:false,percent:50})
assert.equal(jobProgressView({state:'waiting'}),null)
// PE → generate is a stage change on the same row, not a vanish frame
const same=[{id:'one',state:'submitting',queuedAt:10},{id:'two',state:'waiting',queuedAt:20}]
assert.deepEqual(queueJobs(same).map(j=>j.id),['one','two'])
})
test('authoritative completion ends a stale rendering row and freezes its timer',()=>{
const record={state:'rendering',queuedAt:1000,startedAt:2000,updatedAt:5000}
for(const status of ['complete','error','cancelled']){
+34 -5
View File
@@ -41,14 +41,34 @@ export type StillGenerationSettings = {
export type SettingsRow = { label: string; value: string }
function pushPromptRows(rows: SettingsRow[], item: { prompt?: string; promptPre?: string; promptPost?: string }) {
const parts = restorePromptParts(item.prompt || '', {
function pushPromptRows(rows: SettingsRow[], item: {
prompt?: string
promptRaw?: string
promptPre?: string
promptPost?: string
studio2?: { prompt?: string; promptRaw?: string; compiledPrompt?: string }
}) {
// Sampled TextEncode string (after PE + stitch). Never a captioner dump of the PNG.
const sampled = String(
item.prompt
|| item.studio2?.prompt
|| item.studio2?.compiledPrompt
|| ''
).trim()
const typed = String(item.promptRaw || item.studio2?.promptRaw || '').trim()
const parts = restorePromptParts(sampled || item.prompt || '', {
pre: item.promptPre,
prompt: item.prompt,
prompt: sampled || item.prompt,
post: item.promptPost
})
if (parts.pre.trim()) rows.push({ label: 'Pre-text', value: parts.pre.trim() })
if (parts.prompt.trim()) rows.push({ label: 'Prompt', value: parts.prompt.trim() })
const used = parts.prompt.trim()
if (typed && typed !== used) {
rows.push({ label: 'Typed', value: typed })
if (used) rows.push({ label: 'Used', value: used })
} else if (used) {
rows.push({ label: 'Prompt', value: used })
}
if (parts.post.trim()) rows.push({ label: 'Post-text', value: parts.post.trim() })
}
@@ -172,7 +192,16 @@ export function stillModeLabel(still: { filename?: string; settings?: StillGener
return ''
}
export function stillSettingsRows(still: { prompt?: string; promptPre?: string; promptPost?: string; width?: number; height?: number; settings?: StillGenerationSettings | null }) {
export function stillSettingsRows(still: {
prompt?: string
promptRaw?: string
promptPre?: string
promptPost?: string
studio2?: { prompt?: string; promptRaw?: string; compiledPrompt?: string }
width?: number
height?: number
settings?: StillGenerationSettings | null
}) {
const settings = still.settings
const rows: SettingsRow[] = []
pushPromptRows(rows, still)
+14 -3
View File
@@ -283,7 +283,7 @@ export function kleinLoraOnKreaMessage(name: string) {
return imageLoraEngineMismatchMessage(name, 'krea')
}
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea'
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea' | 'qwen21'
export function isSystemGraphLora(name: string) {
const id = loraIdentityKey(name)
@@ -301,19 +301,30 @@ export function inferLoraUse(name: string): LoraUse[] | null {
if (/ltx/.test(value)) uses.push('ltx')
if (/klein|flux/.test(value)) uses.push('flux')
if (/krea/.test(value)) uses.push('krea')
if (/qwen/.test(value)) uses.push('qwen21')
return uses.length ? uses : null
}
/** Qwen 2.1 user adapters: basename stem starts with qwen or qwen2 (not klein). */
export function isQwenImageLora(name: string) {
const stem = loraIdentityKey(name).replace(/\.(safetensors|sft|ckpt|pt|bin)$/i, '')
return /^(qwen2|qwen)/i.test(stem)
}
export function loraMatchesUse(name: string, use: LoraUse) {
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
if (isSystemGraphLora(name)) return false
if (use === 'qwen21') return isQwenImageLora(name)
return !!inferLoraUse(name)?.includes(use)
}
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
if (use === 'ltx' && !xaigen) return []
return filterLoraStackForStudio(stack, xaigen).filter(item => {
if (isSystemGraphLora(item.name)) return false
if (use === 'qwen21') return isQwenImageLora(item.name)
const uses = inferLoraUse(item.name)
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
return !uses || uses.includes(use)
})
}
+575
View File
@@ -0,0 +1,575 @@
export type PromptSequenceOutfit = {
id: string
name: string
description: string
}
export type PromptSequenceCharacter = {
id: string
name: string
description: string
outfits: PromptSequenceOutfit[]
}
export type PromptSequenceCharacterState = {
characterId: string
on: boolean
/** Active outfit id for this scene, or null/'' for none. */
outfitId: string | null
}
/** Suggested labels only — not a hard enum. */
export const PROMPT_SEQUENCE_FRAMING_SUGGESTIONS = ['wide', 'medium', 'narrow'] as const
export type PromptSequenceFraming = {
id: string
label: string
text: string
}
export type PromptSequenceSet = {
id: string
name: string
framings: PromptSequenceFraming[]
}
export type PromptSequenceSetState = {
setId: string
on: boolean
/** Chosen framing for this set in this scene (like outfitId). Empty = copy nothing. */
framingId: string | null
}
export type PromptSequenceScene = {
id: string
title: string
style: string
settings: string
setStates: PromptSequenceSetState[]
characterStates: PromptSequenceCharacterState[]
prompt: string
}
/** Scene prompt sections. Default copy order. */
export const PROMPT_SEQUENCE_SECTION_KEYS = ['style', 'settings', 'set', 'characters', 'prompt'] as const
export type PromptSequenceSectionKey = (typeof PROMPT_SEQUENCE_SECTION_KEYS)[number]
export const DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER: PromptSequenceSectionKey[] = [
...PROMPT_SEQUENCE_SECTION_KEYS
]
export type PromptSequence = {
id: string
name: string
/** Order of scene sections when assembling / in the editor. */
sectionOrder: PromptSequenceSectionKey[]
characters: PromptSequenceCharacter[]
sets: PromptSequenceSet[]
scenes: PromptSequenceScene[]
createdAt: number
updatedAt: number
}
/** Valid unique keys in given order; missing keys append in default order. */
export function normalizeSectionOrder(raw: unknown): PromptSequenceSectionKey[] {
const allowed = new Set<string>(PROMPT_SEQUENCE_SECTION_KEYS)
const seen = new Set<PromptSequenceSectionKey>()
const ordered: PromptSequenceSectionKey[] = []
if (Array.isArray(raw)) {
for (const item of raw) {
const key = String(item || '').trim()
if (!allowed.has(key) || seen.has(key as PromptSequenceSectionKey)) continue
const typed = key as PromptSequenceSectionKey
seen.add(typed)
ordered.push(typed)
}
}
for (const key of DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER) {
if (!seen.has(key)) ordered.push(key)
}
return ordered
}
export const PROMPT_SEQUENCE_NAME_MAX = 80
export const PROMPT_SEQUENCE_FIELD_MAX = 50_000
export const PROMPT_SEQUENCE_TITLE_MAX = 60
export function newId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `ps-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
export function emptyOutfit(): PromptSequenceOutfit {
return { id: newId(), name: '', description: '' }
}
export function emptyCharacter(): PromptSequenceCharacter {
return { id: newId(), name: '', description: '', outfits: [] }
}
export function emptyFraming(label = ''): PromptSequenceFraming {
return { id: newId(), label, text: '' }
}
export function emptySet(): PromptSequenceSet {
return { id: newId(), name: '', framings: [emptyFraming()] }
}
export function defaultCharacterState(characterId: string, on = true): PromptSequenceCharacterState {
return { characterId, on, outfitId: null }
}
export function defaultSetState(setId: string, on = true, framingId: string | null = null): PromptSequenceSetState {
return { setId, on, framingId }
}
export function emptyScene(
index = 1,
characters: PromptSequenceCharacter[] = [],
sets: PromptSequenceSet[] = []
): PromptSequenceScene {
return {
id: newId(),
title: `Scene ${Math.max(1, index)}`,
style: '',
settings: '',
setStates: sets.map(set => defaultSetState(set.id, true, null)),
characterStates: characters.map(character => defaultCharacterState(character.id, true)),
prompt: ''
}
}
export function cloneSceneForNext(previous: PromptSequenceScene, nextIndex: number): PromptSequenceScene {
return {
id: newId(),
title: `Scene ${nextIndex}`,
style: previous.style,
settings: previous.settings,
setStates: (previous.setStates || []).map(state => ({
setId: state.setId,
on: !!state.on,
framingId: state.framingId || null
})),
characterStates: (previous.characterStates || []).map(state => ({
characterId: state.characterId,
on: !!state.on,
outfitId: state.outfitId || null
})),
prompt: previous.prompt
}
}
function outfitPromptText(outfit?: { name?: string; description?: string } | null) {
if (!outfit) return ''
return String(outfit.description || '').trim() || String(outfit.name || '').trim()
}
/**
* Character line when on:
* - with outfit: "Name: description. Outfit: outfit description"
* - without: "Name: description" (or name-only / description-only)
*/
export function formatCharacterLine(
character: { name?: string; description?: string },
outfit?: { name?: string; description?: string } | null
) {
const name = String(character?.name || '').trim()
const description = String(character?.description || '').trim()
let base = ''
if (name && description) base = `${name}: ${description}`
else base = name || description
if (!base) return ''
const outfitText = outfitPromptText(outfit)
if (!outfitText) return base
return `${base}. Outfit: ${outfitText}`
}
/** Selected framing text for each on set. No set name, no framing label. */
export function assembleSetBlock(
scene: { setStates?: PromptSequenceSetState[] },
sets: PromptSequenceSet[] = []
) {
const states = scene.setStates || []
const stateById = new Map(states.map(state => [state.setId, state]))
const chunks: string[] = []
for (const set of sets) {
const state = stateById.get(set.id)
if (state && !state.on) continue
const framingId = state?.framingId || null
if (!framingId) continue
const framing = (set.framings || []).find(item => item.id === framingId)
const text = String(framing?.text || '').trim()
if (text) chunks.push(text)
}
return chunks.join('\n')
}
export function resolveSceneCharacterLines(
scene: { characterStates?: PromptSequenceCharacterState[] },
characters: PromptSequenceCharacter[] = []
) {
const states = scene.characterStates || []
const stateById = new Map(states.map(state => [state.characterId, state]))
return characters
.map((character) => {
const state = stateById.get(character.id)
if (state && !state.on) return ''
const outfitId = state?.outfitId || null
const outfit = outfitId
? (character.outfits || []).find(item => item.id === outfitId) || null
: null
return formatCharacterLine(character, outfit)
})
.filter(Boolean)
}
export function assembleSceneText(
scene: {
style?: string
settings?: string
prompt?: string
setStates?: PromptSequenceSetState[]
characterStates?: PromptSequenceCharacterState[]
/** Legacy per-scene characters (migration / old tests). */
characters?: Array<{ name?: string; description?: string }>
},
sequenceCharacters?: PromptSequenceCharacter[],
sequenceSets?: PromptSequenceSet[],
sectionOrder?: PromptSequenceSectionKey[] | unknown
) {
const setBlock = sequenceSets?.length
? assembleSetBlock(scene, sequenceSets)
: ''
const characterBlock = sequenceCharacters?.length
? resolveSceneCharacterLines(scene, sequenceCharacters).join('\n')
: (scene.characters || [])
.map(character => formatCharacterLine(character))
.filter(Boolean)
.join('\n')
const blocks: Record<PromptSequenceSectionKey, string> = {
style: String(scene.style || '').trim(),
settings: String(scene.settings || '').trim(),
set: setBlock,
characters: characterBlock,
prompt: String(scene.prompt || '').trim()
}
const order = normalizeSectionOrder(sectionOrder)
const parts = order.map(key => blocks[key]).filter(Boolean)
return parts.join('\n\n')
}
/**
* Whole sequence as one copyable block.
* Scenes join with a double blank line (\n\n\n) — same separator desktop Batch uses.
* Sequence name is never included.
*/
export function assembleSequenceText(sequence: {
sectionOrder?: PromptSequenceSectionKey[] | unknown
characters?: PromptSequenceCharacter[]
sets?: PromptSequenceSet[]
scenes?: Array<{
style?: string
settings?: string
prompt?: string
setStates?: PromptSequenceSetState[]
characterStates?: PromptSequenceCharacterState[]
characters?: Array<{ name?: string; description?: string }>
}>
}) {
const characters = sequence.characters || []
const sets = sequence.sets || []
const sectionOrder = normalizeSectionOrder(sequence.sectionOrder)
return (sequence.scenes || [])
.map(scene => assembleSceneText(
scene,
characters.length ? characters : undefined,
sets.length ? sets : undefined,
sectionOrder
))
.filter(Boolean)
.join('\n\n\n')
}
export function normalizePromptSequenceName(raw: unknown) {
return String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_NAME_MAX)
}
export function normalizePromptSequenceField(raw: unknown) {
return String(raw || '').replace(/\r\n/g, '\n').slice(0, PROMPT_SEQUENCE_FIELD_MAX)
}
export function normalizePromptSequenceTitle(raw: unknown, fallback: string) {
const title = String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_TITLE_MAX)
return title || fallback
}
export function normalizeOutfit(raw: unknown): PromptSequenceOutfit {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
return {
id: String(rec.id || '').trim() || newId(),
name: String(rec.name || '').trim().slice(0, 120),
description: normalizePromptSequenceField(rec.description).trim()
}
}
export function normalizeCharacter(raw: unknown): PromptSequenceCharacter {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const outfits = Array.isArray(rec.outfits) ? rec.outfits.map(normalizeOutfit) : []
return {
id: String(rec.id || '').trim() || newId(),
name: String(rec.name || '').trim().slice(0, 120),
description: normalizePromptSequenceField(rec.description).trim(),
outfits
}
}
export function normalizeFraming(raw: unknown): PromptSequenceFraming {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
return {
id: String(rec.id || '').trim() || newId(),
label: String(rec.label || '').trim().slice(0, 120),
text: normalizePromptSequenceField(rec.text)
}
}
/** Fold legacy beat-only sets into one framing labeled "wide". */
function framingsFromLegacyBeats(rec: Record<string, unknown>): PromptSequenceFraming[] {
const parts: string[] = []
const description = normalizePromptSequenceField(rec.description).trim()
if (description) parts.push(description)
const beats = Array.isArray(rec.beats) ? rec.beats : []
for (const beat of beats) {
if (!beat || typeof beat !== 'object') continue
const text = normalizePromptSequenceField((beat as Record<string, unknown>).text).trim()
if (text) parts.push(text)
}
if (!parts.length) return [emptyFraming()]
return [{ id: newId(), label: 'wide', text: parts.join('\n') }]
}
export function normalizeSet(raw: unknown): PromptSequenceSet {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
let framings: PromptSequenceFraming[]
if (Array.isArray(rec.framings) && rec.framings.length) {
framings = rec.framings.map(normalizeFraming)
} else if (Array.isArray(rec.beats) && rec.beats.length) {
framings = framingsFromLegacyBeats(rec)
} else if (String(rec.description || '').trim()) {
framings = framingsFromLegacyBeats(rec)
} else {
framings = [emptyFraming()]
}
return {
id: String(rec.id || '').trim() || newId(),
name: String(rec.name || '').trim().slice(0, 120),
framings
}
}
export function normalizeCharacterState(raw: unknown, fallbackCharacterId = ''): PromptSequenceCharacterState {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const characterId = String(rec.characterId || fallbackCharacterId || '').trim()
const outfitRaw = rec.outfitId
const outfitId = outfitRaw == null || outfitRaw === '' ? null : String(outfitRaw).trim() || null
return {
characterId,
on: rec.on === undefined ? true : !!rec.on,
outfitId
}
}
export function normalizeSetState(
raw: unknown,
fallbackSetId = '',
sets: PromptSequenceSet[] = [],
legacyShotSize = ''
): PromptSequenceSetState {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const setId = String(rec.setId || fallbackSetId || '').trim()
const framingRaw = rec.framingId
let framingId = framingRaw == null || framingRaw === '' ? null : String(framingRaw).trim() || null
const set = sets.find(item => item.id === setId)
if (framingId && set && !set.framings.some(framing => framing.id === framingId)) {
framingId = null
}
if (!framingId && legacyShotSize && set) {
const match = set.framings.find(
framing => framing.label.trim().toLowerCase() === legacyShotSize.trim().toLowerCase()
)
if (match) framingId = match.id
}
return {
setId,
on: rec.on === undefined ? true : !!rec.on,
framingId
}
}
function liftLegacyCharacters(scenesRaw: unknown[]): PromptSequenceCharacter[] {
const byId = new Map<string, PromptSequenceCharacter>()
for (const scene of scenesRaw) {
if (!scene || typeof scene !== 'object') continue
const chars = (scene as Record<string, unknown>).characters
if (!Array.isArray(chars)) continue
for (const character of chars) {
const normalized = normalizeCharacter(character)
if (!byId.has(normalized.id)) byId.set(normalized.id, normalized)
}
}
return [...byId.values()]
}
function syncCharacterStates(
statesRaw: unknown,
characters: PromptSequenceCharacter[],
defaultOn = true
): PromptSequenceCharacterState[] {
const incoming = Array.isArray(statesRaw)
? statesRaw.map(state => normalizeCharacterState(state)).filter(state => state.characterId)
: []
const byId = new Map(incoming.map(state => [state.characterId, state]))
return characters.map((character) => {
const existing = byId.get(character.id)
if (existing) {
const outfitOk = existing.outfitId
&& character.outfits.some(outfit => outfit.id === existing.outfitId)
return {
characterId: character.id,
on: existing.on,
outfitId: outfitOk ? existing.outfitId : null
}
}
return defaultCharacterState(character.id, defaultOn)
})
}
function syncSetStates(
statesRaw: unknown,
sets: PromptSequenceSet[],
defaultOn = true,
legacyShotSize = ''
): PromptSequenceSetState[] {
const incoming = Array.isArray(statesRaw)
? statesRaw.map(state => normalizeSetState(state, '', sets, legacyShotSize)).filter(state => state.setId)
: []
const byId = new Map(incoming.map(state => [state.setId, state]))
return sets.map((set) => {
const existing = byId.get(set.id)
if (existing) {
const framingOk = existing.framingId
&& set.framings.some(framing => framing.id === existing.framingId)
return {
setId: set.id,
on: existing.on,
framingId: framingOk ? existing.framingId : null
}
}
let framingId: string | null = null
if (legacyShotSize) {
const match = set.framings.find(
framing => framing.label.trim().toLowerCase() === legacyShotSize.trim().toLowerCase()
)
if (match) framingId = match.id
}
return defaultSetState(set.id, defaultOn, framingId)
})
}
export function normalizeScene(
raw: unknown,
index: number,
characters: PromptSequenceCharacter[] = [],
sets: PromptSequenceSet[] = []
): PromptSequenceScene {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const legacyShotSize = String(rec.shotSize || '').trim()
return {
id: String(rec.id || '').trim() || newId(),
title: normalizePromptSequenceTitle(rec.title, `Scene ${index + 1}`),
style: normalizePromptSequenceField(rec.style),
settings: normalizePromptSequenceField(rec.settings),
setStates: syncSetStates(rec.setStates, sets, true, legacyShotSize),
characterStates: syncCharacterStates(rec.characterStates, characters, true),
prompt: normalizePromptSequenceField(rec.prompt)
}
}
export function normalizePromptSequence(raw: unknown): PromptSequence | null {
if (!raw || typeof raw !== 'object') return null
const rec = raw as Record<string, unknown>
const name = normalizePromptSequenceName(rec.name)
if (!name) return null
const scenesRaw = Array.isArray(rec.scenes) ? rec.scenes : []
let characters = Array.isArray(rec.characters)
? rec.characters.map(normalizeCharacter)
: liftLegacyCharacters(scenesRaw)
if (!characters.length) characters = [emptyCharacter()]
const sets = Array.isArray(rec.sets) ? rec.sets.map(normalizeSet) : []
const scenes = scenesRaw.length
? scenesRaw.map((scene, index) => normalizeScene(scene, index, characters, sets))
: [emptyScene(1, characters, sets)]
const createdAt = Number(rec.createdAt) || Date.now()
return {
id: String(rec.id || '').trim() || newId(),
name,
sectionOrder: normalizeSectionOrder(rec.sectionOrder),
characters,
sets,
scenes,
createdAt,
updatedAt: Number(rec.updatedAt) || createdAt
}
}
/** Ensure every scene has a state row for every sequence character. */
export function ensureSceneStatesForCharacters(
scenes: PromptSequenceScene[],
characters: PromptSequenceCharacter[],
opts?: { newCharacterId?: string; turnOnSceneIndex?: number }
) {
const newCharacterId = opts?.newCharacterId
const turnOnIndex = opts?.turnOnSceneIndex
for (let i = 0; i < scenes.length; i++) {
const scene = scenes[i]
const byId = new Map(scene.characterStates.map(state => [state.characterId, state]))
scene.characterStates = characters.map((character) => {
const existing = byId.get(character.id)
if (existing) return existing
const on = newCharacterId && character.id === newCharacterId
? i === turnOnIndex
: true
return defaultCharacterState(character.id, on)
})
}
}
/** Ensure every scene has a state row for every sequence set. */
export function ensureSceneStatesForSets(
scenes: PromptSequenceScene[],
sets: PromptSequenceSet[],
opts?: { newSetId?: string; turnOnSceneIndex?: number }
) {
const newSetId = opts?.newSetId
const turnOnIndex = opts?.turnOnSceneIndex
for (let i = 0; i < scenes.length; i++) {
const scene = scenes[i]
const byId = new Map((scene.setStates || []).map(state => [state.setId, state]))
scene.setStates = sets.map((set) => {
const existing = byId.get(set.id)
if (existing) {
const framingOk = existing.framingId
&& set.framings.some(framing => framing.id === existing.framingId)
return {
setId: set.id,
on: existing.on,
framingId: framingOk ? existing.framingId : null
}
}
const on = newSetId && set.id === newSetId
? i === turnOnIndex
: true
return defaultSetState(set.id, on, null)
})
}
}