Compare commits

...
21 Commits
Author SHA1 Message Date
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
TowstyandCursor c0289afa0e Rebuild Bench as the three-column cinematic layout with Generate CTA.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 13:36:09 -05:00
TowstyandCursor a9799e4920 Lock display tokens and slot sizes to the cinematic pixel grid.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 13:36:04 -05:00
TowstyandCursor 52bd6cf4a8 Align Studio display chrome to the Classic | Studio reference mock.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 13:22:18 -05:00
44 changed files with 3903 additions and 467 deletions
+97
View File
@@ -0,0 +1,97 @@
# 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) │
│ │
├ filmstrip scroll-x, tiles 72px ───────────────────────────┤
├ [Slots] [Prompt] [Look] [Gen] Generate (amber, 48px) ─┤
└───────────────────────────────────────────────────────────┘
```
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AIGen — Display mock (Classic | Studio)</title>
<link rel="stylesheet" href="./tokens/display.css" />
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, system-ui, Inter, sans-serif; background: #0e0e10; color: #f3f1ea; }
.app { min-height: 100vh; display: flex; flex-direction: column; gap: var(--chrome-gap); padding: var(--chrome-gap); }
header.top {
height: var(--header-h); display: flex; align-items: center; gap: 12px;
border: 1px solid var(--line); border-radius: var(--radius); padding: 0 12px; background: var(--bg-raise);
}
.brand { font-weight: 650; letter-spacing: .02em; }
.grow { flex: 1; }
.pill { font-size: var(--type-sm); color: #b8e0a8; border: 1px solid #2d4a2a; background: #142016; border-radius: 999px; padding: 4px 10px; }
.nav { color: var(--muted); font-size: var(--type-sm); display: flex; gap: 14px; }
.nav b { color: var(--text); font-weight: 600; }
.identity-header, .classic-hide { display: flex; gap: 10px; align-items: center; font-size: var(--type-sm); color: var(--muted); }
.rail { display: flex; gap: 6px; flex-wrap: wrap; }
.seg {
height: var(--rail-h); padding: 0 12px; border-radius: var(--radius);
border: 1px solid var(--line); background: var(--bg-slot); color: var(--muted); cursor: pointer;
}
.seg[aria-pressed="true"] { background: #2a1f0c; border-color: var(--amber); color: var(--amber); }
.seg:disabled { opacity: .45; cursor: not-allowed; }
.classic-selects { display: flex; gap: 8px; }
.classic-selects select {
background: var(--bg-slot); color: var(--text); border: 1px solid var(--line);
border-radius: var(--radius); height: var(--rail-h); padding: 0 8px;
}
.grid { display: grid; grid-template-columns: 240px 1fr 260px; gap: var(--chrome-gap); flex: 1; }
.col { background: var(--bg-raise); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--card-pad); }
.slot { background: var(--bg-slot); border: 1px dashed var(--line); border-radius: var(--radius); min-height: 72px; margin-bottom: 8px; padding: 8px; font-size: var(--type-sm); color: var(--muted); }
.slot strong { display: block; color: var(--text); margin-bottom: 4px; }
.viewer { position: relative; background: #111; border-radius: var(--radius); min-height: 320px; display: grid; place-items: center; color: var(--muted); border: 1px solid var(--line); }
.eye-viewer { position: absolute; top: 8px; right: 8px; background: #000a; border: 1px solid var(--line); border-radius: 6px; padding: 4px; }
.strip { display: flex; gap: 8px; margin-top: 10px; }
.tile { width: 72px; height: 48px; background: #222; border-radius: 6px; border: 1px solid var(--line); position: relative; }
.eye-tile { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; }
textarea, input {
width: 100%; background: var(--bg-slot); color: var(--text); border: 1px solid var(--line);
border-radius: var(--radius); padding: 8px; font: inherit;
}
.cta { width: 100%; height: 44px; border: 0; border-radius: var(--radius); background: var(--amber); color: #1a1204; font-weight: 700; cursor: pointer; margin-top: 10px; }
.hint { font-size: 11px; color: var(--muted); margin: 6px 0 10px; }
.settings {
position: fixed; right: 16px; bottom: 16px; width: 360px; background: var(--bg-raise);
border: 1px solid var(--line); border-radius: 10px; padding: 14px; box-shadow: 0 12px 40px #0008;
}
.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.card {
border: 1px solid var(--line); border-radius: 8px; padding: 10px; background: var(--bg-slot); cursor: pointer; text-align: left; color: inherit;
}
.card[aria-pressed="true"] { border-color: var(--amber); background: #2a1f0c; }
.card small { display: block; color: var(--muted); margin-top: 4px; }
</style>
</head>
<body>
<div class="app" data-display="studio" id="root">
<header class="top">
<span class="brand">AIGen</span>
<span class="muted" style="color:var(--muted);font-size:12px">/ Inbox</span>
<div class="grow"></div>
<div class="identity-header">
<label><input type="checkbox" checked /> Lock face</label>
<label><input type="checkbox" checked /> Lock outfit</label>
</div>
<div class="classic-hide">
<label><input type="checkbox" /> Hide input</label>
<label><input type="checkbox" /> Hide output</label>
</div>
<nav class="nav"><span>Library</span><b>Queue</b><span>Settings</span></nav>
<span class="pill">5080 Ready</span>
</header>
<div class="rail rail-task" role="tablist">
<button class="seg" aria-pressed="false">Image</button>
<button class="seg" aria-pressed="false">Edit</button>
<button class="seg" aria-pressed="false">Refine</button>
<button class="seg" aria-pressed="true">Video</button>
<button class="seg" aria-pressed="false">Extend</button>
<button class="seg" aria-pressed="false">Music</button>
</div>
<div class="rail rail-model">
<button class="seg" aria-pressed="true">MiniMax H3</button>
<button class="seg" disabled title="xAIGen only">LTX</button>
</div>
<div class="classic-selects">
<select><option>Video</option><option>Image</option><option>Extend</option><option>Music</option></select>
<select><option>MiniMax H3</option><option disabled>LTX (xAIGen only)</option></select>
</div>
<div class="grid">
<aside class="col">
<div class="slot"><strong>Hero</strong>car_bee still</div>
<div class="slot"><strong>Start</strong>Select still or clip</div>
<div class="slot"><strong>End</strong>Optional</div>
<div class="identity-slots">
<label><input type="checkbox" checked /> Lock face</label><br />
<label><input type="checkbox" checked /> Lock outfit</label>
<p class="hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
</div>
</aside>
<main class="col">
<div class="viewer">
<button class="eye-viewer" title="Toggle viewer">eye</button>
viewer · last frame / waveform
</div>
<div class="strip">
<div class="tile"><span class="eye-tile">👁</span></div>
<div class="tile"><span class="eye-tile">👁</span></div>
<div class="tile"><span class="eye-tile">👁</span></div>
<div class="tile"><span class="eye-tile">👁</span></div>
</div>
</main>
<aside class="col">
<textarea rows="5">car_bee walks the Great Hall, cinematic</textarea>
<p class="hint">YuE2 target length is a plan hint, not a trim.</p>
<input value="seed unlocked" />
<button class="cta">Generate</button>
</aside>
</div>
</div>
<aside class="settings">
<strong>Settings → Display</strong>
<p class="hint">Layout only. Jobs, engines, and library IDs stay the same.</p>
<div class="cards">
<button class="card" id="btnClassic" aria-pressed="false">Classic<small>packed header, dropdowns</small></button>
<button class="card" id="btnStudio" aria-pressed="true">Studio<small>task rail, model rail, slots + eyes</small></button>
</div>
</aside>
<script>
const root = document.getElementById("root");
const classic = document.getElementById("btnClassic");
const studio = document.getElementById("btnStudio");
function setMode(mode) {
root.setAttribute("data-display", mode);
classic.setAttribute("aria-pressed", mode === "classic" ? "true" : "false");
studio.setAttribute("aria-pressed", mode === "studio" ? "true" : "false");
}
classic.onclick = () => setMode("classic");
studio.onclick = () => setMode("studio");
</script>
</body>
</html>
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 3l18 18"/>
<path d="M10.6 10.6A3 3 0 0 0 12 15a3 3 0 0 0 2.4-4.4"/>
<path d="M9.9 5.1A11 11 0 0 1 12 5c6.5 0 10 7 10 7a18 18 0 0 1-3.2 3.8"/>
<path d="M6.1 6.1C3.7 7.8 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.2-.9"/>
</svg>

After

Width:  |  Height:  |  Size: 446 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4" y="11" width="16" height="10" rx="2"/>
<path d="M8 11V8a4 4 0 0 1 8 0v3"/>
</svg>

After

Width:  |  Height:  |  Size: 302 B

@@ -0,0 +1,56 @@
/* AIGen display tokens — Classic | Studio
Apply on [data-display] wrapper. Do not fork components. */
:root,
[data-display="studio"] {
--bg: #0e0e10;
--bg-raise: #161618;
--bg-slot: #1c1c1f;
--line: #2a2a2e;
--text: #f3f1ea;
--muted: #9a9588;
--amber: #f5a524;
--amber-dim: #c4841c;
--radius: 8px;
--chrome-gap: 16px;
--rail-h: 40px;
--card-pad: 14px;
--header-h: 52px;
--type: 14px;
--type-sm: 12px;
}
[data-display="classic"] {
--chrome-gap: 8px;
--rail-h: 32px;
--card-pad: 10px;
--header-h: 44px;
--type: 13px;
}
[data-display] {
background: var(--bg);
color: var(--text);
font-size: var(--type);
}
[data-display="classic"] .rail-task,
[data-display="classic"] .rail-model {
display: none;
}
[data-display="studio"] .classic-selects {
display: none;
}
[data-display="classic"] .identity-slots {
display: none;
}
[data-display="studio"] .identity-header {
display: none;
}
[data-display="classic"] .eye-tile,
[data-display="classic"] .eye-viewer {
display: none;
}
[data-display="studio"] .classic-hide {
display: none;
}
+650 -75
View File
@@ -1,118 +1,693 @@
/* Studio 2 display skins — Classic | Studio. Layout tokens only; palette matches main.css. */ /* Pixel grid tokens — Classic | Studio. Match TARGET-classic-layout.png */
:root, :root,
[data-display='classic'],
[data-display='studio'] { [data-display='studio'] {
--display-bg: #09090b; --bg: #0b0b0d;
--display-fg: #e4e4e7; --panel: #141416;
--display-muted: #a1a1aa; --panel-raise: #1a1a1d;
--display-border: rgba(255, 255, 255, 0.1); --line: #2a2a2e;
--display-amber: #fbbf24; --text: #f3f1ea;
--display-amber-fg: #09090b; --muted: #9a9588;
--display-radius: 8px; --amber: #f5a524;
--display-focus: rgba(252, 211, 77, 0.5); --amber-on: #1a1204;
--chip-bg: #2a2418;
--chip-line: #5a4a28;
--chip-text: #e8c37a;
--success: #7dda6f;
--radius: 8px;
--page-gap: 12px;
--field-gap: 12px;
--header-h: 48px;
--rail-task-h: 40px;
--rail-model-h: 36px;
--left-w: 260px;
--right-w: 280px;
--type: 14px;
--type-sm: 12px;
--type-label: 11px;
} }
[data-display='classic'] { [data-display='classic'] {
--chrome-gap: 0.5rem; --field-gap: 8px;
--rail-h: 2.25rem; --type: 13px;
--card-pad: 0.75rem;
--display-type-title: 1.75rem;
--display-type-body: 0.875rem;
--display-viewer-radius: 0.75rem;
} }
[data-display='studio'] { [data-display] {
--chrome-gap: 1rem; background: var(--bg);
--rail-h: 3rem; color: var(--text);
--card-pad: 1rem; font-family: ui-sans-serif, system-ui, sans-serif;
--display-type-title: 1.875rem; font-size: var(--type);
--display-type-body: 0.9375rem;
--display-viewer-radius: 8px;
} }
[data-display] .display-title { /* Skin toggles */
font-size: var(--display-type-title); [data-display='classic'] .rail-task,
[data-display='classic'] .rail-model,
[data-display='classic'] .identity-slots,
[data-display='classic'] .eye-viewer,
[data-display='classic'] .eye-tile {
display: none !important;
}
[data-display='studio'] .classic-task-model,
[data-display='studio'] .viewer-lock-bar {
display: none !important;
} }
[data-display] .display-body { .bench-root {
font-size: var(--display-type-body);
}
[data-display] .display-stack {
display: flex; display: flex;
height: 100dvh;
max-height: 100dvh;
flex-direction: column; flex-direction: column;
gap: var(--chrome-gap); overflow: hidden;
background: var(--bg);
color: var(--text);
} }
[data-display] .display-card { .bench-root[data-mobile="1"] {
border-radius: var(--display-radius); position: relative;
border: 1px solid var(--display-border);
padding: var(--card-pad);
} }
[data-display] .display-viewer { .bench-header {
border-radius: var(--display-viewer-radius); display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
height: var(--header-h);
min-height: var(--header-h);
flex-shrink: 0;
border-bottom: 1px solid var(--line);
padding: 0 12px;
background: var(--bg);
} }
[data-display] .display-rail { .bench-brand {
display: flex; font-weight: 650;
flex-wrap: wrap; letter-spacing: 0.02em;
gap: 0.5rem;
min-height: var(--rail-h);
} }
[data-display] .display-rail__seg { .bench-crumb {
min-height: var(--rail-h); display: inline-flex;
border-radius: var(--display-radius); align-items: center;
border: 1px solid var(--display-border); gap: 6px;
padding: 0.5rem 1rem; color: var(--muted);
font-size: var(--display-type-body); font-size: var(--type-sm);
color: var(--display-fg); }
.bench-crumb select {
max-width: 14rem;
border: 0;
background: transparent; background: transparent;
color: var(--text);
font: inherit;
outline: none; outline: none;
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease; cursor: pointer;
} }
[data-display] .display-rail__seg:focus-visible { .bench-header-right {
box-shadow: 0 0 0 2px var(--display-focus); display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
} }
[data-display] .display-rail__seg[aria-pressed='true'], .bench-nav {
[data-display] .display-rail__seg[data-active='true'] { display: flex;
border-color: var(--display-amber); gap: 14px;
background: var(--display-amber); font-size: var(--type-sm);
color: var(--display-amber-fg); color: var(--muted);
} }
[data-display] .display-rail__seg:disabled, .bench-nav a {
[data-display] .display-rail__seg[aria-disabled='true'] { color: inherit;
opacity: 0.55; text-decoration: none;
}
.bench-nav a:hover,
.bench-nav a.router-link-active {
color: var(--amber);
font-weight: 600;
}
.gpu-chip {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-raise);
padding: 8px 12px;
font-size: var(--type-sm);
color: var(--text);
}
.gpu-chip .dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--muted);
}
.gpu-chip[data-tone='ready'] .dot { background: var(--success); }
.gpu-chip[data-tone='busy'] .dot { background: var(--amber); animation: pulse 1.2s infinite; }
@keyframes pulse {
50% { opacity: 0.45; }
}
.rail {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 6px;
padding: 0 12px;
}
.rail-task { min-height: var(--rail-task-h); align-items: center; padding-top: 8px; }
.rail-model { min-height: var(--rail-model-h); align-items: center; padding-bottom: 8px; }
.seg {
height: 36px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
padding: 0 14px;
color: var(--muted);
font-size: var(--type);
font-weight: 600;
text-transform: capitalize;
outline: none;
cursor: pointer;
}
.rail-model .seg { height: 32px; font-weight: 500; }
.seg[aria-pressed='true'] {
border-color: var(--amber);
background: var(--amber);
color: var(--amber-on);
}
.seg:disabled {
opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
} }
[data-display='studio'] .display-rail__seg { .seg:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
}
.bench-grid {
display: grid;
flex: 1;
min-height: 0;
grid-template-columns: var(--left-w) minmax(0, 1fr) var(--right-w);
gap: var(--page-gap);
padding: var(--page-gap);
}
.bench-panel {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
}
.bench-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
}
.inspector {
display: flex;
flex-direction: column;
gap: var(--field-gap);
}
.field-label {
display: block;
margin-bottom: 4px;
color: var(--muted);
font-size: var(--type-label);
text-transform: none;
}
.field-control {
width: 100%;
height: 36px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel-raise);
padding: 0 10px;
color: var(--text);
font: inherit;
outline: none;
}
.field-control:focus-visible,
.field-area:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
}
.field-area {
width: 100%;
min-height: 88px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel-raise);
padding: 8px 10px;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.4;
resize: vertical;
outline: none;
}
.neg-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.neg-chip {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--chip-line);
border-radius: 999px;
background: var(--chip-bg);
padding: 4px 10px;
color: var(--chip-text);
font-size: var(--type-sm);
}
.neg-chip button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.75;
}
.seed-row {
display: flex;
gap: 8px;
}
.seed-row .field-control { flex: 1; }
.dice-btn {
display: grid;
width: 36px;
height: 36px;
place-items: center;
flex-shrink: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel-raise);
color: var(--text);
cursor: pointer;
}
.viewer-lock-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: var(--type-sm);
}
.center-stack {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
flex-direction: column;
}
/* Stage: fills center column above filmstrip + transport */
.viewer-frame {
position: relative;
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
align-items: center;
justify-content: center;
overflow: hidden;
background: #0b0b0d;
}
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */
.viewer-frame > img,
.viewer-frame > video {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
object-position: center;
}
.eye-viewer {
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: 1px solid var(--line);
border-radius: 6px;
background: #000a;
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);
}
.tile {
position: relative;
width: 96px;
height: 64px;
flex-shrink: 0;
overflow: hidden;
border: 2px solid transparent;
border-radius: 6px;
background: #222;
}
@media (min-width: 1440px) {
.tile { width: 120px; height: 80px; }
.filmstrip { height: 96px; }
}
.tile[data-active='true'] {
border-color: var(--amber);
}
.tile button.pick {
display: block;
width: 100%;
height: 100%;
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
}
.tile img {
width: 100%;
height: 100%;
object-fit: cover;
}
.tile .eye-tile,
.tile .tile-trash {
position: absolute;
top: 3px;
right: 3px;
display: grid;
width: 18px;
height: 18px;
place-items: center;
border: 0;
border-radius: 4px;
background: #000a;
color: var(--text);
cursor: pointer;
}
.tile .tile-trash { right: 22px; }
.strip-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
border-top: 1px solid var(--line);
padding: 8px 10px;
background: var(--panel);
}
.strip-actions button {
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--text);
padding: 4px 10px;
font-size: var(--type-sm);
cursor: pointer;
}
.strip-actions button:hover {
border-color: var(--amber);
color: var(--amber);
}
.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;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--line);
padding: 0 10px;
color: var(--muted);
font-size: var(--type-sm);
font-variant-numeric: tabular-nums;
}
.transport .ops {
display: flex;
gap: 8px;
}
.transport button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
}
.queue-col {
display: flex;
min-height: 0;
flex-direction: column;
}
.queue-head {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
padding: 12px 12px 8px;
font-weight: 650;
}
.queue-badge {
display: inline-grid;
min-width: 1.25rem;
height: 1.25rem;
place-items: center;
border-radius: 999px;
background: var(--amber);
color: var(--amber-on);
font-size: 11px;
font-weight: 700;
}
.queue-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 0 12px 12px;
}
.job-card {
margin-bottom: 8px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel-raise);
padding: 10px;
cursor: pointer;
}
.job-card[data-active='true'] {
border-width: 2px;
border-color: var(--amber);
}
.job-id {
color: var(--muted);
font-size: var(--type-sm);
}
.job-title {
margin-top: 2px;
font-weight: 600; font-weight: 600;
padding: 0.625rem 1.25rem; line-height: 1.3;
} }
[data-display='classic'] .display-rail__seg { .job-meta {
min-height: 2.25rem; margin-top: 4px;
padding: 0.375rem 0.75rem; color: var(--muted);
font-weight: 500; font-size: var(--type-sm);
} }
[data-display] .display-cta { .job-status {
border-radius: var(--display-radius); display: flex;
background: var(--display-amber); margin-top: 8px;
color: var(--display-amber-fg); align-items: center;
font-weight: 600; justify-content: space-between;
gap: 8px;
font-size: var(--type-sm);
} }
[data-display] .display-cta:focus-visible { .job-status .left {
box-shadow: 0 0 0 2px var(--display-focus); display: inline-flex;
align-items: center;
gap: 6px;
} }
[data-display] .display-control { .job-status .dot {
border-radius: var(--display-radius); width: 7px;
height: 7px;
border-radius: 999px;
background: var(--success);
}
.job-status[data-state='queued'] .dot,
.job-status[data-state='waiting'] .dot {
background: var(--muted);
}
.kebab {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.queue-foot {
flex-shrink: 0;
border-top: 1px solid var(--line);
padding: 12px;
}
.generate-cta {
display: inline-flex;
width: 100%;
height: 44px;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: var(--radius);
background: var(--amber);
color: var(--amber-on);
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.generate-cta:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.queue-foot .tiny {
margin-top: 8px;
text-align: center;
color: var(--muted);
font-size: 11px;
}
.queue-foot .tiny button {
border: 0;
background: transparent;
color: inherit;
text-decoration: underline;
cursor: pointer;
}
.slot-block { width: 100%; }
.slot-thumb {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #0a0a0c;
cursor: pointer;
}
.slot-thumb--hero { height: 148px; }
.slot-thumb--slot { height: 96px; }
.slot-thumb--empty {
height: 72px;
border-style: dashed;
color: var(--muted);
font-size: var(--type-sm);
}
.slot-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.slot-caption {
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: var(--type-sm);
}
.slot-upload {
display: inline-block;
margin-top: 4px;
color: var(--amber);
font-size: var(--type-sm);
cursor: pointer;
}
.placeholder-still {
color: var(--muted);
font-size: var(--type-sm);
} }
+159 -52
View File
@@ -6,22 +6,21 @@
<li <li
v-for="(item, index) in model" v-for="(item, index) in model"
:key="item.name" :key="item.name"
class="rounded-xl border bg-zinc-950/80" class="rounded-lg border border-[#2A2A2E] bg-[#1A1A1D] p-2.5"
:class="[ :class="[
compact ? 'px-2 py-1.5' : 'px-3 py-2', dragOverIndex === index ? 'border-amber-300/50' : '',
dragOverIndex === index ? 'border-amber-300/50' : 'border-white/10',
draggingIndex === index ? 'opacity-60' : '' draggingIndex === index ? 'opacity-60' : ''
]" ]"
@dragover.prevent="onDragOver(index)" @dragover.prevent="onDragOver(index)"
@drop.prevent="onDrop(index)" @drop.prevent="onDrop(index)"
@dragleave="onDragLeave(index)" @dragleave="onDragLeave(index)"
> >
<div class="flex items-center justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 flex-1 items-start gap-2">
<button <button
v-if="model.length > 1" v-if="model.length > 1"
type="button" 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" :disabled="disabled"
draggable="true" draggable="true"
title="Drag to reorder" title="Drag to reorder"
@@ -31,61 +30,91 @@
> >
<span class="block leading-none" aria-hidden="true">⋮⋮</span> <span class="block leading-none" aria-hidden="true">⋮⋮</span>
</button> </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">
<LoraTriggerTip <span class="flex min-w-0 items-center gap-1.5">
:text="triggerFor(item.name)" <span class="block truncate font-medium text-[#F3F1EA]" :title="labelFor(item.name)">{{ labelFor(item.name) }}</span>
:aria-label="'Activation for ' + labelFor(item.name)" <LoraTriggerTip
@insert="emit('insert-trigger', $event)" :text="triggerFor(item.name)"
@save="saveTrigger(item.name, $event)" :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> </div>
<button <button
type="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" :disabled="disabled"
@click="removeAt(index)" @click="removeAt(index)"
> >
Remove Remove
</button> </button>
</div> </div>
<div class="mt-1.5 flex flex-wrap gap-3">
<Thumbwheel <div class="mt-2 space-y-1.5">
:model-value="item.strengthModel" <label class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]">
label="Model" <span>{{ clipStrength ? 'Model' : 'Strength' }}</span>
:lock-key="`lora-model:${item.name}`" <input
:min="LORA_STRENGTH_MIN" type="range"
:max="LORA_STRENGTH_MAX" class="lora-strength-range min-w-0"
:step="LORA_STRENGTH_STEP" :min="0"
:fine-step="LORA_STRENGTH_STEP" :max="2"
:coarse-step="0.25" :step="LORA_STRENGTH_STEP"
:digits="2" :disabled="disabled"
compact :value="displayStrength(item.strengthModel)"
:disabled="disabled" :aria-label="(clipStrength ? 'Model' : 'Strength') + ' for ' + labelFor(item.name)"
@update:model-value="setStrength(index, 'strengthModel', $event)" @input="onRange(index, 'strengthModel', $event)"
/> >
<Thumbwheel <input
:model-value="item.strengthClip" type="number"
label="CLIP" 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]"
:lock-key="`lora-clip:${item.name}`" :min="0"
:min="LORA_STRENGTH_MIN" :max="2"
:max="LORA_STRENGTH_MAX" :step="LORA_STRENGTH_STEP"
:step="LORA_STRENGTH_STEP" :disabled="disabled"
:fine-step="LORA_STRENGTH_STEP" :value="displayStrength(item.strengthModel).toFixed(2)"
:coarse-step="0.25" :aria-label="(clipStrength ? 'Model' : 'Strength') + ' value'"
:digits="2" @change="onNumber(index, 'strengthModel', $event)"
compact >
:disabled="disabled || !clipStrength" </label>
@update:model-value="setStrength(index, 'strengthClip', $event)" <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> </div>
</li> </li>
</ul> </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 <select
v-if="model.length < LORA_STACK_MAX" 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="[ :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' : '' model.length ? 'mt-2' : ''
]" ]"
:disabled="disabled || !availableNames.length" :disabled="disabled || !availableNames.length"
@@ -103,13 +132,55 @@
</div> </div>
</template> </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"> <script setup lang="ts">
import { import {
LORA_STACK_MAX, LORA_STACK_MAX,
LORA_STRENGTH_MAX, LORA_STRENGTH_DEFAULT,
LORA_STRENGTH_MIN,
LORA_STRENGTH_STEP, LORA_STRENGTH_STEP,
clampLoraStrength, loraDisplayName,
loraFriendlyName, loraFriendlyName,
loraIdentityKey, loraIdentityKey,
loraMatchesImageEngine, loraMatchesImageEngine,
@@ -120,6 +191,9 @@ import {
type LoraStackItem type LoraStackItem
} from '~/utils/loras' } from '~/utils/loras'
const UI_MIN = 0
const UI_MAX = 2
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
names: string[] names: string[]
filenameSubtitle?: boolean filenameSubtitle?: boolean
@@ -175,6 +249,17 @@ const selectPlaceholder = computed(() => {
return model.value.length ? props.addLabel : props.emptyLabel 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) { function onAdd(event: Event) {
const name = (event.target as HTMLSelectElement).value const name = (event.target as HTMLSelectElement).value
;(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 if (!item || model.value.length >= LORA_STACK_MAX) return
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name))) const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
if (taken.has(loraIdentityKey(item.name))) return 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) { function removeAt(index: number) {
@@ -234,8 +323,22 @@ function onDragEnd() {
} }
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) { function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
const next = clampLoraStrength(value) const next = clampUi(value)
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item) 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) { function triggerFor(name: string) {
@@ -246,6 +349,10 @@ function labelFor(name: string) {
return loraFriendlyName(name, props.aliases) return loraFriendlyName(name, props.aliases)
} }
function stemFor(name: string) {
return loraDisplayName(name)
}
function saveTrigger(name: string, trigger: string) { function saveTrigger(name: string, trigger: string) {
emit('update-trigger', name, trigger) emit('update-trigger', name, trigger)
} }
+2 -2
View File
@@ -34,7 +34,7 @@
@click="setDisplayMode('classic')" @click="setDisplayMode('classic')"
> >
<span class="block font-display text-lg font-semibold text-zinc-50">Classic</span> <span class="block font-display text-lg font-semibold text-zinc-50">Classic</span>
<span class="mt-1 block text-xs text-zinc-400">Packed header, compact selects, tight gaps.</span> <span class="mt-1 block text-xs text-zinc-400">packed header, dropdowns</span>
</button> </button>
<button <button
type="button" type="button"
@@ -46,7 +46,7 @@
@click="setDisplayMode('studio')" @click="setDisplayMode('studio')"
> >
<span class="block font-display text-lg font-semibold text-zinc-50">Studio</span> <span class="block font-display text-lg font-semibold text-zinc-50">Studio</span>
<span class="mt-1 block text-xs text-zinc-400">Task and model rails, locks by slots, eye toggles.</span> <span class="mt-1 block text-xs text-zinc-400">task rail, model rail, slots + eyes</span>
</button> </button>
</div> </div>
</section> </section>
+18 -2
View File
@@ -1,8 +1,24 @@
<template> <template>
<div class="w-36 shrink-0 space-y-2"><span class="text-xs text-zinc-400">{{ label }}</span><button class="flex h-24 w-full items-center justify-center overflow-hidden rounded-xl border border-white/15 bg-black" @click="$emit('pick')"><span v-if="asset && hidden" class="p-2 text-xs text-zinc-400">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}`" class="h-full w-full object-contain" :alt="label"><span v-else class="text-xs text-zinc-400">Choose {{ label.toLowerCase() }}</span></button><p v-if="asset" class="truncate text-xs" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p><label v-if="upload" class="block cursor-pointer text-xs text-amber-200">Upload {{ label.toLowerCase() }}<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)"></label></div> <div class="slot-block">
<span class="field-label">{{ label }}</span>
<button
type="button"
class="slot-thumb"
:class="asset ? (size==='hero' ? 'slot-thumb--hero' : 'slot-thumb--slot') : 'slot-thumb--empty'"
@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>
</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)">
</label>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {assetLabel} from '~/shared/studio2/viewer.mjs' import {assetLabel} from '~/shared/studio2/viewer.mjs'
defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean}>() withDefaults(defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean;size?:'hero'|'slot'}>(),{size:'slot'})
defineEmits(['pick','upload']) defineEmits(['pick','upload'])
</script> </script>
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -19,7 +19,17 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'}, {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:'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:'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)}, {label:'Compiled prompt',value:m.compiledPrompt},
{label:'Prompt (raw)',value:m.promptRaw || null},
{label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'},
{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} {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> </script>
+585
View File
@@ -0,0 +1,585 @@
<template>
<div class="m-shell" data-mobile="1">
<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" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
<details class="m-more">
<summary aria-label="More">⋯</summary>
<div class="m-more-menu">
<NuxtLink to="/library" @click="closeMenus">Library</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">
<button
v-if="ctx.selected && ctx.xaigen"
type="button"
class="m-eye"
:aria-label="ctx.isHidden(ctx.selected) ? 'Show preview' : 'Hide preview'"
@click.stop="ctx.toggleReveal(ctx.selected.id)"
>
<img :src="ctx.isHidden(ctx.selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
</button>
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
<p class="m-muted">{{ ctx.selected.kind==='track' ? 'Track loaded · preview hidden' : 'Preview hidden' }}</p>
</template>
<template v-else-if="ctx.selected?.kind==='track'">
<div 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'"
:key="ctx.selected.id"
:src="`/api/library/clips/${ctx.selected.id}/video`"
controls
playsinline
/>
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still">
<p v-else class="m-muted">Select a still or clip</p>
</div>
<div class="m-strip" aria-label="Family outputs">
<div
v-for="o in ctx.family"
:key="o.id"
class="m-tile"
:class="{ on: ctx.selected?.id===o.id }"
>
<button type="button" class="m-tile-pick" @click="ctx.selectOutput(o)">
<span v-if="ctx.isHidden(o)" class="m-tile-hid">Hidden</span>
<span v-else-if="o.kind==='track'" class="m-tile-hid">Audio</span>
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : ctx.stillUrl(o.id)" alt="">
</button>
<button
v-if="ctx.xaigen"
type="button"
class="m-tile-eye"
:aria-label="ctx.isHidden(o) ? 'Show tile' : 'Hide tile'"
@click.stop="ctx.toggleReveal(o.id)"
>
<img :src="ctx.isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
</button>
<button
v-if="ctx.selected?.id===o.id"
type="button"
class="m-tile-trash"
aria-label="Delete selected"
@click.stop="ctx.deleteOutput"
>🗑</button>
</div>
</div>
<div v-if="ctx.selected && ctx.selected.kind!=='track'" class="m-strip-actions">
<button type="button" @click="ctx.applyUseAsSource">Use as start</button>
<button type="button" @click="ctx.applyUseAsHero">Use as hero</button>
<button type="button" @click="ctx.copySelectedPrompt">Copy prompt</button>
<button type="button" @click="ctx.loadSelectedJob">Load</button>
<button type="button" @click="ctx.deleteOutput">Delete</button>
</div>
<div class="m-dock">
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Slots</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==='slots'" class="m-sheet" role="dialog" aria-label="Slots">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Slots</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
<template v-if="ctx.form.mode==='music'">
<p class="m-muted">Music uses tags + lyrics in Prompt / Gen — no hero slot.</p>
</template>
<template v-else>
<Studio2AssetSlot size="hero" :label="ctx.heroSlotLabel" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.identityStillId)" :hidden="ctx.slotHidden(ctx.form.identityStillId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker('identityStillId')" @upload="ctx.upload($event,'hero')" />
<Studio2AssetSlot :label="ctx.sourceSlotLabel" :asset="ctx.sourceSlot" :hidden="ctx.slotHidden(ctx.form.mode==='extend'?ctx.form.startClipId:ctx.form.imageAId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker(ctx.form.mode==='extend'?'startClipId':'imageAId')" @upload="ctx.upload($event,'source')" />
<p v-if="ctx.editSourceRequired && !ctx.form.imageAId" class="m-hint">Required for Edit</p>
<p v-if="ctx.slotPairHint" class="m-hint">{{ ctx.slotPairHint }}</p>
<Studio2AssetSlot v-if="ctx.form.mode==='compose'" label="Second still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.imageBId)" :hidden="ctx.slotHidden(ctx.form.imageBId)" @pick="ctx.setPicker('imageBId')" />
<Studio2AssetSlot v-if="ctx.form.mode==='refine'" label="Mask" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.maskId)" :hidden="ctx.slotHidden(ctx.form.maskId)" @pick="ctx.setPicker('maskId')" />
<Studio2AssetSlot v-if="ctx.video" label="End still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.endStillId)" :hidden="ctx.slotHidden(ctx.form.endStillId)" @pick="ctx.setPicker('endStillId')" />
<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>
<p class="m-hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
<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">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></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.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" 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>
</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>
</template>
<script setup lang="ts">
export type Studio2MobileCtx = Record<string, any>
const props = defineProps<{ ctx: Studio2MobileCtx }>()
const sheet = ref('')
function toggleSheet(id: string) {
sheet.value = sheet.value === id ? '' : id
}
function closeMenus() {
sheet.value = ''
}
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 {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
.m-eye {
position: absolute;
top: 8px;
right: 8px;
z-index: 5;
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: 1px solid var(--line);
border-radius: 6px;
background: #000a;
}
.m-strip {
display: flex;
height: 64px;
flex-shrink: 0;
gap: 6px;
overflow-x: auto;
border-top: 1px solid var(--line);
padding: 8px 12px;
}
.m-tile {
position: relative;
width: 72px;
height: 48px;
flex: 0 0 auto;
overflow: hidden;
border: 2px solid transparent;
border-radius: 6px;
background: #222;
}
.m-tile.on { border-color: var(--amber); }
.m-tile-pick { display: block; width: 100%; height: 100%; border: 0; padding: 0; background: transparent; }
.m-tile img { width: 100%; height: 100%; object-fit: cover; }
.m-tile-hid { display: grid; height: 100%; place-items: center; color: var(--muted); font-size: 10px; }
.m-tile-eye, .m-tile-trash {
position: absolute;
top: 2px;
right: 2px;
display: grid;
width: 16px;
height: 16px;
place-items: center;
border: 0;
border-radius: 3px;
background: #000a;
font-size: 10px;
}
.m-tile-trash { right: 20px; }
.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-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-strip-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid var(--line);
background: var(--panel);
}
.m-strip-actions button {
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--text);
padding: 4px 8px;
font-size: 12px;
}
.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-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
</style>
+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 }
}
+48
View File
@@ -0,0 +1,48 @@
# 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 requires Start still → `images.image_1`; prompt slots use `<image1>` / `<image2>`. If the user omits `<image1>`, the runner prepends `Keep the subject in <image1>.`
- 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.
- 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`.
+9 -15
View File
@@ -91,11 +91,11 @@
{{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }} {{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
</button> </button>
<button <button
v-if="hasHiddenThumbs" v-if="showPrivacyToggles && entries.length"
type="button" type="button"
class="rounded-full border px-2.5 py-1 text-[11px]" 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'" :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" @click="toggleRevealAll"
> >
<span class="inline-flex items-center gap-1.5"> <span class="inline-flex items-center gap-1.5">
@@ -324,7 +324,7 @@
@click="focusPart(node.clip.id)" @click="focusPart(node.clip.id)"
> >
<span class="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-zinc-900"> <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 <img
v-else v-else
:src="partThumb(node.clip)" :src="partThumb(node.clip)"
@@ -354,7 +354,7 @@
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''" :class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
@click="focusPart(part.id)" @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 <img
v-else v-else
:src="partThumb(part)" :src="partThumb(part)"
@@ -828,24 +828,18 @@ function cardClass(entry: LibraryEntry) {
return selectMode.value ? 'border-amber-300/25' : 'border-white/10' 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) { 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 const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.parts[cardImageIndex(entry)]?.id
if (id && forceHiddenIds.value.includes(id)) return true 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() { function toggleRevealAll() {
revealAll.value = !revealAll.value revealAll.value = !revealAll.value
forceHiddenIds.value = [] forceHiddenIds.value = []
if (focused.value && itemHidden(focused.value)) { if (focused.value && focused.value.kind !== 'track') {
detailsHidden.value = !revealAll.value detailsHidden.value = !revealAll.value
} }
} }
@@ -996,7 +990,7 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) {
: entry.kind === 'track' : entry.kind === 'track'
? (entry.track?.id || '') ? (entry.track?.id || '')
: (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '') : (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '')
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value if (!sameCard) detailsHidden.value = thumbHidden(entry)
cancelRename() cancelRename()
} }
+2 -2
View File
@@ -961,8 +961,8 @@ async function loadLog(entry: GenerationLogEntry) {
kind: entry.kind, kind: entry.kind,
payload: entry.payload payload: entry.payload
})) }))
if (entry.kind === 'music') { if (entry.payload?.studio2Id || entry.payload?.studio2Request || entry.kind === 'music') {
await navigateTo('/music') await navigateTo(entry.kind === 'music' ? '/studio-2?task=music' : '/studio-2')
} else { } else {
await navigateTo('/') await navigateTo('/')
} }
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f3f1ea" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 3l18 18"/>
<path d="M10.6 10.6A3 3 0 0 0 12 15a3 3 0 0 0 2.4-4.4"/>
<path d="M9.9 5.1A11 11 0 0 1 12 5c6.5 0 10 7 10 7a18 18 0 0 1-3.2 3.8"/>
<path d="M6.1 6.1C3.7 7.8 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.2-.9"/>
</svg>

After

Width:  |  Height:  |  Size: 441 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f3f1ea" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4" y="11" width="16" height="10" rx="2"/>
<path d="M8 11V8a4 4 0 0 1 8 0v3"/>
</svg>

After

Width:  |  Height:  |  Size: 302 B

+225
View File
@@ -0,0 +1,225 @@
# 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"
}
}
Write-Host ""
Write-Host "Done. Restart Comfy only when /status shows gpu.busy=false." -ForegroundColor Cyan
Write-Host "ComfyRoot=$root"
Write-Host "ModelsRoot=$models"
+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())
+6 -4
View File
@@ -4,19 +4,21 @@ import { resolveRequestSize } from '../../utils/studio2/size';
import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras'; import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras';
import { saveRecord, readRecord } from '../../utils/studio2/store'; import { saveRecord, readRecord } from '../../utils/studio2/store';
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue'; import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue';
import { isXaigenStudio } from '~/utils/videoModels';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event), raw = await readBody(event); const { owner } = assertLibraryOwner(event), raw = await readBody(event);
const xaigen = isXaigenStudio();
let request: any; let request: any;
try { try {
request = validateRequest(raw, useRuntimeConfig().public.studio === 'xaigen'); request = validateRequest(raw, xaigen);
} }
catch (e: any) { catch (e: any) {
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message }); throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
} }
const video = ['video', 'extend'].includes(request.mode); const video = ['video', 'extend'].includes(request.mode);
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image'); request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen'); request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, xaigen);
if (!video) if (!video && request.engine !== 'qwen21')
assertImageV2LoraStack(request.settings.loraStack, request.engine); assertImageV2LoraStack(request.settings.loraStack, request.engine);
assertFolderAccess(event, request.folderId); 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)) for (const id of [request.identityStillId, request.imageAId, request.imageBId, request.maskId, request.endStillId, ...request.guides.map((g: any) => g.stillId)].filter(Boolean))
@@ -26,7 +28,7 @@ export default defineEventHandler(async (event) => {
await resolveRequestSize(owner,request); 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' }; const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
saveRecord(record); 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 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 }); saveRecord({ ...readRecord(owner, id), queueId: row.id });
kickStudioQueue(); kickStudioQueue();
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height }; return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
+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" }
}
}
+34 -1
View File
@@ -139,11 +139,44 @@ export async function updateGenerationLogByStudioJob(
export async function requeueGenerationLog(owner: string, id: string) { export async function requeueGenerationLog(owner: string, id: string) {
const entry = getGenerationLogEntry(owner, id) const entry = getGenerationLogEntry(owner, id)
const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue') 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({ const studio = await addStudioJob({
ownerKey: owner, ownerKey: owner,
familyId: crypto.randomUUID(), familyId: crypto.randomUUID(),
kind: entry.kind, kind: entry.kind,
payload: structuredClone(entry.payload) payload
}) })
await kickStudioQueue() await kickStudioQueue()
return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) } return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) }
+208 -41
View File
@@ -8,13 +8,21 @@ import { queueSeeds } from '~/shared/studio2/seed.mjs';
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'; import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import template from '../../assets/studio2_minimax_native.json'; 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 { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs'; import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
import { resolveQwen21Size } from '~/shared/studio2/qwen21-size.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 { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue'; import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
import { readRecord, saveRecord, studio2Root, records } from './store'; import { readRecord, saveRecord, studio2Root, records } from './store';
import { ensureComfyReady } from '../comfyLifecycle'; import { ensureComfyReady } from '../comfyLifecycle';
import { comfyFetch, queuePrompt, fetchHistory, extractVideo } from '../comfy'; import { comfyFetch, queuePrompt, fetchHistory, extractVideo, freeComfyVram } from '../comfy';
import { extractEditedImage } from '../imageComfy'; import { extractEditedImage } from '../imageComfy';
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2'; import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras'; import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
@@ -34,10 +42,111 @@ 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 // 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. // extracted last frame). Fitting these every hop is what destroys identity across chained extends.
const NEVER_FIT = new Set(['hero', 'start']); 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
}
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 (edit && !q.imageAId) throw new Error('Choose the source still.')
update(r, 'enhancing')
emitJob(job, { type: 'status', message: 'Enhancing prompt…' })
let prompt = String(q.compiledPrompt || '')
if (edit && !/<image1>/i.test(prompt))
prompt = `Keep the subject in <image1>. ${prompt}`.trim()
const graph = structuredClone(edit ? qwen21PeEditTemplate : qwen21PeT2iTemplate)
graph['9'].inputs.prompt = prompt
graph['9'].inputs.seed = s.seed
if (edit) {
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'
saveRecord(r)
const queued = await queuePrompt(graph, job.clientId)
r.pePromptId = queued.prompt_id
saveRecord(r)
const history = await waitPromptHistory(r, job, queued.prompt_id)
const result = harvestQwen21Pe(history, queued.prompt_id, edit)
if (!result.parse_ok || !result.positive_prompt)
throw new Error('Prompt enhance failed to parse a rewrite. Try again or turn Enhance prompt off.')
r.request.promptRaw = q.compiledPrompt
r.request.compiledPrompt = result.positive_prompt
r.request.enhance = {
wh_ratio: result.wh_ratio || '',
...(edit ? { ratio_follow: result.ratio_follow || '' } : {}),
parse_ok: true,
thinking: result.thinking || ''
}
r.pePromptId = ''
saveRecord(r)
await freeComfyVram()
}
async function upload(r: any, name: string, data: Buffer): Promise<string> { async function upload(r: any, name: string, data: Buffer): Promise<string> {
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`; const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
let fitted = false; 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); data = await fitStill(data, r.request.settings.width, r.request.settings.height);
fitted = true; fitted = true;
} }
@@ -126,6 +235,89 @@ async function prepareGraph(r: any) {
r.graphId = 'studio2_minimax_native.json'; 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') {
if (!a) throw new Error('Choose the source still.')
let prompt = String(q.compiledPrompt || '')
if (!/<image1>/i.test(prompt))
prompt = `Keep the subject in <image1>. ${prompt}`.trim()
graph = structuredClone(turbo ? qwen21EditTurboTemplate : qwen21EditTemplate)
graph['10'].inputs.image = a
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).
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
}
}
else { else {
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode); const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null; const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
@@ -171,56 +363,31 @@ async function run(r: any, job: Job) {
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request); r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
update(r, 'waking'); update(r, 'waking');
await ensureComfyReady(message => emitJob(job, { type: 'status', message })); await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
if (job.status === 'cancelled')
throw new Error('Cancelled');
await runQwen21PromptEnhance(r, job);
if (job.status === 'cancelled') if (job.status === 'cancelled')
throw new Error('Cancelled'); throw new Error('Cancelled');
const graph = await prepareGraph(r); const graph = await prepareGraph(r);
if (job.status === 'cancelled') if (job.status === 'cancelled')
throw new Error('Cancelled'); throw new Error('Cancelled');
update(r, 'submitting'); 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; job.promptId = queued.prompt_id;
r.promptId = queued.prompt_id; r.promptId = queued.prompt_id;
r.renderStartedAt = Date.now(); r.renderStartedAt = Date.now();
update(r, 'rendering'); update(r, 'rendering');
} }
let history: any, missingSince = 0; const history = await waitPromptHistory(r, job, r.promptId);
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 messages = history[r.promptId]?.status?.messages || []; const messages = history[r.promptId]?.status?.messages || [];
const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp; const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp;
const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp; const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp;
+1 -1
View File
@@ -21,5 +21,5 @@ export async function resolveRequestSize(owner: string, request: any) {
} }
} }
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.') 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))
} }
+2
View File
@@ -17,6 +17,8 @@ export type StudioJobKind = 'video' | 'edit' | 'music'
export interface StudioJobPayload { export interface StudioJobPayload {
studio2Id?: string 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 } upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string }
prompt: string prompt: string
promptMid?: string promptMid?: string
+25 -9
View File
@@ -1,6 +1,8 @@
import {normalizeStyles,stylePrompt} from './styles.mjs' import {normalizeStyles,stylePrompt} from './styles.mjs'
import { queueSeeds } from './seed.mjs' import { queueSeeds } from './seed.mjs'
import { sectionsFrom, compileSections } from './prompt.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'
export { sectionsFrom } from './prompt.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 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.' 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 +15,7 @@ export function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
export function compilePrompt(value, video = false, locks = {}) { export function compilePrompt(value, video = false, locks = {}) {
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n') if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
const hasImage=!!(locks.imageAId || locks.identityStillId) 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' 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), return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '', attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
@@ -31,23 +33,31 @@ export function validateRequest(raw, xaigen = false) {
const mode = raw.mode || 'generate', engine = raw.engine || 'flux' const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.') if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
const video = ['video','extend'].includes(mode) 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 === '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 === '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.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 (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
if (!raw.folderId) fail('Choose a folder.') 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 (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) fail('Choose the source still.')
if ((video || ['edit','compose','iterate'].includes(mode)) && engine !== 'qwen21' && (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 (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.') 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 or Previous clip.')
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''}) const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
const promptSections = video ? allSections : {action:allSections.action} const promptSections = video ? allSections : {action:allSections.action}
if (!promptSections.action.trim()) fail('Enter an action prompt.') 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 } 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}.`) const qwen = engine === 'qwen21'
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.') 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) const duration=number('duration',5,1,15)
if (video) { if (video) {
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.') if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
@@ -55,10 +65,16 @@ export function validateRequest(raw, xaigen = false) {
} }
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5 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)) 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 || '',
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''}, startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw), promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
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:[] }, 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:qwen?[]:(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} }), 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}}) } 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 {restoreLoras,loraUse} from './lora-memory.mjs'
import { restoreInput } from './contracts.mjs' import { restoreInput, sectionsFrom } 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'}] import { normalizeStyles } from './styles.mjs'
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) { 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]} 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 (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} 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)) 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)} 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) { export function bindClip(form, clip, xaigen = false, memory) {
if (!clip?.id) return form if (!clip?.id) return form
const saved = clip.studio2 || clip.job || {} const saved = clip.studio2 || clip.job || {}
const settings = saved.settings || clip.settings || {} const settings = saved.settings || clip.settings || {}
const next = {...form, startClipId:clip.id, mode:'extend', settings:{...form.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']) { for (const key of ['duration','width','height','fps','loraStack','turbo','steps','cfg']) {
if (settings[key] == null) continue if (settings[key] == null) continue
try { next.settings[key] = typeof structuredClone === 'function' ? structuredClone(settings[key]) : JSON.parse(JSON.stringify(settings[key])) } 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 restored = restoreInput(item)
const next = {...form,...restored,settings:{...form.settings,...restored.settings}} const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
next.settings.seedMode='random' 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.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
next.imageAId=item.id next.imageAId=item.id
next.startClipId='' next.startClipId=''
@@ -58,11 +78,77 @@ export function changeSource(form) {
export function useAsHero(form, item) { return {...form,identityStillId:item.id} } export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
export function useAsSource(form, item) { return {...form,imageAId: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) { export function restoreAll(form,item,xaigen=false,memory) {
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}} 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'} if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit' 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.imageAId=item.id
next.startClipId='' next.startClipId=''
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory) 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) { export function loraUse(form) {
const video=['video','extend'].includes(form.mode) 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). // One controller per bench. Storage is optional (SSR/private browsing still works).
+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
+2 -2
View File
@@ -8,8 +8,8 @@ export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)} return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)}
} }
if(health.yue2?.busy)return {label:'Generating',tone:'busy',detail:'YuE2 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)) const job=jobs.find(j=>['waking','enhancing','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'} 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?.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?.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'} if(health.comfy?.asleep)return {label:'Sleeping',tone:'sleeping',detail:'Wakes when a job starts'}
+9 -1
View File
@@ -1,5 +1,13 @@
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 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 function normalizeStyles(value={}) { const known=Object.values(styleGroups).flat();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 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)))]
// 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 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(', ')} export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)} export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
+64 -1
View File
@@ -3,10 +3,11 @@ import assert from 'node:assert/strict'
import {readFileSync} from 'node:fs' import {readFileSync} from 'node:fs'
import ts from 'typescript' import ts from 'typescript'
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs' import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
import {useAsHero,useAsSource,restoreAll} from '../shared/studio2/input.mjs' import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs'
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs' import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs' import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
import {cachedLoras} from '../shared/studio2/lora-cache.mjs' import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
test('deleted library files never return from job outputs; selection falls back or clears',()=>{ 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]}] const deleted={id:'gone',folderId:'f'},keep={id:'keep',folderId:'f',createdAt:2};const jobs=[{request:{folderId:'f'},outputs:[deleted]}]
@@ -28,6 +29,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') const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
assert.match(src,/aria-label="Current family outputs"/) assert.match(src,/aria-label="Current family outputs"/)
assert.match(src,/preview hidden/) assert.match(src,/preview hidden/)
assert.match(src,/Reveal all/)
assert.match(src,/pickerRevealAll/)
assert.doesNotMatch(src,/Hide input stills/) assert.doesNotMatch(src,/Hide input stills/)
assert.doesNotMatch(src,/Hide output preview/) assert.doesNotMatch(src,/Hide output preview/)
}) })
@@ -74,3 +77,63 @@ test('image chips compile positive and negative independently; video chips do no
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/) 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(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
}) })
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'}}),/source still/)
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/)
})
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')
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Edit source/)
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(src,/\bTurbo\b/);assert.match(src,/form\.turbo/)
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/)
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})
})
+5 -1
View File
@@ -283,7 +283,7 @@ export function kleinLoraOnKreaMessage(name: string) {
return imageLoraEngineMismatchMessage(name, 'krea') return imageLoraEngineMismatchMessage(name, 'krea')
} }
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea' export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea' | 'qwen21'
export function isSystemGraphLora(name: string) { export function isSystemGraphLora(name: string) {
const id = loraIdentityKey(name) const id = loraIdentityKey(name)
@@ -301,16 +301,19 @@ export function inferLoraUse(name: string): LoraUse[] | null {
if (/ltx/.test(value)) uses.push('ltx') if (/ltx/.test(value)) uses.push('ltx')
if (/klein|flux/.test(value)) uses.push('flux') if (/klein|flux/.test(value)) uses.push('flux')
if (/krea/.test(value)) uses.push('krea') if (/krea/.test(value)) uses.push('krea')
if (/qwen/.test(value)) uses.push('qwen21')
return uses.length ? uses : null return uses.length ? uses : null
} }
export function loraMatchesUse(name: string, use: LoraUse) { export function loraMatchesUse(name: string, use: LoraUse) {
if (use === 'qwen21') return false
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use) return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
} }
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */ /** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) { export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
if (use === 'ltx' && !xaigen) return [] if (use === 'ltx' && !xaigen) return []
if (use === 'qwen21') return []
return filterLoraStackForStudio(stack, xaigen).filter(item => { return filterLoraStackForStudio(stack, xaigen).filter(item => {
const uses = inferLoraUse(item.name) const uses = inferLoraUse(item.name)
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use)) return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
@@ -318,6 +321,7 @@ export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: bool
} }
export function filterLorasForUse(names: string[], use: LoraUse) { export function filterLorasForUse(names: string[], use: LoraUse) {
if (use === 'qwen21') return []
return names.filter(name => loraMatchesUse(name, use)) return names.filter(name => loraMatchesUse(name, use))
} }