Compare commits

...
16 Commits
Author SHA1 Message Date
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
36 changed files with 3250 additions and 422 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,
[data-display='classic'],
[data-display='studio'] {
--display-bg: #09090b;
--display-fg: #e4e4e7;
--display-muted: #a1a1aa;
--display-border: rgba(255, 255, 255, 0.1);
--display-amber: #fbbf24;
--display-amber-fg: #09090b;
--display-radius: 8px;
--display-focus: rgba(252, 211, 77, 0.5);
--bg: #0b0b0d;
--panel: #141416;
--panel-raise: #1a1a1d;
--line: #2a2a2e;
--text: #f3f1ea;
--muted: #9a9588;
--amber: #f5a524;
--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'] {
--chrome-gap: 0.5rem;
--rail-h: 2.25rem;
--card-pad: 0.75rem;
--display-type-title: 1.75rem;
--display-type-body: 0.875rem;
--display-viewer-radius: 0.75rem;
--field-gap: 8px;
--type: 13px;
}
[data-display='studio'] {
--chrome-gap: 1rem;
--rail-h: 3rem;
--card-pad: 1rem;
--display-type-title: 1.875rem;
--display-type-body: 0.9375rem;
--display-viewer-radius: 8px;
[data-display] {
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: var(--type);
}
[data-display] .display-title {
font-size: var(--display-type-title);
/* Skin toggles */
[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 {
font-size: var(--display-type-body);
}
[data-display] .display-stack {
.bench-root {
display: flex;
height: 100dvh;
max-height: 100dvh;
flex-direction: column;
gap: var(--chrome-gap);
overflow: hidden;
background: var(--bg);
color: var(--text);
}
[data-display] .display-card {
border-radius: var(--display-radius);
border: 1px solid var(--display-border);
padding: var(--card-pad);
.bench-root[data-mobile="1"] {
position: relative;
}
[data-display] .display-viewer {
border-radius: var(--display-viewer-radius);
.bench-header {
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 {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: var(--rail-h);
.bench-brand {
font-weight: 650;
letter-spacing: 0.02em;
}
[data-display] .display-rail__seg {
min-height: var(--rail-h);
border-radius: var(--display-radius);
border: 1px solid var(--display-border);
padding: 0.5rem 1rem;
font-size: var(--display-type-body);
color: var(--display-fg);
.bench-crumb {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-size: var(--type-sm);
}
.bench-crumb select {
max-width: 14rem;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
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 {
box-shadow: 0 0 0 2px var(--display-focus);
.bench-header-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
}
[data-display] .display-rail__seg[aria-pressed='true'],
[data-display] .display-rail__seg[data-active='true'] {
border-color: var(--display-amber);
background: var(--display-amber);
color: var(--display-amber-fg);
.bench-nav {
display: flex;
gap: 14px;
font-size: var(--type-sm);
color: var(--muted);
}
[data-display] .display-rail__seg:disabled,
[data-display] .display-rail__seg[aria-disabled='true'] {
opacity: 0.55;
.bench-nav a {
color: inherit;
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;
}
[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;
padding: 0.625rem 1.25rem;
line-height: 1.3;
}
[data-display='classic'] .display-rail__seg {
min-height: 2.25rem;
padding: 0.375rem 0.75rem;
font-weight: 500;
.job-meta {
margin-top: 4px;
color: var(--muted);
font-size: var(--type-sm);
}
[data-display] .display-cta {
border-radius: var(--display-radius);
background: var(--display-amber);
color: var(--display-amber-fg);
font-weight: 600;
.job-status {
display: flex;
margin-top: 8px;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: var(--type-sm);
}
[data-display] .display-cta:focus-visible {
box-shadow: 0 0 0 2px var(--display-focus);
.job-status .left {
display: inline-flex;
align-items: center;
gap: 6px;
}
[data-display] .display-control {
border-radius: var(--display-radius);
.job-status .dot {
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
v-for="(item, index) in model"
:key="item.name"
class="rounded-xl border bg-zinc-950/80"
class="rounded-lg border border-[#2A2A2E] bg-[#1A1A1D] p-2.5"
:class="[
compact ? 'px-2 py-1.5' : 'px-3 py-2',
dragOverIndex === index ? 'border-amber-300/50' : 'border-white/10',
dragOverIndex === index ? 'border-amber-300/50' : '',
draggingIndex === index ? 'opacity-60' : ''
]"
@dragover.prevent="onDragOver(index)"
@drop.prevent="onDrop(index)"
@dragleave="onDragLeave(index)"
>
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 flex-1 items-start gap-2">
<button
v-if="model.length > 1"
type="button"
class="shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
class="mt-0.5 shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
:disabled="disabled"
draggable="true"
title="Drag to reorder"
@@ -31,61 +30,91 @@
>
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
</button>
<span class="min-w-0"><span class="block truncate text-zinc-200">{{ labelFor(item.name) }}</span><span v-if="filenameSubtitle" class="block truncate text-[11px] text-zinc-500" :title="item.name">{{ item.name }}</span></span>
<LoraTriggerTip
:text="triggerFor(item.name)"
:aria-label="'Activation for ' + labelFor(item.name)"
@insert="emit('insert-trigger', $event)"
@save="saveTrigger(item.name, $event)"
/>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-1.5">
<span class="block truncate font-medium text-[#F3F1EA]" :title="labelFor(item.name)">{{ labelFor(item.name) }}</span>
<LoraTriggerTip
:text="triggerFor(item.name)"
:aria-label="'Activation for ' + labelFor(item.name)"
@insert="emit('insert-trigger', $event)"
@save="saveTrigger(item.name, $event)"
/>
</span>
<span v-if="filenameSubtitle" class="mt-0.5 block truncate text-[11px] text-[#9A9588]" :title="item.name">{{ stemFor(item.name) }}</span>
</span>
</div>
<button
type="button"
class="shrink-0 text-[11px] text-zinc-500 hover:text-red-300 disabled:opacity-50"
class="shrink-0 text-[11px] text-[#9A9588] hover:text-red-300 disabled:opacity-50"
:disabled="disabled"
@click="removeAt(index)"
>
Remove
</button>
</div>
<div class="mt-1.5 flex flex-wrap gap-3">
<Thumbwheel
:model-value="item.strengthModel"
label="Model"
:lock-key="`lora-model:${item.name}`"
:min="LORA_STRENGTH_MIN"
:max="LORA_STRENGTH_MAX"
:step="LORA_STRENGTH_STEP"
:fine-step="LORA_STRENGTH_STEP"
:coarse-step="0.25"
:digits="2"
compact
:disabled="disabled"
@update:model-value="setStrength(index, 'strengthModel', $event)"
/>
<Thumbwheel
:model-value="item.strengthClip"
label="CLIP"
:lock-key="`lora-clip:${item.name}`"
:min="LORA_STRENGTH_MIN"
:max="LORA_STRENGTH_MAX"
:step="LORA_STRENGTH_STEP"
:fine-step="LORA_STRENGTH_STEP"
:coarse-step="0.25"
:digits="2"
compact
:disabled="disabled || !clipStrength"
@update:model-value="setStrength(index, 'strengthClip', $event)"
/>
<div class="mt-2 space-y-1.5">
<label class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]">
<span>{{ clipStrength ? 'Model' : 'Strength' }}</span>
<input
type="range"
class="lora-strength-range min-w-0"
:min="0"
:max="2"
:step="LORA_STRENGTH_STEP"
:disabled="disabled"
:value="displayStrength(item.strengthModel)"
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' for ' + labelFor(item.name)"
@input="onRange(index, 'strengthModel', $event)"
>
<input
type="number"
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
:min="0"
:max="2"
:step="LORA_STRENGTH_STEP"
:disabled="disabled"
:value="displayStrength(item.strengthModel).toFixed(2)"
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' value'"
@change="onNumber(index, 'strengthModel', $event)"
>
</label>
<label
v-if="clipStrength"
class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]"
>
<span>CLIP</span>
<input
type="range"
class="lora-strength-range min-w-0"
:min="0"
:max="2"
:step="LORA_STRENGTH_STEP"
:disabled="disabled"
:value="displayStrength(item.strengthClip)"
:aria-label="'CLIP for ' + labelFor(item.name)"
@input="onRange(index, 'strengthClip', $event)"
>
<input
type="number"
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
:min="0"
:max="2"
:step="LORA_STRENGTH_STEP"
:disabled="disabled"
:value="displayStrength(item.strengthClip).toFixed(2)"
:aria-label="'CLIP value'"
@change="onNumber(index, 'strengthClip', $event)"
>
</label>
</div>
</li>
</ul>
<p v-if="!clipStrength && model.length" class="mt-1 text-[11px] text-zinc-500">CLIP unused on this graph — model strength only.</p>
<select
v-if="model.length < LORA_STACK_MAX"
class="w-full rounded-xl border border-white/10 bg-zinc-950 outline-none ring-amber-300/40 focus:ring-2 disabled:opacity-50"
class="w-full rounded-lg border border-[#2A2A2E] bg-[#0B0B0D] text-[#F3F1EA] outline-none ring-[#F5A524]/40 focus:ring-2 disabled:opacity-50"
:class="[
compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm',
compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
model.length ? 'mt-2' : ''
]"
:disabled="disabled || !availableNames.length"
@@ -103,13 +132,55 @@
</div>
</template>
<style scoped>
.lora-strength-range {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 999px;
background: #2a2a2e;
outline: none;
}
.lora-strength-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 999px;
background: #f5a524;
border: 0;
cursor: pointer;
}
.lora-strength-range::-moz-range-track {
height: 6px;
border-radius: 999px;
background: #2a2a2e;
}
.lora-strength-range::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 999px;
background: #f5a524;
border: 0;
cursor: pointer;
}
.lora-strength-range:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.lora-strength-range::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: #2a2a2e;
}
</style>
<script setup lang="ts">
import {
LORA_STACK_MAX,
LORA_STRENGTH_MAX,
LORA_STRENGTH_MIN,
LORA_STRENGTH_DEFAULT,
LORA_STRENGTH_STEP,
clampLoraStrength,
loraDisplayName,
loraFriendlyName,
loraIdentityKey,
loraMatchesImageEngine,
@@ -120,6 +191,9 @@ import {
type LoraStackItem
} from '~/utils/loras'
const UI_MIN = 0
const UI_MAX = 2
const props = withDefaults(defineProps<{
names: string[]
filenameSubtitle?: boolean
@@ -175,6 +249,17 @@ const selectPlaceholder = computed(() => {
return model.value.length ? props.addLabel : props.emptyLabel
})
function clampUi(raw: unknown) {
const value = Number(raw)
if (!Number.isFinite(value)) return LORA_STRENGTH_DEFAULT
const snapped = Math.round(value / LORA_STRENGTH_STEP) * LORA_STRENGTH_STEP
return Math.min(UI_MAX, Math.max(UI_MIN, Math.round(snapped * 100) / 100))
}
function displayStrength(value: unknown) {
return clampUi(value ?? LORA_STRENGTH_DEFAULT)
}
function onAdd(event: Event) {
const name = (event.target as HTMLSelectElement).value
;(event.target as HTMLSelectElement).value = ''
@@ -186,7 +271,11 @@ function addLora(name: string) {
if (!item || model.value.length >= LORA_STACK_MAX) return
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
if (taken.has(loraIdentityKey(item.name))) return
model.value = [...model.value, item]
model.value = [...model.value, {
...item,
strengthModel: clampUi(item.strengthModel ?? LORA_STRENGTH_DEFAULT),
strengthClip: clampUi(item.strengthClip ?? LORA_STRENGTH_DEFAULT)
}]
}
function removeAt(index: number) {
@@ -234,8 +323,22 @@ function onDragEnd() {
}
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
const next = clampLoraStrength(value)
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item)
const next = clampUi(value)
model.value = model.value.map((item, i) => {
if (i !== index) return item
if (!props.clipStrength) {
return { ...item, strengthModel: next, strengthClip: next }
}
return { ...item, [key]: next }
})
}
function onRange(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
setStrength(index, key, Number((event.target as HTMLInputElement).value))
}
function onNumber(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
setStrength(index, key, Number((event.target as HTMLInputElement).value))
}
function triggerFor(name: string) {
@@ -246,6 +349,10 @@ function labelFor(name: string) {
return loraFriendlyName(name, props.aliases)
}
function stemFor(name: string) {
return loraDisplayName(name)
}
function saveTrigger(name: string, trigger: string) {
emit('update-trigger', name, trigger)
}
+2 -2
View File
@@ -34,7 +34,7 @@
@click="setDisplayMode('classic')"
>
<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
type="button"
@@ -46,7 +46,7 @@
@click="setDisplayMode('studio')"
>
<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>
</div>
</section>
+18 -2
View File
@@ -1,8 +1,24 @@
<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>
<script setup lang="ts">
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'])
</script>
File diff suppressed because it is too large Load Diff
+582
View File
@@ -0,0 +1,582 @@
<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>
<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 }
}
+44
View File
@@ -0,0 +1,44 @@
# 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 canvas **1024×1024** (aspect 16:9 / 9:16 / 1:1 → long-edge ~1024 via `TextEncodeQwenImage21` resolution; multiples of 32). Edit with aspect `auto` follows `image_1` aspect at ~1024.
- No hero / locks / Klein LoRA stack on this engine.
Graphs: `server/assets/studio2_qwen21_t2i.json`, `server/assets/studio2_qwen21_edit.json`.
+9 -15
View File
@@ -91,11 +91,11 @@
{{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
</button>
<button
v-if="hasHiddenThumbs"
v-if="showPrivacyToggles && entries.length"
type="button"
class="rounded-full border px-2.5 py-1 text-[11px]"
:class="revealAll ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
:title="revealAll ? 'Hide flagged thumbnails' : 'Reveal all hidden thumbnails'"
:title="revealAll ? 'Hide all thumbnails' : 'Reveal all thumbnails'"
@click="toggleRevealAll"
>
<span class="inline-flex items-center gap-1.5">
@@ -324,7 +324,7 @@
@click="focusPart(node.clip.id)"
>
<span class="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-zinc-900">
<span v-if="node.clip.hideThumbnail" class="sensitive-glow block h-full w-full" />
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block h-full w-full" />
<img
v-else
:src="partThumb(node.clip)"
@@ -354,7 +354,7 @@
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
@click="focusPart(part.id)"
>
<span v-if="('hideThumbnail' in part && part.hideThumbnail) || ('hideInput' in part && part.hideInput)" class="sensitive-glow block aspect-square w-full" />
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block aspect-square w-full" />
<img
v-else
:src="partThumb(part)"
@@ -828,24 +828,18 @@ function cardClass(entry: LibraryEntry) {
return selectMode.value ? 'border-amber-300/25' : 'border-white/10'
}
function itemHidden(entry: LibraryEntry) {
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
if (entry.kind === 'track') return false
return Boolean(entry.stillGroup?.parts[cardImageIndex(entry)]?.hideInput)
}
function thumbHidden(entry: LibraryEntry) {
if (!showPrivacyToggles.value || entry.kind === 'track') return false
const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.parts[cardImageIndex(entry)]?.id
if (id && forceHiddenIds.value.includes(id)) return true
return itemHidden(entry) && !revealAll.value
// Session cover for the whole grid — not limited to individually flagged items.
return !revealAll.value
}
const hasHiddenThumbs = computed(() => entries.value.some(entry => itemHidden(entry)))
function toggleRevealAll() {
revealAll.value = !revealAll.value
forceHiddenIds.value = []
if (focused.value && itemHidden(focused.value)) {
if (focused.value && focused.value.kind !== 'track') {
detailsHidden.value = !revealAll.value
}
}
@@ -996,7 +990,7 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) {
: entry.kind === 'track'
? (entry.track?.id || '')
: (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '')
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value
if (!sameCard) detailsHidden.value = thumbHidden(entry)
cancelRename()
}
+2 -2
View File
@@ -961,8 +961,8 @@ async function loadLog(entry: GenerationLogEntry) {
kind: entry.kind,
payload: entry.payload
}))
if (entry.kind === 'music') {
await navigateTo('/music')
if (entry.payload?.studio2Id || entry.payload?.studio2Request || entry.kind === 'music') {
await navigateTo(entry.kind === 'music' ? '/studio-2?task=music' : '/studio-2')
} else {
await navigateTo('/')
}
+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 { saveRecord, readRecord } from '../../utils/studio2/store';
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue';
import { isXaigenStudio } from '~/utils/videoModels';
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event), raw = await readBody(event);
const xaigen = isXaigenStudio();
let request: any;
try {
request = validateRequest(raw, useRuntimeConfig().public.studio === 'xaigen');
request = validateRequest(raw, xaigen);
}
catch (e: any) {
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
}
const video = ['video', 'extend'].includes(request.mode);
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen');
if (!video)
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, xaigen);
if (!video && request.engine !== 'qwen21')
assertImageV2LoraStack(request.settings.loraStack, request.engine);
assertFolderAccess(event, request.folderId);
for (const id of [request.identityStillId, request.imageAId, request.imageBId, request.maskId, request.endStillId, ...request.guides.map((g: any) => g.stillId)].filter(Boolean))
@@ -26,7 +28,7 @@ export default defineEventHandler(async (event) => {
await resolveRequestSize(owner,request);
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
saveRecord(record);
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
const 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 });
kickStudioQueue();
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" }
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"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" }
},
"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": ["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",
"images": ["20", 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) {
const entry = getGenerationLogEntry(owner, id)
const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue')
let payload = structuredClone(entry.payload)
if (payload?.studio2Id || payload?.studio2Request) {
const { saveRecord, readRecord } = await import('~/server/utils/studio2/store')
let request = payload.studio2Request
if (!request && payload.studio2Id) {
try { request = structuredClone(readRecord(owner, payload.studio2Id).request) }
catch { request = null }
}
if (!request) throw createError({ statusCode: 400, statusMessage: 'That Studio 2 job has no saved request to re-queue.' })
const studio2Id = crypto.randomUUID()
saveRecord({
id: studio2Id,
owner,
request: structuredClone(request),
state: 'waiting',
queuedAt: Date.now(),
familyId: crypto.randomUUID(),
outputs: [],
purgeResult: 'Not yet saved'
})
payload = {
...payload,
...structuredClone(request.settings || {}),
studio2Id,
studio2Request: structuredClone(request),
prompt: request.compiledPrompt || payload.prompt,
folderId: request.folderId || payload.folderId,
extensions: [],
referenceStillIds: [],
useIdentityRefs: false,
queueAutoRun: true
}
}
const studio = await addStudioJob({
ownerKey: owner,
familyId: crypto.randomUUID(),
kind: entry.kind,
payload: structuredClone(entry.payload)
payload
})
await kickStudioQueue()
return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) }
+64 -1
View File
@@ -8,8 +8,11 @@ import { queueSeeds } from '~/shared/studio2/seed.mjs';
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import template from '../../assets/studio2_minimax_native.json';
import qwen21Template from '../../assets/studio2_qwen21_t2i.json'
import qwen21EditTemplate from '../../assets/studio2_qwen21_edit.json';
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
import { resolveQwen21Size } from '~/shared/studio2/input.mjs';
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
import { readRecord, saveRecord, studio2Root, records } from './store';
@@ -37,7 +40,7 @@ const NEVER_FIT = new Set(['hero', 'start']);
async function upload(r: any, name: string, data: Buffer): Promise<string> {
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
let fitted = false;
if (!NEVER_FIT.has(name) && needsFit(data, r.request.settings.width, r.request.settings.height)) {
if (!NEVER_FIT.has(name) && r.request?.engine !== 'qwen21' && needsFit(data, r.request.settings.width, r.request.settings.height)) {
data = await fitStill(data, r.request.settings.width, r.request.settings.height);
fitted = true;
}
@@ -126,6 +129,66 @@ async function prepareGraph(r: any) {
r.graphId = 'studio2_minimax_native.json';
}
}
else if (q.engine === 'qwen21') {
if (!['generate', 'edit'].includes(q.mode))
throw new Error('Qwen 2.1 supports Generate and Edit.')
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(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 = stylePrompt(q.imageStyles, true)
graph['9'].inputs.resolution = 1024
if (s.aspect && s.aspect !== 'auto') {
graph['16'] = {
inputs: { width: s.width, height: s.height, batch_size: 1 },
class_type: 'EmptyLatentImage',
_meta: { title: 'Qwen edit canvas' }
}
graph['15'].inputs.latent_image = ['16', 0]
}
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 = s.aspect && s.aspect !== 'auto' ? 'qwen21 empty latent' : 'qwen21 edit latent'
r.sampleDenoise = null
r.heroReferenceAttached = false
r.graphId = 'studio2_qwen21_edit.json'
} else {
const size = resolveQwen21Size(s.aspect)
s.width = size.width
s.height = size.height
graph = structuredClone(qwen21Template)
graph['9'].inputs.prompt = q.compiledPrompt
graph['9'].inputs.negative_prompt = stylePrompt(q.imageStyles, true)
// TextEncodeQwenImage21 builds the 64-ch empty latent from resolution (square T2I).
graph['9'].inputs.resolution = Math.max(size.width, size.height)
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 textencode latent'
r.sampleDenoise = null
r.heroReferenceAttached = false
r.graphId = 'studio2_qwen21_t2i.json'
}
}
else {
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
+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.')
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 {
studio2Id?: string
/** Full Studio 2 validateRequest snapshot for Load / Re-queue. */
studio2Request?: Record<string, any>
upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string }
prompt: string
promptMid?: string
+15 -9
View File
@@ -13,7 +13,7 @@ export function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
export function compilePrompt(value, video = false, locks = {}) {
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
const hasImage=!!(locks.imageAId || locks.identityStillId)
const attached=hasImage && locks.engine !== 'krea'
const attached=hasImage && locks.engine !== 'krea' && locks.engine !== 'qwen21'
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
@@ -31,23 +31,29 @@ export function validateRequest(raw, xaigen = false) {
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
const video = ['video','extend'].includes(mode)
if (!(video ? ['minimax','ltx'] : ['flux','krea']).includes(engine)) fail('Engine does not support this task.')
if (!(video ? ['minimax','ltx'] : ['flux','krea','qwen21']).includes(engine)) fail('Engine does not support this task.')
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
if (engine === 'qwen21' && !['generate', 'edit'].includes(mode)) fail('Qwen 2.1 supports Generate and Edit.')
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
if (!raw.folderId) fail('Choose a folder.')
if ((video || ['edit','compose','iterate'].includes(mode)) && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
if (['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 === '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.')
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
const promptSections = video ? allSections : {action:allSections.action}
if (!promptSections.action.trim()) fail('Enter an action prompt.')
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
const qwen = engine === 'qwen21'
const defaultSteps = video ? 8 : qwen ? 25 : 24
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
const defaultWidth = qwen ? 1024 : 1344
const defaultHeight = qwen ? 1024 : 768
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?defaultSteps:1,0,2147483647))) fail(`Invalid ${key}.`)
for (const key of ['width','height']) if (number(key,key==='width'?defaultWidth:defaultHeight,256,4096)%32) fail('Dimensions must be multiples of 32.')
const duration=number('duration',5,1,15)
if (video) {
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
@@ -55,10 +61,10 @@ export function validateRequest(raw, xaigen = false) {
}
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, 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 || ''},
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',defaultWidth,256,4096),height:number('height',defaultHeight,256,4096),steps:number('steps',defaultSteps,1,100),cfg:number('cfg',defaultCfg,0,20),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} }),
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}}) }
}
+81 -4
View File
@@ -1,7 +1,20 @@
import {restoreLoras,loraUse} from './lora-memory.mjs'
import { restoreInput } from './contracts.mjs'
import { restoreInput, sectionsFrom } from './contracts.mjs'
import { normalizeStyles } from './styles.mjs'
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) {
export function resolveQwen21Size(aspect, source) {
const presets = {'16:9':[1024,576],'9:16':[576,1024],'1:1':[1024,1024]}
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
if (source?.width && source?.height) {
const ratio = source.width / source.height
const width = Math.max(32, Math.round(Math.sqrt(1024 * 1024 * ratio) / 32) * 32)
const height = Math.max(32, Math.round(Math.sqrt(1024 * 1024 / ratio) / 32) * 32)
return {width, height}
}
return {width:1024,height:1024}
}
export function resolveSize(aspect, source, fallback = {width:1344,height:768}, engine = '') {
if (engine === 'qwen21') return resolveQwen21Size(aspect, source)
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
@@ -35,7 +48,7 @@ export function restoreAsset(form, item, xaigen = false, memory) {
const restored = restoreInput(item)
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
next.settings.seedMode='random'
next.engine=restored.engine==='krea'?'krea':'flux'
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
next.imageAId=item.id
next.startClipId=''
@@ -58,11 +71,75 @@ export function changeSource(form) {
export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
export function useAsSource(form, item) { return {...form,imageAId:item.id} }
/** Hydrate the live bench from a saved Studio 2 request (Load). Does not enqueue. */
export function hydrateStudio2Job(form, saved, { stillIds = new Set(), clipIds = new Set(), xaigen = false, memory } = {}) {
const src = structuredClone(saved?.studio2 || saved || {})
const missing = []
const keepStill = (id, label) => {
const value = id || ''
if (!value) return ''
if (stillIds.has(value)) return value
missing.push(`${label} missing from library`)
return ''
}
const keepClip = (id, label) => {
const value = id || ''
if (!value) return ''
if (clipIds.has(value)) return value
missing.push(`${label} missing from library`)
return ''
}
const modeRaw = src.mode === 'iterate' ? 'edit' : src.mode
const mode = ['generate','edit','compose','refine','video','extend','music'].includes(modeRaw) ? modeRaw : 'edit'
let engine = src.engine === 'qwen21' ? 'qwen21' : src.engine === 'krea' ? 'krea' : src.engine === 'ltx' ? 'ltx' : src.engine === 'minimax' ? 'minimax' : 'flux'
if (['video','extend'].includes(mode)) engine = xaigen && engine === 'ltx' ? 'ltx' : 'minimax'
else if (engine === 'minimax' || engine === 'ltx') engine = 'flux'
const settings = structuredClone(src.settings || {})
const aspect = ['auto','16:9','9:16','1:1'].includes(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,
lockFace: src.lockFace !== false,
lockOutfit: src.lockOutfit !== false,
hideInput: !!src.hideInput,
folderId: src.folderId || form.folderId,
identityStillId: keepStill(src.identityStillId, 'hero'),
imageAId: keepStill(src.imageAId, 'start'),
imageBId: keepStill(src.imageBId, 'second still'),
maskId: keepStill(src.maskId, 'mask'),
startClipId: keepClip(src.startClipId, 'start clip'),
endStillId: keepStill(src.endStillId, 'end still'),
guides,
imageStyles: normalizeStyles(src.imageStyles || { positive: [], negative: [] }),
promptSections: sectionsFrom(src.promptSections || { action: src.compiledPrompt || src.prompt || '' }),
settings: {
...form.settings,
...settings,
aspect,
seed: settings.seed ?? form.settings?.seed ?? null,
seedMode: 'locked',
loraStack
}
}
return { form: restoreLoras(next, loraStack, xaigen, memory), missing: missing[0] || '', warnings: missing }
}
export function restoreAll(form,item,xaigen=false,memory) {
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
next.engine=restored.engine==='krea'?'krea':'flux'
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
next.imageAId=item.id
next.startClipId=''
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
+3 -1
View File
@@ -2,7 +2,9 @@ import {filterLoraStackForUse} from '../../utils/loras.ts'
export function loraUse(form) {
const video=['video','extend'].includes(form.mode)
return video ? (form.engine==='ltx'?'ltx':'minimax') : (form.engine==='krea'?'krea':'flux')
if (video) return form.engine==='ltx'?'ltx':'minimax'
if (form.engine==='qwen21') return 'qwen21'
return form.engine==='krea'?'krea':'flux'
}
// One controller per bench. Storage is optional (SSR/private browsing still works).
+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 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 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)}
+33 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import {readFileSync} from 'node:fs'
import ts from 'typescript'
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
import {useAsHero,useAsSource,restoreAll} from '../shared/studio2/input.mjs'
import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs'
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
@@ -28,6 +28,8 @@ test('hidden xAIGen canvas and family render no image or video elements',async()
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
assert.match(src,/aria-label="Current family outputs"/)
assert.match(src,/preview hidden/)
assert.match(src,/Reveal all/)
assert.match(src,/pickerRevealAll/)
assert.doesNotMatch(src,/Hide input stills/)
assert.doesNotMatch(src,/Hide output preview/)
})
@@ -74,3 +76,33 @@ test('image chips compile positive and negative independently; video chips do no
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'])
})
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,[])
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/)
})
+5 -1
View File
@@ -283,7 +283,7 @@ export function kleinLoraOnKreaMessage(name: string) {
return imageLoraEngineMismatchMessage(name, 'krea')
}
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea'
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea' | 'qwen21'
export function isSystemGraphLora(name: string) {
const id = loraIdentityKey(name)
@@ -301,16 +301,19 @@ export function inferLoraUse(name: string): LoraUse[] | null {
if (/ltx/.test(value)) uses.push('ltx')
if (/klein|flux/.test(value)) uses.push('flux')
if (/krea/.test(value)) uses.push('krea')
if (/qwen/.test(value)) uses.push('qwen21')
return uses.length ? uses : null
}
export function loraMatchesUse(name: string, use: LoraUse) {
if (use === 'qwen21') return false
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
}
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
if (use === 'ltx' && !xaigen) return []
if (use === 'qwen21') return []
return filterLoraStackForStudio(stack, xaigen).filter(item => {
const uses = inferLoraUse(item.name)
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) {
if (use === 'qwen21') return []
return names.filter(name => loraMatchesUse(name, use))
}