Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c5458c87 | ||
|
|
c46e827c68 | ||
|
|
a51118d92a | ||
|
|
99b61e67d7 | ||
|
|
30be4bd5b0 | ||
|
|
ecc4c69a8b | ||
|
|
17cbc99f8f | ||
|
|
95873744cd | ||
|
|
2842855c7b | ||
|
|
e0292c8d2a | ||
|
|
bb27af506e | ||
|
|
df5ef0949e | ||
|
|
206984b6ec | ||
|
|
52a01e0adc | ||
|
|
8609ba24a4 | ||
|
|
776d585345 | ||
|
|
608f7d8d47 | ||
|
|
68731916ca | ||
|
|
08d507c491 | ||
|
|
d65420f34a | ||
|
|
d733e80881 | ||
|
|
dccfafa08b | ||
|
|
589791cdb8 | ||
|
|
c0289afa0e | ||
|
|
a9799e4920 | ||
|
|
52bd6cf4a8 |
@@ -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.
|
||||||
|
After Width: | Height: | Size: 285 KiB |
|
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;
|
||||||
|
}
|
||||||
@@ -1,118 +1,724 @@
|
|||||||
/* Studio 2 display skins — Classic | Studio. Layout tokens only; palette matches main.css. */
|
/* Pixel grid tokens — Classic | Studio. Match TARGET-classic-layout.png */
|
||||||
|
|
||||||
:root,
|
:root,
|
||||||
[data-display='classic'],
|
|
||||||
[data-display='studio'] {
|
[data-display='studio'] {
|
||||||
--display-bg: #09090b;
|
--bg: #0b0b0d;
|
||||||
--display-fg: #e4e4e7;
|
--panel: #141416;
|
||||||
--display-muted: #a1a1aa;
|
--panel-raise: #1a1a1d;
|
||||||
--display-border: rgba(255, 255, 255, 0.1);
|
--line: #2a2a2e;
|
||||||
--display-amber: #fbbf24;
|
--text: #f3f1ea;
|
||||||
--display-amber-fg: #09090b;
|
--muted: #9a9588;
|
||||||
--display-radius: 8px;
|
--amber: #f5a524;
|
||||||
--display-focus: rgba(252, 211, 77, 0.5);
|
--amber-on: #1a1204;
|
||||||
|
--chip-bg: #2a2418;
|
||||||
|
--chip-line: #5a4a28;
|
||||||
|
--chip-text: #e8c37a;
|
||||||
|
--success: #7dda6f;
|
||||||
|
--radius: 8px;
|
||||||
|
--page-gap: 12px;
|
||||||
|
--field-gap: 12px;
|
||||||
|
--header-h: 48px;
|
||||||
|
--rail-task-h: 40px;
|
||||||
|
--rail-model-h: 36px;
|
||||||
|
--left-w: 260px;
|
||||||
|
--right-w: 280px;
|
||||||
|
--type: 14px;
|
||||||
|
--type-sm: 12px;
|
||||||
|
--type-label: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display='classic'] {
|
[data-display='classic'] {
|
||||||
--chrome-gap: 0.5rem;
|
--field-gap: 8px;
|
||||||
--rail-h: 2.25rem;
|
--type: 13px;
|
||||||
--card-pad: 0.75rem;
|
|
||||||
--display-type-title: 1.75rem;
|
|
||||||
--display-type-body: 0.875rem;
|
|
||||||
--display-viewer-radius: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display='studio'] {
|
[data-display] {
|
||||||
--chrome-gap: 1rem;
|
background: var(--bg);
|
||||||
--rail-h: 3rem;
|
color: var(--text);
|
||||||
--card-pad: 1rem;
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
--display-type-title: 1.875rem;
|
font-size: var(--type);
|
||||||
--display-type-body: 0.9375rem;
|
|
||||||
--display-viewer-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-title {
|
/* Skin toggles */
|
||||||
font-size: var(--display-type-title);
|
[data-display='classic'] .rail-task,
|
||||||
|
[data-display='classic'] .rail-model,
|
||||||
|
[data-display='classic'] .identity-slots,
|
||||||
|
[data-display='classic'] .eye-viewer,
|
||||||
|
[data-display='classic'] .eye-tile {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
[data-display='studio'] .classic-task-model,
|
||||||
|
[data-display='studio'] .viewer-lock-bar {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-body {
|
.bench-root {
|
||||||
font-size: var(--display-type-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-display] .display-stack {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: 100dvh;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--chrome-gap);
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-card {
|
.bench-root[data-mobile="1"] {
|
||||||
border-radius: var(--display-radius);
|
position: relative;
|
||||||
border: 1px solid var(--display-border);
|
|
||||||
padding: var(--card-pad);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-viewer {
|
.bench-header {
|
||||||
border-radius: var(--display-viewer-radius);
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--header-h);
|
||||||
|
min-height: var(--header-h);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-rail {
|
.bench-brand {
|
||||||
display: flex;
|
font-weight: 650;
|
||||||
flex-wrap: wrap;
|
letter-spacing: 0.02em;
|
||||||
gap: 0.5rem;
|
|
||||||
min-height: var(--rail-h);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-rail__seg {
|
.bench-crumb {
|
||||||
min-height: var(--rail-h);
|
display: inline-flex;
|
||||||
border-radius: var(--display-radius);
|
align-items: center;
|
||||||
border: 1px solid var(--display-border);
|
gap: 6px;
|
||||||
padding: 0.5rem 1rem;
|
color: var(--muted);
|
||||||
font-size: var(--display-type-body);
|
font-size: var(--type-sm);
|
||||||
color: var(--display-fg);
|
}
|
||||||
|
|
||||||
|
.bench-crumb select {
|
||||||
|
max-width: 14rem;
|
||||||
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-rail__seg:focus-visible {
|
.bench-header-right {
|
||||||
box-shadow: 0 0 0 2px var(--display-focus);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-rail__seg[aria-pressed='true'],
|
.bench-nav {
|
||||||
[data-display] .display-rail__seg[data-active='true'] {
|
display: flex;
|
||||||
border-color: var(--display-amber);
|
gap: 14px;
|
||||||
background: var(--display-amber);
|
font-size: var(--type-sm);
|
||||||
color: var(--display-amber-fg);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-rail__seg:disabled,
|
.bench-nav a {
|
||||||
[data-display] .display-rail__seg[aria-disabled='true'] {
|
color: inherit;
|
||||||
opacity: 0.55;
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-nav a:hover,
|
||||||
|
.bench-nav a.router-link-active {
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-raise);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-chip .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-chip[data-tone='ready'] .dot { background: var(--success); }
|
||||||
|
.gpu-chip[data-tone='busy'] .dot { background: var(--amber); animation: pulse 1.2s infinite; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
50% { opacity: 0.45; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-task { min-height: var(--rail-task-h); align-items: center; padding-top: 8px; }
|
||||||
|
.rail-model { min-height: var(--rail-model-h); align-items: center; padding-bottom: 8px; }
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 0 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-model .seg { height: 32px; font-weight: 500; }
|
||||||
|
|
||||||
|
.seg[aria-pressed='true'] {
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: var(--amber);
|
||||||
|
color: var(--amber-on);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display='studio'] .display-rail__seg {
|
.seg:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-grid {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: var(--left-w) minmax(0, 1fr) var(--right-w);
|
||||||
|
gap: var(--page-gap);
|
||||||
|
padding: var(--page-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-panel {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--field-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-label);
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-control {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-raise);
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-control:focus-visible,
|
||||||
|
.field-area:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-area {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 88px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-raise);
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--chip-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--chip-bg);
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: var(--chip-text);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-chip button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-row .field-control { flex: 1; }
|
||||||
|
|
||||||
|
.dice-btn {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-raise);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-lock-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-stack {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage: fills center column above filmstrip + transport */
|
||||||
|
.viewer-frame {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0b0b0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */
|
||||||
|
.viewer-frame > img,
|
||||||
|
.viewer-frame > video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye-viewer {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #000a;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filmstrip {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
height: 80px;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
position: relative;
|
||||||
|
width: 96px;
|
||||||
|
height: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1440px) {
|
||||||
|
.tile { width: 120px; height: 80px; }
|
||||||
|
.filmstrip { height: 96px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile[data-active='true'] {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile button.pick {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile .eye-tile,
|
||||||
|
.tile .tile-trash {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
right: 3px;
|
||||||
|
display: grid;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #000a;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile .tile-trash { right: 22px; }
|
||||||
|
|
||||||
|
.strip-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-actions button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-actions button:hover {
|
||||||
|
border-color: var(--amber);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-add-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-add-row .field-control {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport {
|
||||||
|
display: flex;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport .ops {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-col {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-head {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 12px 8px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-badge {
|
||||||
|
display: inline-grid;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--amber);
|
||||||
|
color: var(--amber-on);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-raise);
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card[data-active='true'] {
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-id {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-title {
|
||||||
|
margin-top: 2px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.625rem 1.25rem;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display='classic'] .display-rail__seg {
|
.job-meta {
|
||||||
min-height: 2.25rem;
|
margin-top: 4px;
|
||||||
padding: 0.375rem 0.75rem;
|
color: var(--muted);
|
||||||
font-weight: 500;
|
font-size: var(--type-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-cta {
|
.job-status {
|
||||||
border-radius: var(--display-radius);
|
display: flex;
|
||||||
background: var(--display-amber);
|
margin-top: 8px;
|
||||||
color: var(--display-amber-fg);
|
align-items: center;
|
||||||
font-weight: 600;
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: var(--type-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-cta:focus-visible {
|
.job-status .left {
|
||||||
box-shadow: 0 0 0 2px var(--display-focus);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-control {
|
.job-status .dot {
|
||||||
border-radius: var(--display-radius);
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status[data-state='queued'] .dot,
|
||||||
|
.job-status[data-state='waiting'] .dot {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status[data-state='enhancing'] .dot,
|
||||||
|
.job-status[data-state='waking'] .dot,
|
||||||
|
.job-status[data-state='submitting'] .dot,
|
||||||
|
.job-status[data-state='rendering'] .dot,
|
||||||
|
.job-status[data-state='saving'] .dot {
|
||||||
|
background: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
accent-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-progress[data-indeterminate='true'] .job-progress-bar {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-elapsed {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,21 @@
|
|||||||
<li
|
<li
|
||||||
v-for="(item, index) in model"
|
v-for="(item, index) in model"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
class="rounded-xl border bg-zinc-950/80"
|
class="rounded-lg border border-[#2A2A2E] bg-[#1A1A1D] p-2.5"
|
||||||
:class="[
|
:class="[
|
||||||
compact ? 'px-2 py-1.5' : 'px-3 py-2',
|
dragOverIndex === index ? 'border-amber-300/50' : '',
|
||||||
dragOverIndex === index ? 'border-amber-300/50' : 'border-white/10',
|
|
||||||
draggingIndex === index ? 'opacity-60' : ''
|
draggingIndex === index ? 'opacity-60' : ''
|
||||||
]"
|
]"
|
||||||
@dragover.prevent="onDragOver(index)"
|
@dragover.prevent="onDragOver(index)"
|
||||||
@drop.prevent="onDrop(index)"
|
@drop.prevent="onDrop(index)"
|
||||||
@dragleave="onDragLeave(index)"
|
@dragleave="onDragLeave(index)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 flex-1 items-start gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="model.length > 1"
|
v-if="model.length > 1"
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
|
class="mt-0.5 shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
title="Drag to reorder"
|
title="Drag to reorder"
|
||||||
@@ -31,61 +30,91 @@
|
|||||||
>
|
>
|
||||||
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
|
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="min-w-0"><span class="block truncate text-zinc-200">{{ labelFor(item.name) }}</span><span v-if="filenameSubtitle" class="block truncate text-[11px] text-zinc-500" :title="item.name">{{ item.name }}</span></span>
|
<span class="min-w-0 flex-1">
|
||||||
|
<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
|
<LoraTriggerTip
|
||||||
:text="triggerFor(item.name)"
|
:text="triggerFor(item.name)"
|
||||||
:aria-label="'Activation for ' + labelFor(item.name)"
|
:aria-label="'Activation for ' + labelFor(item.name)"
|
||||||
@insert="emit('insert-trigger', $event)"
|
@insert="emit('insert-trigger', $event)"
|
||||||
@save="saveTrigger(item.name, $event)"
|
@save="saveTrigger(item.name, $event)"
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
<span v-if="filenameSubtitle" class="mt-0.5 block truncate text-[11px] text-[#9A9588]" :title="item.name">{{ stemFor(item.name) }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 text-[11px] text-zinc-500 hover:text-red-300 disabled:opacity-50"
|
class="shrink-0 text-[11px] text-[#9A9588] hover:text-red-300 disabled:opacity-50"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@click="removeAt(index)"
|
@click="removeAt(index)"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1.5 flex flex-wrap gap-3">
|
|
||||||
<Thumbwheel
|
<div class="mt-2 space-y-1.5">
|
||||||
:model-value="item.strengthModel"
|
<label class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]">
|
||||||
label="Model"
|
<span>{{ clipStrength ? 'Model' : 'Strength' }}</span>
|
||||||
:lock-key="`lora-model:${item.name}`"
|
<input
|
||||||
:min="LORA_STRENGTH_MIN"
|
type="range"
|
||||||
:max="LORA_STRENGTH_MAX"
|
class="lora-strength-range min-w-0"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
:step="LORA_STRENGTH_STEP"
|
:step="LORA_STRENGTH_STEP"
|
||||||
:fine-step="LORA_STRENGTH_STEP"
|
|
||||||
:coarse-step="0.25"
|
|
||||||
:digits="2"
|
|
||||||
compact
|
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@update:model-value="setStrength(index, 'strengthModel', $event)"
|
:value="displayStrength(item.strengthModel)"
|
||||||
/>
|
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' for ' + labelFor(item.name)"
|
||||||
<Thumbwheel
|
@input="onRange(index, 'strengthModel', $event)"
|
||||||
:model-value="item.strengthClip"
|
>
|
||||||
label="CLIP"
|
<input
|
||||||
:lock-key="`lora-clip:${item.name}`"
|
type="number"
|
||||||
:min="LORA_STRENGTH_MIN"
|
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]"
|
||||||
:max="LORA_STRENGTH_MAX"
|
:min="0"
|
||||||
|
:max="2"
|
||||||
:step="LORA_STRENGTH_STEP"
|
:step="LORA_STRENGTH_STEP"
|
||||||
:fine-step="LORA_STRENGTH_STEP"
|
:disabled="disabled"
|
||||||
:coarse-step="0.25"
|
:value="displayStrength(item.strengthModel).toFixed(2)"
|
||||||
:digits="2"
|
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' value'"
|
||||||
compact
|
@change="onNumber(index, 'strengthModel', $event)"
|
||||||
:disabled="disabled || !clipStrength"
|
>
|
||||||
@update:model-value="setStrength(index, 'strengthClip', $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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-if="!clipStrength && model.length" class="mt-1 text-[11px] text-zinc-500">CLIP unused on this graph — model strength only.</p>
|
|
||||||
<select
|
<select
|
||||||
v-if="model.length < LORA_STACK_MAX"
|
v-if="model.length < LORA_STACK_MAX"
|
||||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 outline-none ring-amber-300/40 focus:ring-2 disabled:opacity-50"
|
class="w-full rounded-lg border border-[#2A2A2E] bg-[#0B0B0D] text-[#F3F1EA] outline-none ring-[#F5A524]/40 focus:ring-2 disabled:opacity-50"
|
||||||
:class="[
|
:class="[
|
||||||
compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm',
|
compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
|
||||||
model.length ? 'mt-2' : ''
|
model.length ? 'mt-2' : ''
|
||||||
]"
|
]"
|
||||||
:disabled="disabled || !availableNames.length"
|
:disabled="disabled || !availableNames.length"
|
||||||
@@ -103,13 +132,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lora-strength-range {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2a2a2e;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f5a524;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-moz-range-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2a2a2e;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f5a524;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lora-strength-range:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-webkit-slider-runnable-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2a2a2e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
LORA_STACK_MAX,
|
LORA_STACK_MAX,
|
||||||
LORA_STRENGTH_MAX,
|
LORA_STRENGTH_DEFAULT,
|
||||||
LORA_STRENGTH_MIN,
|
|
||||||
LORA_STRENGTH_STEP,
|
LORA_STRENGTH_STEP,
|
||||||
clampLoraStrength,
|
loraDisplayName,
|
||||||
loraFriendlyName,
|
loraFriendlyName,
|
||||||
loraIdentityKey,
|
loraIdentityKey,
|
||||||
loraMatchesImageEngine,
|
loraMatchesImageEngine,
|
||||||
@@ -120,6 +191,9 @@ import {
|
|||||||
type LoraStackItem
|
type LoraStackItem
|
||||||
} from '~/utils/loras'
|
} from '~/utils/loras'
|
||||||
|
|
||||||
|
const UI_MIN = 0
|
||||||
|
const UI_MAX = 2
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
names: string[]
|
names: string[]
|
||||||
filenameSubtitle?: boolean
|
filenameSubtitle?: boolean
|
||||||
@@ -175,6 +249,17 @@ const selectPlaceholder = computed(() => {
|
|||||||
return model.value.length ? props.addLabel : props.emptyLabel
|
return model.value.length ? props.addLabel : props.emptyLabel
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function clampUi(raw: unknown) {
|
||||||
|
const value = Number(raw)
|
||||||
|
if (!Number.isFinite(value)) return LORA_STRENGTH_DEFAULT
|
||||||
|
const snapped = Math.round(value / LORA_STRENGTH_STEP) * LORA_STRENGTH_STEP
|
||||||
|
return Math.min(UI_MAX, Math.max(UI_MIN, Math.round(snapped * 100) / 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayStrength(value: unknown) {
|
||||||
|
return clampUi(value ?? LORA_STRENGTH_DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
function onAdd(event: Event) {
|
function onAdd(event: Event) {
|
||||||
const name = (event.target as HTMLSelectElement).value
|
const name = (event.target as HTMLSelectElement).value
|
||||||
;(event.target as HTMLSelectElement).value = ''
|
;(event.target as HTMLSelectElement).value = ''
|
||||||
@@ -186,7 +271,11 @@ function addLora(name: string) {
|
|||||||
if (!item || model.value.length >= LORA_STACK_MAX) return
|
if (!item || model.value.length >= LORA_STACK_MAX) return
|
||||||
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
|
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
|
||||||
if (taken.has(loraIdentityKey(item.name))) return
|
if (taken.has(loraIdentityKey(item.name))) return
|
||||||
model.value = [...model.value, item]
|
model.value = [...model.value, {
|
||||||
|
...item,
|
||||||
|
strengthModel: clampUi(item.strengthModel ?? LORA_STRENGTH_DEFAULT),
|
||||||
|
strengthClip: clampUi(item.strengthClip ?? LORA_STRENGTH_DEFAULT)
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAt(index: number) {
|
function removeAt(index: number) {
|
||||||
@@ -234,8 +323,22 @@ function onDragEnd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
|
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
|
||||||
const next = clampLoraStrength(value)
|
const next = clampUi(value)
|
||||||
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item)
|
model.value = model.value.map((item, i) => {
|
||||||
|
if (i !== index) return item
|
||||||
|
if (!props.clipStrength) {
|
||||||
|
return { ...item, strengthModel: next, strengthClip: next }
|
||||||
|
}
|
||||||
|
return { ...item, [key]: next }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRange(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||||
|
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNumber(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||||
|
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerFor(name: string) {
|
function triggerFor(name: string) {
|
||||||
@@ -246,6 +349,10 @@ function labelFor(name: string) {
|
|||||||
return loraFriendlyName(name, props.aliases)
|
return loraFriendlyName(name, props.aliases)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stemFor(name: string) {
|
||||||
|
return loraDisplayName(name)
|
||||||
|
}
|
||||||
|
|
||||||
function saveTrigger(name: string, trigger: string) {
|
function saveTrigger(name: string, trigger: string) {
|
||||||
emit('update-trigger', name, trigger)
|
emit('update-trigger', name, trigger)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
@click="setDisplayMode('classic')"
|
@click="setDisplayMode('classic')"
|
||||||
>
|
>
|
||||||
<span class="block font-display text-lg font-semibold text-zinc-50">Classic</span>
|
<span class="block font-display text-lg font-semibold text-zinc-50">Classic</span>
|
||||||
<span class="mt-1 block text-xs text-zinc-400">Packed header, compact selects, tight gaps.</span>
|
<span class="mt-1 block text-xs text-zinc-400">packed header, dropdowns</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
@click="setDisplayMode('studio')"
|
@click="setDisplayMode('studio')"
|
||||||
>
|
>
|
||||||
<span class="block font-display text-lg font-semibold text-zinc-50">Studio</span>
|
<span class="block font-display text-lg font-semibold text-zinc-50">Studio</span>
|
||||||
<span class="mt-1 block text-xs text-zinc-400">Task and model rails, locks by slots, eye toggles.</span>
|
<span class="mt-1 block text-xs text-zinc-400">task rail, model rail, slots + eyes</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-36 shrink-0 space-y-2"><span class="text-xs text-zinc-400">{{ label }}</span><button class="flex h-24 w-full items-center justify-center overflow-hidden rounded-xl border border-white/15 bg-black" @click="$emit('pick')"><span v-if="asset && hidden" class="p-2 text-xs text-zinc-400">Still loaded · preview hidden</span><img v-else-if="asset" :src="asset.kind==='clip'?`/api/library/clips/${asset.id}/thumb`:`/api/library/stills/${asset.id}`" class="h-full w-full object-contain" :alt="label"><span v-else class="text-xs text-zinc-400">Choose {{ label.toLowerCase() }}</span></button><p v-if="asset" class="truncate text-xs" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p><label v-if="upload" class="block cursor-pointer text-xs text-amber-200">Upload {{ label.toLowerCase() }}<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)"></label></div>
|
<div class="slot-block">
|
||||||
|
<span class="field-label">{{ label }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="slot-thumb"
|
||||||
|
:class="asset ? (size==='hero' ? 'slot-thumb--hero' : 'slot-thumb--slot') : 'slot-thumb--empty'"
|
||||||
|
@click="$emit('pick')"
|
||||||
|
>
|
||||||
|
<span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Still loaded · preview hidden</span>
|
||||||
|
<img v-else-if="asset" :src="asset.kind==='clip' ? `/api/library/clips/${asset.id}/thumb` : `/api/library/stills/${asset.id}`" :alt="label">
|
||||||
|
<span v-else class="inline-flex items-center gap-1">Select <span aria-hidden="true">▾</span></span>
|
||||||
|
</button>
|
||||||
|
<p v-if="asset" class="slot-caption" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p>
|
||||||
|
<label v-if="upload" class="slot-upload">Upload {{ label.toLowerCase() }}
|
||||||
|
<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {assetLabel} from '~/shared/studio2/viewer.mjs'
|
import {assetLabel} from '~/shared/studio2/viewer.mjs'
|
||||||
defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean}>()
|
withDefaults(defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean;size?:'hero'|'slot'}>(),{size:'slot'})
|
||||||
defineEmits(['pick','upload'])
|
defineEmits(['pick','upload'])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,7 +19,22 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=
|
|||||||
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
|
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
|
||||||
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
|
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
|
||||||
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
|
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
|
||||||
{label:'Compiled prompt',value:m.compiledPrompt},{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
|
{label:'Prompt',value:m.prompt || m.compiledPrompt},
|
||||||
|
{label:'Typed',value:m.promptRaw || null},
|
||||||
|
{label:'Task',value:m.task || m.mode || null},
|
||||||
|
{label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'},
|
||||||
|
{label:'Enhance',value:m.enhance?.refused?'Enhance skipped (model refused) — used your prompt.':m.enhance?.skippedAsDescribe?'Enhance skipped (describe rewrite) — keep stanza + typed only.':m.enhance?.used?'Applied':m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Applied':'Parse incomplete'},
|
||||||
|
{label:'Enhance used',value:m.enhance?.used==null?null:m.enhance.used?'Yes':'No'},
|
||||||
|
{label:'Enhance refused',value:m.enhance?.refused==null?null:m.enhance.refused?'Yes':'No'},
|
||||||
|
{label:'Enhance skip reason',value:m.enhance?.skipReason || (m.enhance?.skippedAsDescribe?'skippedAsDescribe':null)},
|
||||||
|
{label:'Turbo',value:m.turbo==null?null:m.turbo?'On':'Off'},
|
||||||
|
{label:'Turbo LoRA',value:m.lora || null},
|
||||||
|
{label:'Turbo sigmas',value:m.sigmas || null},
|
||||||
|
{label:'Enhance wh_ratio',value:m.enhance?.wh_ratio || null},
|
||||||
|
{label:'Enhance ratio_follow',value:m.enhance?.ratio_follow || null},
|
||||||
|
{label:'Enhance parse_ok',value:m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Yes':'No'},
|
||||||
|
{label:'Enhance thinking',value:m.enhance?.thinking || null},
|
||||||
|
{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
|
||||||
{label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult}
|
{label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult}
|
||||||
]})
|
]})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<div class="space-y-2 rounded-xl border border-white/10 p-3 text-sm" aria-live="polite">
|
<div class="space-y-2 rounded-xl border border-white/10 p-3 text-sm" aria-live="polite">
|
||||||
<div class="flex justify-between gap-3"><button class="text-left" @click="$emit('select')"><strong>{{ job.request.promptSections.action.slice(0,70) }}</strong><span class="block capitalize text-amber-200">{{ job.state==='waiting'?'queued':job.state }} · Shot {{ shot }}/{{ total }}</span></button><button v-if="!terminal" @click="$emit('cancel')">Cancel</button></div>
|
<div class="flex justify-between gap-3"><button class="text-left" @click="$emit('select')"><strong>{{ job.request.promptSections.action.slice(0,70) }}</strong><span class="block capitalize text-amber-200">{{ job.state==='waiting'?'queued':job.state }} · Shot {{ shot }}/{{ total }}</span></button><button v-if="!terminal" @click="$emit('cancel')">Cancel</button></div>
|
||||||
<p>{{ elapsed }} {{ job.startedAt?'elapsed':'waiting' }} · {{ job.request.settings.duration }}s target<span v-if="job.state==='rendering' && job.progress"> · Step {{ job.progress.step }}/{{ job.progress.maxStep }} ({{ job.progress.percent }}%)</span></p>
|
<p>{{ elapsed }} {{ job.startedAt?'elapsed':'waiting' }} · {{ job.request.settings.duration }}s target<span v-if="job.state==='rendering' && job.progress"> · Step {{ job.progress.step }}/{{ job.progress.maxStep }} ({{ job.progress.percent }}%)</span></p>
|
||||||
<progress v-if="job.state==='rendering'" class="h-2 w-full accent-amber-300" :value="job.progress?.percent" max="100" />
|
<progress v-if="job.state==='enhancing'" class="h-2 w-full accent-amber-300" max="100" :value="enhancePercent" />
|
||||||
|
<progress v-else-if="job.state==='rendering'" class="h-2 w-full accent-amber-300" :value="job.progress?.percent" max="100" />
|
||||||
<p v-if="job.error" role="alert" class="text-red-300">{{ job.error }}</p>
|
<p v-if="job.error" role="alert" class="text-red-300">{{ job.error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -13,4 +14,9 @@ const terminal=computed(()=>['complete','failed','cancelled'].includes(props.job
|
|||||||
const total=computed(()=>props.job.prompts?.length || (props.job.request.batch?.length || 0)+1)
|
const total=computed(()=>props.job.prompts?.length || (props.job.request.batch?.length || 0)+1)
|
||||||
const shot=computed(()=>Math.min(total.value,(props.job.index || 0)+1))
|
const shot=computed(()=>Math.min(total.value,(props.job.index || 0)+1))
|
||||||
const elapsed=computed(()=>{const seconds=elapsedSeconds(props.job,props.now);return `${Math.floor(seconds/60)}m ${seconds%60}s`})
|
const elapsed=computed(()=>{const seconds=elapsedSeconds(props.job,props.now);return `${Math.floor(seconds/60)}m ${seconds%60}s`})
|
||||||
|
const enhancePercent=computed(()=>{
|
||||||
|
const p=props.job.progress
|
||||||
|
if(p && Number(p.maxStep)>0) return Number(p.percent)||Math.round(Number(p.step)/Number(p.maxStep)*100)
|
||||||
|
return undefined // indeterminate while PE runs without Comfy step events
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,595 @@
|
|||||||
|
<template>
|
||||||
|
<div class="m-shell" data-mobile="1">
|
||||||
|
<header class="m-header">
|
||||||
|
<span class="m-brand">{{ ctx.instanceName }}</span>
|
||||||
|
<label class="m-crumb">
|
||||||
|
<select v-model="ctx.form.folderId" aria-label="Folder">
|
||||||
|
<option v-for="f in ctx.library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="m-grow" />
|
||||||
|
<span class="m-gpu" :data-tone="ctx.gpu.tone" :title="ctx.gpu.detail">● {{ ctx.gpuChipLabel }}</span>
|
||||||
|
<button type="button" class="m-chip" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
|
||||||
|
<details class="m-more">
|
||||||
|
<summary aria-label="More">⋯</summary>
|
||||||
|
<div class="m-more-menu">
|
||||||
|
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
|
||||||
|
<NuxtLink to="/queue" @click="closeMenus">Queue</NuxtLink>
|
||||||
|
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="m-chips" role="tablist" aria-label="Tasks">
|
||||||
|
<button
|
||||||
|
v-for="task in ctx.tasks"
|
||||||
|
:key="task"
|
||||||
|
type="button"
|
||||||
|
class="m-chip capitalize"
|
||||||
|
:class="{ on: ctx.form.mode===task }"
|
||||||
|
:aria-pressed="ctx.form.mode===task"
|
||||||
|
@click="ctx.chooseTask(task)"
|
||||||
|
>{{ task }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ctx.form.mode!=='music'" class="m-chips" role="group" aria-label="Model">
|
||||||
|
<button
|
||||||
|
v-for="opt in ctx.engineOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
class="m-chip"
|
||||||
|
:class="{ on: ctx.form.engine===opt.value }"
|
||||||
|
:disabled="opt.disabled"
|
||||||
|
:title="opt.hint || undefined"
|
||||||
|
:aria-pressed="ctx.form.engine===opt.value"
|
||||||
|
@click="!opt.disabled && (ctx.form.engine=opt.value)"
|
||||||
|
>{{ opt.label }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="m-chips"><span class="m-chip on">YuE2</span></div>
|
||||||
|
|
||||||
|
<div class="m-stage">
|
||||||
|
<button
|
||||||
|
v-if="ctx.selected && ctx.xaigen"
|
||||||
|
type="button"
|
||||||
|
class="m-eye"
|
||||||
|
:aria-label="ctx.isHidden(ctx.selected) ? 'Show preview' : 'Hide preview'"
|
||||||
|
@click.stop="ctx.toggleReveal(ctx.selected.id)"
|
||||||
|
>
|
||||||
|
<img :src="ctx.isHidden(ctx.selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
|
||||||
|
</button>
|
||||||
|
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
|
||||||
|
<p class="m-muted">{{ ctx.selected.kind==='track' ? 'Track loaded · preview hidden' : 'Preview hidden' }}</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="ctx.selected?.kind==='track'">
|
||||||
|
<div class="m-audio">
|
||||||
|
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<video
|
||||||
|
v-else-if="ctx.selected?.kind==='clip'"
|
||||||
|
:key="ctx.selected.id"
|
||||||
|
:src="`/api/library/clips/${ctx.selected.id}/video`"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
/>
|
||||||
|
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still">
|
||||||
|
<p v-else class="m-muted">Select a still or clip</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-strip" aria-label="Family outputs">
|
||||||
|
<div
|
||||||
|
v-for="o in ctx.family"
|
||||||
|
:key="o.id"
|
||||||
|
class="m-tile"
|
||||||
|
:class="{ on: ctx.selected?.id===o.id }"
|
||||||
|
>
|
||||||
|
<button type="button" class="m-tile-pick" @click="ctx.selectOutput(o)">
|
||||||
|
<span v-if="ctx.isHidden(o)" class="m-tile-hid">Hidden</span>
|
||||||
|
<span v-else-if="o.kind==='track'" class="m-tile-hid">Audio</span>
|
||||||
|
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : ctx.stillUrl(o.id)" alt="">
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="ctx.xaigen"
|
||||||
|
type="button"
|
||||||
|
class="m-tile-eye"
|
||||||
|
:aria-label="ctx.isHidden(o) ? 'Show tile' : 'Hide tile'"
|
||||||
|
@click.stop="ctx.toggleReveal(o.id)"
|
||||||
|
>
|
||||||
|
<img :src="ctx.isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="ctx.selected?.id===o.id"
|
||||||
|
type="button"
|
||||||
|
class="m-tile-trash"
|
||||||
|
aria-label="Delete selected"
|
||||||
|
@click.stop="ctx.deleteOutput"
|
||||||
|
>🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ctx.selected && ctx.selected.kind!=='track'" class="m-strip-actions">
|
||||||
|
<button type="button" @click="ctx.applyUseAsSource">Use as start</button>
|
||||||
|
<button type="button" @click="ctx.applyUseAsHero">Use as hero</button>
|
||||||
|
<button type="button" @click="ctx.copySelectedPrompt">Copy prompt</button>
|
||||||
|
<button type="button" @click="ctx.loadSelectedJob">Load</button>
|
||||||
|
<button type="button" @click="ctx.deleteOutput">Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-dock">
|
||||||
|
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Slots</button>
|
||||||
|
<button type="button" :class="{ on: sheet==='prompt' }" @click="toggleSheet('prompt')">Prompt</button>
|
||||||
|
<button type="button" :class="{ on: sheet==='look' }" @click="toggleSheet('look')">Look</button>
|
||||||
|
<button type="button" :class="{ on: sheet==='gen' }" @click="toggleSheet('gen')">Gen</button>
|
||||||
|
<button type="button" class="m-cta" :disabled="ctx.submitting" @click="ctx.queue">{{ ctx.submitting ? '…' : 'Generate' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sheets -->
|
||||||
|
<div v-if="sheet" class="m-sheet-backdrop" @click="sheet=''" />
|
||||||
|
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Slots">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Slots</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<template v-if="ctx.form.mode==='music'">
|
||||||
|
<p class="m-muted">Music uses tags + lyrics in Prompt / Gen — no hero slot.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Studio2AssetSlot size="hero" :label="ctx.heroSlotLabel" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.identityStillId)" :hidden="ctx.slotHidden(ctx.form.identityStillId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker('identityStillId')" @upload="ctx.upload($event,'hero')" />
|
||||||
|
<Studio2AssetSlot :label="ctx.sourceSlotLabel" :asset="ctx.sourceSlot" :hidden="ctx.slotHidden(ctx.form.mode==='extend'?ctx.form.startClipId:ctx.form.imageAId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker(ctx.form.mode==='extend'?'startClipId':'imageAId')" @upload="ctx.upload($event,'source')" />
|
||||||
|
<p v-if="ctx.editSourceRequired && !ctx.form.imageAId" class="m-hint">Required for Edit</p>
|
||||||
|
<p v-if="ctx.slotPairHint" class="m-hint">{{ ctx.slotPairHint }}</p>
|
||||||
|
<Studio2AssetSlot v-if="ctx.form.mode==='compose'" label="Second still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.imageBId)" :hidden="ctx.slotHidden(ctx.form.imageBId)" @pick="ctx.setPicker('imageBId')" />
|
||||||
|
<Studio2AssetSlot v-if="ctx.form.mode==='refine'" label="Mask" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.maskId)" :hidden="ctx.slotHidden(ctx.form.maskId)" @pick="ctx.setPicker('maskId')" />
|
||||||
|
<Studio2AssetSlot v-if="ctx.video" label="End still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.endStillId)" :hidden="ctx.slotHidden(ctx.form.endStillId)" @pick="ctx.setPicker('endStillId')" />
|
||||||
|
<label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label>
|
||||||
|
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label>
|
||||||
|
<p class="m-hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
||||||
|
<p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside v-if="sheet==='prompt'" class="m-sheet" role="dialog" aria-label="Prompt">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Prompt</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<template v-if="ctx.form.mode==='music'">
|
||||||
|
<label class="m-label">Genre / style tags<textarea v-model="ctx.music.tags" rows="3" class="m-area" /></label>
|
||||||
|
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label>
|
||||||
|
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label>
|
||||||
|
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label>
|
||||||
|
<p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
|
||||||
|
<div>
|
||||||
|
<span class="m-label">Negative prompt</span>
|
||||||
|
<div class="m-neg">
|
||||||
|
<span v-for="chip in ctx.negativeChips" :key="chip" class="m-neg-chip">
|
||||||
|
{{ chip }}
|
||||||
|
<button type="button" :aria-label="'Remove '+chip" @click="ctx.removeNegative(chip)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-neg-add">
|
||||||
|
<input
|
||||||
|
:value="ctx.negativeDraft"
|
||||||
|
type="text"
|
||||||
|
class="m-input"
|
||||||
|
placeholder="Add negative chip"
|
||||||
|
@input="ctx.setNegativeDraft(($event.target as HTMLInputElement).value)"
|
||||||
|
@keydown.enter.prevent="ctx.addNegative"
|
||||||
|
>
|
||||||
|
<button type="button" @click="ctx.addNegative">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Studio2StyleChips v-if="!ctx.video" v-model="ctx.form.imageStyles" />
|
||||||
|
</template>
|
||||||
|
<p v-if="ctx.error" class="m-err">{{ ctx.error }}</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside v-if="sheet==='look'" class="m-sheet" role="dialog" aria-label="Look">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Look</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<template v-if="ctx.form.mode==='music'">
|
||||||
|
<label class="m-label">Seed<input v-model="ctx.music.seed" class="m-input" placeholder="random"></label>
|
||||||
|
<label class="m-label">Track name<input v-model="ctx.music.name" class="m-input" placeholder="Optional"></label>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div>
|
||||||
|
<span class="m-label">LoRA</span>
|
||||||
|
<LoraSelect v-model="ctx.form.settings.loraStack" compact :names="ctx.pickerLoras" :use="ctx.currentLoraUse" :clip-strength="!ctx.video" filename-subtitle :aliases="ctx.loraAliases" :triggers="ctx.loraTriggers" missing-label="None" @insert-trigger="ctx.form.promptSections.action += ' '+$event" />
|
||||||
|
</div>
|
||||||
|
<label class="m-label">Seed
|
||||||
|
<div class="m-seed">
|
||||||
|
<input
|
||||||
|
:value="ctx.form.settings.seed ?? ''"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="2147483647"
|
||||||
|
class="m-input"
|
||||||
|
@input="ctx.form.settings.seed=Number(($event.target as HTMLInputElement).value);ctx.form.settings.seedMode='locked'"
|
||||||
|
>
|
||||||
|
<button type="button" class="m-dice" aria-label="Random seed" @click="ctx.randomizeSeed">🎲</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="m-label">Aspect
|
||||||
|
<select v-model="ctx.form.settings.aspect" class="m-input">
|
||||||
|
<option v-for="opt in ctx.aspectOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="m-label">Steps<input v-model.number="ctx.form.settings.steps" type="number" class="m-input"></label>
|
||||||
|
<label class="m-label">CFG<input v-model.number="ctx.form.settings.cfg" type="number" class="m-input"></label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside v-if="sheet==='gen'" class="m-sheet" role="dialog" aria-label="Gen extras">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Gen</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<template v-if="ctx.form.mode==='music'">
|
||||||
|
<label class="m-label">Target length (sec)<input v-model.number="ctx.music.duration" type="number" min="15" max="150" class="m-input"></label>
|
||||||
|
<p class="m-hint">Caps semantic tokens (~25/s). Actual length follows the model end token.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="ctx.video">
|
||||||
|
<label class="m-label">Duration (sec)<input v-model.number="ctx.form.settings.duration" type="number" class="m-input"></label>
|
||||||
|
<label class="m-label">FPS<input v-model.number="ctx.form.settings.fps" type="number" class="m-input"></label>
|
||||||
|
<label class="m-check"><input v-model="ctx.form.settings.turbo" type="checkbox"> Turbo</label>
|
||||||
|
<Studio2PromptControls v-model="ctx.form.promptSections" />
|
||||||
|
<details class="m-details">
|
||||||
|
<summary>Queue more shots</summary>
|
||||||
|
<textarea :value="ctx.batchText" rows="4" class="m-area" placeholder="First additional prompt 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 v-if="card.progress" class="m-job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
|
||||||
|
<progress
|
||||||
|
class="m-job-progress-bar"
|
||||||
|
max="100"
|
||||||
|
:value="card.progress.indeterminate ? undefined : card.progress.percent"
|
||||||
|
/>
|
||||||
|
<span v-if="card.elapsed" class="m-muted">{{ card.elapsed }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
|
||||||
|
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</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-job-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||||
|
.m-job-progress-bar { flex: 1; height: 4px; accent-color: var(--amber); }
|
||||||
|
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
|
||||||
|
</style>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Qwen Image 2.1 (engine `qwen21`) — host weights + Comfy graph
|
||||||
|
|
||||||
|
## Host paths (this RTX 5080 box)
|
||||||
|
|
||||||
|
| Role | Resolved path |
|
||||||
|
|---|---|
|
||||||
|
| ComfyUI root (Klein / host agent `ComfyUI (1)`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI` |
|
||||||
|
| Models root (Desktop Shared via `shared_model_paths.yaml`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Shared\models` |
|
||||||
|
|
||||||
|
Weights must land under Shared so every Desktop instance sees them:
|
||||||
|
|
||||||
|
- `diffusion_models\qwen-image-2.1-Q8_0.gguf` (~7.59 GiB) — from `abenzerps/Qwen-Image-2.1-Uncensored-GGUF`
|
||||||
|
- `text_encoders\qwen3vl_8b_int8_convrot.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (INT8; BF16 8B VL OOMs on 16 GB)
|
||||||
|
- `vae\qwen_image_2.1_vae_bf16.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (**not** the old Qwen-Image 1.0 VAE)
|
||||||
|
|
||||||
|
Custom node: `custom_nodes\ComfyUI-GGUF` (`UnetLoaderGGUF`). Do not install a second GGUF pack.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
DiT download uses the exact host invocation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||||
|
```
|
||||||
|
|
||||||
|
That lands in the Hugging Face hub cache; the setup script copies it into Shared `diffusion_models`. The abenzerps GGUF ships with `kv_count=0` (no `general.architecture`) **and** Q8_0-quantized 1D RMSNorm weights (logical 128 → packed 136), which breaks Comfy’s fused `rms_rope`. `scripts/tag-qwen21-gguf.py` rewrites the file with `general.architecture=qwen_image` and promotes small/1D tensors to F32 so city96 `UnetLoaderGGUF` can load it.
|
||||||
|
|
||||||
|
Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false`). Confirm `object_info` lists `UnetLoaderGGUF` and the three filenames.
|
||||||
|
|
||||||
|
## App
|
||||||
|
|
||||||
|
- Engine key: `qwen21` · UI label: **Qwen 2.1**
|
||||||
|
- **Generate** (T2I) and **Edit** (same checkpoint, second graph). Compose / Iterate / Video / Extend / Music stay disabled unless a graph exists.
|
||||||
|
- Edit requires Start still → `images.image_1`; prompt slots use `<image1>` / `<image2>`. If the user omits `<image1>`, the runner prepends `Keep the identity, face, body, and pose of the person in <image1>. Apply only this change:` then their text. Hero is Klein-only and is ignored (never image_1).
|
||||||
|
- Face / outfit locks stay Klein semantics — they do not drive Qwen slots.
|
||||||
|
- Sampler defaults: euler / simple / cfg **1** / steps **25**. Edit uses `QwenImage21Cache` (device auto, dtype int8). T2I uses `ModelSamplingAuraFlow` shift **3.1**.
|
||||||
|
- Default Generate canvas follows the bench Aspect control on the Qwen-safe ~1 MP grid (`EmptyLatentImage`): 1:1 → 1024×1024, 16:9 → 1536×864, 9:16 → 864×1536 (and the other table rows). PE `wh_ratio` is advisory only and never sizes the canvas. Do not use native 2K bins on 16 GB.
|
||||||
|
- Edit follows `image_1` via the encode node’s latent (resolution long-edge ~1024). Do not inject a picker EmptyLatentImage onto the edit sampler.
|
||||||
|
- Optional **Enhance prompt** (off by default): runs a separate PE-only Comfy graph (`CLIPLoader` + rewrite node), then frees VRAM and queues the existing T2I/Edit graph with the rewritten prompt. Never loads PE CLIP + DiT together on 16 GB. Fail closed if `parse_ok` is false or the rewrite is empty.
|
||||||
|
- Generate + Enhance → PE-T2I only (`pe_t2i`). No images.
|
||||||
|
- Edit + Enhance → PE-I2I only (`pe_i2i`) with the same Start still on `image_1`. After PE, the sample prompt is always the keep-identity stanza + typed instruction first (`<image1>` in front). A PE rewrite is appended only when it is an edit directive; T2I-style observer captions (`The image is…`) are dropped (`enhance.skippedAsDescribe`) and the stanza + raw remain. Never let the PE chunk be the entire prompt. Library stores `prompt` (TextEncode string) and `promptRaw` (Typed). Never fall back to PE-T2I on Edit.
|
||||||
|
- If PE refuses or returns an empty/gutted rewrite, the job continues with `promptRaw` (Edit still gets the keep-identity stanza). Details shows `Enhance skipped (model refused) — used your prompt.` (`enhance.refused`).
|
||||||
|
- Host PE system prompts live in `host/qwen21-pe-prompts/` and are copied onto the node pack by `scripts/setup-qwen21.ps1` (official steps + Adult appendix). Edit prompt must lead with an operation and `<image1>` — never “The image is a photograph of…”. Do not swap Heretic PE weights on 16 GB.
|
||||||
|
- PE weights (int8 only): `text_encoders\qwen3.5_9b_qwen_image_2.1_pe_{t2i,i2i}.int8_convrot.safetensors` — do not replace the image TE `qwen3vl_8b_int8_convrot.safetensors`.
|
||||||
|
- Optional **Turbo** (off by default): Viggle DMD LoRA `Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors` on the same Q8 GGUF via `ViggleTurboLora` + `ViggleTurboSigmas` (`1.0, 0.9375, 0.875, 0.75, 0.5, 0.25`), 6 steps, CFG 1, empty negative. Not a new engine. Enhance prompt stays compatible and is recommended with Turbo.
|
||||||
|
- Custom node: `ComfyUI-Viggle-Turbo` (`viggle_turbo.py`). Do not merge the LoRA with stock `LoraLoaderModelOnly` (lossy on int8/bf16).
|
||||||
|
|
||||||
|
Graphs: `server/assets/studio2_qwen21_t2i.json`, `server/assets/studio2_qwen21_edit.json`, `server/assets/studio2_qwen21_t2i_turbo.json`, `server/assets/studio2_qwen21_edit_turbo.json`, `server/assets/studio2_qwen21_pe_t2i.json`, `server/assets/studio2_qwen21_pe_edit.json`.
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Edit Prompt Enhancer — General (v2, 精简版)
|
||||||
|
|
||||||
|
**FIRST — there are TWO separate language decisions. Do NOT conflate them.**
|
||||||
|
|
||||||
|
**(A) Language of the rewritten prompt's DESCRIPTIVE prose — every word OUTSIDE double quotes (the description you write for the diffusion model, NOT the text painted into the image). This decision is final and non-negotiable:**
|
||||||
|
- User instruction is in Chinese → write the description in Chinese.
|
||||||
|
- User instruction is in English → write the description in English.
|
||||||
|
- User instruction is in ANY other language (Japanese, Korean, French, Spanish, Thai, etc.) → write the description in English.
|
||||||
|
|
||||||
|
**(B) Language of the TEXT THAT WILL BE RENDERED INTO THE OUTPUT IMAGE — the content INSIDE double quotes. Decide it in this strict priority order:**
|
||||||
|
1. If the user's instruction gives the exact text to write, OR names a target language for the text (e.g. "改æˆ'夿—¥ç‰¹æƒ '", "æŠŠæ ‡é¢˜å†™æˆè‹±æ–‡", "add a Japanese title", "write the caption in Thai") → render exactly that text / in exactly that specified language.
|
||||||
|
2. Otherwise, if the input image already contains text → render in the DOMINANT language of the image's existing text — even when the instruction is written in a different language.
|
||||||
|
3. Otherwise (the image contains no text AND the instruction names no target language) → render in the language of the user's instruction itself — including Japanese, Korean, Thai, Arabic, French, etc. Do NOT force it to English.
|
||||||
|
Worked example: image is mostly Thai, instruction is in English asking to add/redesign a title without giving the exact words or a language → the rendered (quoted) text must be **Thai** (the image's dominant language), while the surrounding description (A) is still written in English.
|
||||||
|
|
||||||
|
Two reinforcements on decision (B): all rendered (quoted) text must be **monolingual** — do not mix Chinese and English inside the quotes and do not emit a bilingual pair unless the user explicitly asks for one. And **genre never overrides input language**: a "spec sheet / cinematic data-document / storyboard / technical parameter" look is achieved through layout and typography, NOT by switching rendered labels to English — every header, label, and caption stays in the decided language (standardized units and user-given proper nouns may remain Latin).
|
||||||
|
|
||||||
|
You are an expert at clarifying image editing instructions. Given a user's vague or ambiguous edit instruction and the input image(s), rewrite it into a precise, unambiguous, actionable editing directive. An input image is ALWAYS present — this is always an image-editing task, never text-to-image from nothing.
|
||||||
|
|
||||||
|
## Core Objective
|
||||||
|
|
||||||
|
Rewrite the instruction so a downstream image-editing model can execute it without guessing — anchored on what the input image(s) actually show, faithful to the user's intent, inventing nothing.
|
||||||
|
|
||||||
|
**How much you build is intent-branched.** When the user wants *this picture changed* (a local object/attribute/background edit, a text or UI edit, a quality or style change, a viewpoint/canvas transform), clarify and constrain: say exactly what changes, and let everything else stand. When the user wants *a new picture of this subject* (placing a subject in a new scene, compositing across images, a photo-shoot or poster or infographic built from a reference), construct actively: design the scene, lighting, composition and layout to a professional standard. Scale the elaboration to what was asked — a plain placement stays restrained, a styled shoot or a publication-grade poster is built out fully.
|
||||||
|
|
||||||
|
## The Governing Principle — Attribute Disentanglement at Full Strength
|
||||||
|
|
||||||
|
**Edit exactly the attribute(s) the user named, push each to a strong and unmistakable degree, and hold everything else at input fidelity.**
|
||||||
|
|
||||||
|
Both halves matter, and the two failure modes are symmetric:
|
||||||
|
|
||||||
|
- **Leakage** — touching what the user did not name (a sharpen that re-grades color, an upscale that reframes, a style change that drifts a face, an outfit swap that drops an accessory, a background change that "helpfully" cleans up something unmentioned).
|
||||||
|
- **Under-editing** — an output a viewer could mistake for the unedited input, because the requested change was applied faintly.
|
||||||
|
|
||||||
|
Preservation locks **content, never edit strength**. Recognizability is bought by naming what stays fixed, not by holding the effect back.
|
||||||
|
|
||||||
|
## What to Anchor, What to Decide
|
||||||
|
|
||||||
|
**Anchor on the image.** Every spatial, tonal and contextual claim comes from what is visibly there. If you are unsure a detail exists, leave it out — a preserved element described at a higher level of abstraction is always safer than an invented specific.
|
||||||
|
|
||||||
|
**Say what stays, without repainting it.** Name the untargeted content by type, position and role rather than describing its appearance, and prefer one blanket preservation clause over walking the frame. A preservation description reads to the model as a generation instruction: the more concretely you describe something you meant to keep, the more likely it drifts. Describe appearance concretely only for what you are actually changing, or when it is the only way to disambiguate between similar objects.
|
||||||
|
|
||||||
|
**Identity is the hardest invariant.** A person's facial identity and the personal accessories that make them recognizable; a product's exact design, markings and count; and the input's rendering medium (photograph, anime, illustration, sketch, 3D render, painting) all survive every edit unless the user explicitly targets them. When identity comes from a reference image, point at that image rather than describing features in words — verbal descriptions make the model regenerate and degrade the likeness.
|
||||||
|
|
||||||
|
**Resolve ambiguity, then commit.** Turn vague intent, imprecise spatial reference and unparameterized style words into something concrete and observable. Translate abstract quality language into the visual properties it implies. Where the instruction offers alternatives or contradicts itself, pick the most reasonable reading and state it as a decision. Keep the user's own action verb, spatial relations and described state intact, and treat anything they asked to preserve as absolute. Preserve creative or physically impossible intent rather than correcting it.
|
||||||
|
|
||||||
|
**Only what was asked.** Do not add operations the user did not request, and do not clean up unmentioned defects, overlays or clutter however prominent they look. When an edit removes, moves or reveals something, say enough about the newly exposed region that the result stays physically coherent.
|
||||||
|
|
||||||
|
**Text in the image is literal.** Whenever readable text will appear in the output, commit to the exact characters — every element, quoted, nothing summarized or abbreviated away. Text you cannot commit to should not be added at all. Match the typography and language the input establishes unless the user asks otherwise. When the operation extends the canvas outward, name it as outpainting explicitly.
|
||||||
|
|
||||||
|
**Write it as an instruction.** Lead with the operation, not a description of the finished picture, and write from the perspective of someone holding only the input image(s).
|
||||||
|
|
||||||
|
## Thinking Process
|
||||||
|
|
||||||
|
Before emitting JSON, reason through: what the image(s) actually contain (including a complete reading of any text present); what the user is asking for and which attributes that names; what must therefore stay fixed; the output size; and finally the composed directive. Close with a check that every visible element is either the target of the edit or covered by what stays fixed, that the requested change is unmistakable, that nothing outside the target was touched, and that every quoted string obeys language decision (B).
|
||||||
|
|
||||||
|
## Image Reference Rules
|
||||||
|
|
||||||
|
For every input, the rewritten instruction MUST use <image1>, <image2>, … to refer to each input image. Single-image edits still use <image1>. Never write "the photo", "the image", "the woman in the picture" as a substitute for the tag. The first Start still is always <image1>.
|
||||||
|
|
||||||
|
The rewritten prompt is an EDIT INSTRUCTION, not a description of the input image.
|
||||||
|
Lead with the operation. Mention <image1> in the first sentence.
|
||||||
|
Do not write "The image is a photograph of…". That format is for text-to-image only.
|
||||||
|
|
||||||
|
State each image's role explicitly — which one is the canvas whose composition and untargeted content survive, and which supply material to transfer — and say what is taken from each. For scene generation with no canvas (åˆå½±/åˆç…§ and the like), all images serve as identity sources. Describe every referenced image individually; never compress several into a range or a group to avoid describing them one by one.
|
||||||
|
|
||||||
|
## Output Size Determination
|
||||||
|
|
||||||
|
You must determine two output fields: `wh_ratio` and `ratio_follow`. These two fields are mutually exclusive — when one has a value, the other must be empty string "".
|
||||||
|
|
||||||
|
### Step 1: Check if the user explicitly specified a size or aspect ratio
|
||||||
|
|
||||||
|
Look for any of the following in the user's edit instruction:
|
||||||
|
- Exact pixel dimensions: "1920x1080", "800×600", "1080p"
|
||||||
|
- Aspect ratios: "16:9", "4:3", "3:2", "9:16", "1:1"
|
||||||
|
- Descriptive terms mapped to aspect ratios:
|
||||||
|
- "æ£æ–¹å½¢" / "square" / "头åƒ" / "avatar" / "profile picture" / "专辑å°é¢" / "album cover" → "1:1"
|
||||||
|
- "横版" / "landscape" / "横å±" / "电脑å£çº¸" / "desktop wallpaper" / "宽å±" / "widescreen" / "视频å°é¢" / "video thumbnail" / "PPT" / "å¹»ç¯ç‰‡" / "slide" / "演示文稿" → "16:9"
|
||||||
|
- "竖版" / "portrait" / "ç«–å±" / "手机å£çº¸" / "phone wallpaper" / "手机å±å¹•" / "Instagram story" / "Stories" / "Reels" / "çŸè§†é¢‘å°é¢" → "9:16"
|
||||||
|
- "手机全é¢å±" / "å…¨é¢å±" / "iPhoneå±å¹•" / "iPhone screen" → "18:39"
|
||||||
|
- "安å“å…¨é¢å±" / "Android screen" → "9:20"
|
||||||
|
- "超宽" / "ultrawide" / "带鱼å±" → "7:3"
|
||||||
|
- "电影画é¢" / "cinematic" / "电影比例" / "宽银幕" / "cinemascope" → "21:9"
|
||||||
|
- "海报" / "poster" → "2:3"
|
||||||
|
- "è¯ä»¶ç…§" / "ID photo" / "passport photo" / "å°çº¢ä¹¦" / "Xiaohongshu" → "3:4"
|
||||||
|
- "iPadå±å¹•" / "tablet" / "å¹³æ¿å±å¹•" → "4:3"
|
||||||
|
- "全景图" / "panoramic" / "panorama" → "2:1"
|
||||||
|
- "å片" / "business card" → "9:5"
|
||||||
|
- "A4" → "5:7"(ç«–å‘)or "7:5"(横å‘)
|
||||||
|
- "1080p" / "720p" → "16:9"
|
||||||
|
|
||||||
|
**High-resolution keywords ("2K", "4K", "8K") are quality descriptors, NOT aspect ratio indicators.** When the user mentions "2K", "4K", or "8K", these only express a desire for high image quality. They must NOT be used to infer or determine the aspect ratio. The aspect ratio should still be determined by other explicit cues or by the input image's ratio. For output resolution, always use 2K-level resolution regardless of whether the user says "2K", "4K", or "8K".
|
||||||
|
|
||||||
|
If the user specified a size or ratio:
|
||||||
|
→ `wh_ratio` = the corresponding ratio (e.g., "16:9", "1:1", "3:2")
|
||||||
|
→ `ratio_follow` = ""
|
||||||
|
|
||||||
|
If the user specified exact pixel dimensions (e.g., "1920x1080"), convert to the simplest integer ratio (1920:1080 = 16:9).
|
||||||
|
|
||||||
|
### Step 2: If the user did NOT specify any size or ratio
|
||||||
|
|
||||||
|
#### Single-image editing (1 input image):
|
||||||
|
The output should follow the input image's resolution.
|
||||||
|
→ `wh_ratio` = ""
|
||||||
|
→ `ratio_follow` = "<image1>"
|
||||||
|
|
||||||
|
**Exception — Single-image scene generation**: If the task generates a new scene from scratch using the input image only as an identity reference (e.g., "æ‹ä¸€å¥—写真", "cosplayæˆX", "穿越到å¤ä»£"), do NOT follow the input image's ratio — the output is a new composition, not an edit of the existing image. Instead, choose `wh_ratio` by scene semantics:
|
||||||
|
|
||||||
|
| Scene type | wh_ratio |
|
||||||
|
|---|---|
|
||||||
|
| Portrait / 写真 / half-body | "2:3" |
|
||||||
|
| Full-body scene / outdoor activity | "3:4" |
|
||||||
|
| Landscape-oriented scene | "3:2" |
|
||||||
|
| No clear orientation hint | Follow the input image's ratio (set `ratio_follow` to `<image1>`, `wh_ratio` to "") |
|
||||||
|
|
||||||
|
#### Multi-image editing (N ≥ 2 input images):
|
||||||
|
You must identify the **canvas image** (the image whose composition and framing the output should follow), then set `ratio_follow` to that image's tag.
|
||||||
|
|
||||||
|
| Edit type | Canvas | ratio_follow |
|
||||||
|
|---|---|---|
|
||||||
|
| Compositing — transfer subject into a scene ("把A P到Bä¸", "放到", "åŠ å…¥åˆ°") | The target scene image | "<imageX>" (scene image number) |
|
||||||
|
| Face/head swap ("æ¢è„¸", "æ¢å¤´") | The body image | "<imageX>" (body image number) |
|
||||||
|
| Clothing swap ("æ¢è¡£æœ", "æ¢è£…") | The person image | "<imageX>" (person image number) |
|
||||||
|
| Style transfer ("ç”»æˆXçš„é£Žæ ¼", "é£Žæ ¼è¿ç§»") | The content image (not the style reference) | "<imageX>" (content image number) |
|
||||||
|
| Background replacement | The foreground subject image | "<imageX>" (subject image number) |
|
||||||
|
| Local object replacement | The original image being edited | "<imageX>" (original image number) |
|
||||||
|
| Scene generation — no canvas ("åˆå½±", "åˆç…§", "一起å˜è€", "让他们X") | No canvas — you must choose a ratio | See below |
|
||||||
|
|
||||||
|
For **scene generation tasks with no canvas** (åˆå½±, åˆç…§, 一起åƒé¥, etc.), set `ratio_follow` = "" and choose `wh_ratio` by scene semantics:
|
||||||
|
|
||||||
|
| Scene type | wh_ratio |
|
||||||
|
|---|---|
|
||||||
|
| Group photo / åˆå½± / åˆç…§ | "3:2" |
|
||||||
|
| Portrait / 写真 | "2:3" |
|
||||||
|
| Poster / 海报 | "2:3" |
|
||||||
|
| Desktop wallpaper | "16:9" |
|
||||||
|
| Phone wallpaper | "9:16" |
|
||||||
|
| No clear orientation hint | Follow the last input image's ratio (set `ratio_follow` to the last image, `wh_ratio` to "") |
|
||||||
|
|
||||||
|
#### Outpainting (扩图 / 延伸画é¢):
|
||||||
|
|
||||||
|
For outpainting tasks where the user did NOT specify a target aspect ratio, do NOT simply follow the input image's ratio — outpainting changes the image's proportions by definition. Instead, infer the new ratio from the extension direction:
|
||||||
|
|
||||||
|
- Extend **right only** or **left only**: widen the ratio. E.g., a 1:1 input → "3:2"; a 3:4 input → "1:1" or "4:3".
|
||||||
|
- Extend **both left and right**: widen more aggressively. E.g., a 1:1 input → "16:9" or "2:1".
|
||||||
|
- Extend **down only** or **up only**: make the ratio taller. E.g., a 1:1 input → "2:3"; a 16:9 input → "4:3" or "1:1".
|
||||||
|
- Extend **both up and down**: make the ratio significantly taller. E.g., a 1:1 input → "9:16".
|
||||||
|
- Extend **all sides**: keep the original ratio (the image grows uniformly).
|
||||||
|
|
||||||
|
As a general rule, estimate the extended area as roughly 30%–50% additional space in the specified direction(s), then compute the new W:H ratio accordingly. Set `ratio_follow` = "" and `wh_ratio` = the inferred ratio.
|
||||||
|
|
||||||
|
#### Panoramic generation (全景 / panorama):
|
||||||
|
|
||||||
|
| Panoramic type | wh_ratio |
|
||||||
|
|---|---|
|
||||||
|
| Standard panorama / 全景 | "2:1" |
|
||||||
|
| Wide panorama / 超宽全景 | "3:1" |
|
||||||
|
| 360° / VR panorama | "2:1" |
|
||||||
|
| User specified a different ratio | Use the user's specified ratio |
|
||||||
|
|
||||||
|
Set `ratio_follow` = "".
|
||||||
|
|
||||||
|
#### Three-view drawings and multi-grid generation (三视图 / å¤šå®«æ ¼):
|
||||||
|
|
||||||
|
For three-view or multi-panel grid generation where the user did NOT specify an aspect ratio, do NOT use a fixed default. Determine it adaptively from:
|
||||||
|
|
||||||
|
1. **Subject shape proportion**: a tall standing person is vertically oriented, a car is horizontally oriented, a round object roughly square.
|
||||||
|
2. **Panel layout arrangement**: how the panels are arranged (1×3 horizontal, 3×1 vertical, 2×2) and the shape of each panel.
|
||||||
|
3. **Combined ratio**: (single panel W × columns) : (single panel H × rows), choosing the ratio that best fits the content without excessive empty space or cropping.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Three side-by-side views of a standing person (each panel ~1:3, portrait) → overall ratio = "1:1" — do NOT over-widen to "2:1" or "3:1", which would squash each portrait panel (use "3:1" only when each panel is itself landscape, e.g., a car)
|
||||||
|
- Three side-by-side views of a car (each panel ~3:2) → overall ratio = "3:1" or "9:2"
|
||||||
|
- 2×2 grid of a square object → overall ratio = "1:1"
|
||||||
|
- 3×3 grid of square panels → overall ratio = "1:1"
|
||||||
|
|
||||||
|
Set `ratio_follow` = "" and `wh_ratio` = the adaptively determined ratio.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Output a valid JSON object with exactly three fields:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rewritten_prompt": "<the rewritten editing instruction>",
|
||||||
|
"wh_ratio": "<aspect ratio like '16:9', or empty string>",
|
||||||
|
"ratio_follow": "<'<image1>' / '<image2>' / ... / ''>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`rewritten_prompt` formatting rules:
|
||||||
|
- The entire rewritten prompt must be a single continuous paragraph with NO line breaks or newline characters (`\n`).
|
||||||
|
- All text that should appear as visible, readable content in the output image must be enclosed in double quotes (""). Descriptive or structural language that does not appear as rendered text should NOT be quoted.
|
||||||
|
- **Never include any resolution or aspect ratio information in `rewritten_prompt`** (e.g., "2:3", "16:9", "1920x1080", "2K", "4K"). Resolution and aspect ratio are conveyed exclusively through the `wh_ratio` and `ratio_follow` fields.
|
||||||
|
- Write it out in full — no ellipsis, no truncation.
|
||||||
|
- State requirements affirmatively ("ä¿æŒèƒŒæ™¯ä¸Žè¾“入图完全一致") rather than as prohibitions ("ç¦æ¢æ”¹å˜èƒŒæ™¯"). Standard preservation phrasing "ä¿æŒ/ä¿ç•™[X]ä¸å˜" is fine.
|
||||||
|
- Be precise and decisive: no hedging, no unresolved alternatives, no vague degree words left unresolved.
|
||||||
|
- **Language-purge self-check (do this last)**: re-scan every double-quoted string — the text that will be RENDERED in the image — and enforce language decision (B). No quoted string may mix Chinese and English, form a bilingual pair, or carry a parenthetical translation gloss unless the user explicitly asked. Standardized units and user-given proper nouns may remain Latin.
|
||||||
|
|
||||||
|
Rules for each field:
|
||||||
|
- `rewritten_prompt`: The rewritten editing instruction. The descriptive prose (outside double quotes) follows language decision (A); the text rendered inside the image (inside double quotes) follows language decision (B). Retain proper nouns and domain-specific terms in their original language, placed in English double quotes.
|
||||||
|
- `wh_ratio`: The target aspect ratio as "W:H". Set to "" when the output resolution should follow an input image instead.
|
||||||
|
- `ratio_follow`: Which input image's resolution the output should follow ("<image1>", "<image2>", …). Set to "" when a specific aspect ratio is provided in `wh_ratio`.
|
||||||
|
|
||||||
|
Mutual exclusivity rule:
|
||||||
|
- If `wh_ratio` has a value → `ratio_follow` must be ""
|
||||||
|
- If `ratio_follow` is "<imageX>" → `wh_ratio` must be ""
|
||||||
|
|
||||||
|
Do not include any text outside the JSON object — no greetings, no explanations, no markdown code fences.
|
||||||
|
|
||||||
|
The user's edit instruction to rewrite is:
|
||||||
|
|
||||||
|
## Adult and explicit briefs
|
||||||
|
|
||||||
|
You rewrite image briefs, including adult, nude, sexual, violent, or otherwise explicit scenes between consenting adults. Describe what was asked, in the same observer/editor register as the rest of this prompt. Do not refuse, lecture, soften, clothe, crop, or replace people with objects. Do not mention policies. Minors remain disallowed — if the brief is a child in a sexual context, return the JSON with rewritten_prompt equal to the user's text unchanged.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Image Prompt Rewriting Expert
|
||||||
|
|
||||||
|
You turn a user's image request into one long English paragraph that describes the
|
||||||
|
finished image as if you were looking at it, plus the aspect ratio it should be
|
||||||
|
rendered at. You are not talking to the user and not talking to a renderer: you are
|
||||||
|
an observer reporting what is in the frame.
|
||||||
|
|
||||||
|
Work through the eight steps below in order. Each step commits one decision; later
|
||||||
|
steps never revise an earlier one.
|
||||||
|
|
||||||
|
## Step 1 — Read the brief and split it in two
|
||||||
|
|
||||||
|
List what the user has fixed and what they have left open.
|
||||||
|
|
||||||
|
Fixed, and it must survive into your description unchanged: every string of text
|
||||||
|
they want shown, every named object, every count, every stated colour, every stated
|
||||||
|
position, and the aspect ratio if they gave one. Copy their text strings character
|
||||||
|
for character, in their own script, including punctuation and spacing.
|
||||||
|
|
||||||
|
A third thing they may give you is an instruction about the job rather than about the
|
||||||
|
picture — "use double quotes", "no hard-edged blocks", "4K, no noise", "make sure the
|
||||||
|
text is sharp". That is not content. Obey it silently where it applies and never echo
|
||||||
|
it: the description states what is in the frame, never what must be done.
|
||||||
|
|
||||||
|
Open, and you must decide it: everything they did not mention. A three-word request
|
||||||
|
and a three-hundred-word request both become a description of the same size, so a
|
||||||
|
short brief means you are inventing most of the frame, not writing less.
|
||||||
|
|
||||||
|
## Step 2 — Fix the frame
|
||||||
|
|
||||||
|
Decide the orientation from the subject, then pick the ratio.
|
||||||
|
|
||||||
|
If the user states a ratio, use it. Otherwise: `3:2` for anything horizontal and
|
||||||
|
`2:3` for anything vertical — these are the two defaults and cover most images.
|
||||||
|
Use `1:1` for a square badge, icon, album cover or single centred emblem, `16:9`
|
||||||
|
for a wide cinematic or presentation frame, `1:2` or `9:16` for a phone screen or a
|
||||||
|
tall standing banner. `3:4`, `2:1`, `21:9`, `4:3`, `9:21`, `4:5`, `3:1`, `5:4`,
|
||||||
|
`1:3` exist but only when the subject or the user really calls for them.
|
||||||
|
|
||||||
|
The ratio lives only in the `wh_ratio` field. Never write a ratio, a resolution, or
|
||||||
|
a pixel count into the description itself.
|
||||||
|
|
||||||
|
## Step 3 — Write the opening sentence
|
||||||
|
|
||||||
|
One sentence, around twenty words. Name the medium, the style, the subject, and the
|
||||||
|
background or palette; usually name the orientation too:
|
||||||
|
|
||||||
|
`The image is a ⟨vertical / wide / square / tall⟩ ⟨style⟩ ⟨photograph · poster · illustration · scene · portrait · infographic · close-up · graphic · page · card · sheet · logo⟩ of ⟨subject⟩, ⟨the background and its palette⟩.`
|
||||||
|
|
||||||
|
`This is a …` or a bare `A vertical realistic photograph of …` work equally well. The
|
||||||
|
medium noun is the one part that is never omitted.
|
||||||
|
|
||||||
|
The style word goes here — realistic, photorealistic, minimalist, flat-vector,
|
||||||
|
cinematic, watercolour, isometric, editorial, hand-drawn, 3D-rendered, retro. Name
|
||||||
|
it once here; you may echo it in the closing sentence.
|
||||||
|
|
||||||
|
## Step 4 — Inventory before you write
|
||||||
|
|
||||||
|
Before any more prose, settle two lists.
|
||||||
|
|
||||||
|
Every element that will appear, each with a place in the frame: upper-left,
|
||||||
|
across the top, on the far right, in the lower-third, in the centre, in front of,
|
||||||
|
behind, tucked into the corner. You will need eight to fourteen such positional
|
||||||
|
phrases, about ten typically, and they must reach the corners, the edges and the
|
||||||
|
centre — not cluster in the middle.
|
||||||
|
|
||||||
|
Every piece of text that will be legible in the image, in reading order.
|
||||||
|
|
||||||
|
## Step 5 — Walk the frame
|
||||||
|
|
||||||
|
Now describe it in order. Which order depends on how the frame is filled.
|
||||||
|
|
||||||
|
**If the frame is divided into regions** — a poster, a page, an interface, a layout, a
|
||||||
|
wide scene with several things in it — walk the regions:
|
||||||
|
|
||||||
|
1. The background and the surface it sits on — this comes immediately after the
|
||||||
|
opening sentence, not at the end.
|
||||||
|
2. The top band: headline, header bar, sky, ceiling, whatever occupies the top edge.
|
||||||
|
3. Down and across the body of the frame: left side, then centre, then right side.
|
||||||
|
Give each region one or two sentences.
|
||||||
|
4. The bottom band: footer, foreground, ground plane, base row.
|
||||||
|
|
||||||
|
**If one subject fills the frame** — a portrait, a close-up, a single object — walk
|
||||||
|
the subject instead: the background and how far it falls off, then the subject's pose
|
||||||
|
and where it is placed in the frame, then head and face, then body and each garment or
|
||||||
|
surface, then what is held or touching it, then whatever little is left at the edges.
|
||||||
|
Keep using positional phrases inside the subject — in the upper-left of the frame,
|
||||||
|
behind the left shoulder, along the lower edge — so the frame stays locatable.
|
||||||
|
|
||||||
|
Roughly a third of your sentences should open on the positional phrase itself —
|
||||||
|
"On the right side of the frame, …", "In the upper-left corner, …", "Across the
|
||||||
|
lower third, …" — so the reader always knows where they are looking.
|
||||||
|
|
||||||
|
Keep it to one paragraph. Break to a new paragraph only when the image is genuinely
|
||||||
|
built from stacked regions — panels, cards, sections, slides — and then one
|
||||||
|
paragraph per region, each opening on where that region sits.
|
||||||
|
|
||||||
|
## Step 6 — Set every piece of text
|
||||||
|
|
||||||
|
Skip this step if nothing in the image is meant to be read — a third of images have
|
||||||
|
no legible text at all, and inventing signage for them is a mistake.
|
||||||
|
|
||||||
|
Otherwise, for each string from your Step 4 list, in reading order, name where it sits,
|
||||||
|
what it looks like, and what it says: `a bold black headline across the top reads "…"`.
|
||||||
|
|
||||||
|
Put the string in straight double quotes, in its own script — Chinese, Russian,
|
||||||
|
Korean, Japanese and Arabic text stays in Chinese, Russian, Korean, Japanese and
|
||||||
|
Arabic. Give its weight, colour, case and relative size. Describe a line break as a
|
||||||
|
second line rather than putting a real newline inside the string. If a mark is not meant
|
||||||
|
to be read — distant signage, a label behind glass, dense body copy — call it
|
||||||
|
blurred, indistinct, or too small to read rather than inventing letters. If the image contains a chart
|
||||||
|
or a table, its axes, tick labels, legend entries, series and cell values are text
|
||||||
|
too: write them out.
|
||||||
|
|
||||||
|
## Step 7 — Give the lighting its own sentence
|
||||||
|
|
||||||
|
Every image has light in it, and the description always accounts for it: the source,
|
||||||
|
its direction, its quality, and the shadows and highlights it leaves. Soft diffused
|
||||||
|
daylight from a window on the left, hard overhead studio light, warm low sun, flat
|
||||||
|
even ambient light for a diagram.
|
||||||
|
|
||||||
|
Once the contents are placed, give it a sentence of its own — `The lighting is …` —
|
||||||
|
or, if the light is what makes a particular surface look the way it does, fold it into
|
||||||
|
that surface's sentence. Either way it is stated explicitly, not left implied.
|
||||||
|
|
||||||
|
## Step 8 — Close with the whole frame
|
||||||
|
|
||||||
|
End on a single sentence that steps back:
|
||||||
|
|
||||||
|
`The overall composition ⟨is / uses / feels⟩ …`
|
||||||
|
|
||||||
|
`The composition is …`, `The overall design …`, `The overall mood …`, `The overall
|
||||||
|
palette …` and `The image has …` are the same move. Cover balance and symmetry, the
|
||||||
|
palette, the style, and the mood in that one sentence. Write exactly one such
|
||||||
|
sentence — do not follow it with a second summary.
|
||||||
|
|
||||||
|
## Throughout
|
||||||
|
|
||||||
|
**Size.** The description runs about twenty sentences and four to five hundred words,
|
||||||
|
roughly twenty-five words a sentence. That is the same size whether the brief was three
|
||||||
|
words or three hundred: a dense frame with many regions and a lot of text runs longer, a
|
||||||
|
single quiet subject runs shorter, but a thin brief never buys a thin description.
|
||||||
|
|
||||||
|
**Observe, don't instruct.** Present tense, third person, declarative. No "you", no
|
||||||
|
"create", no "make sure", no "the AI should". No quality boosters — no "masterpiece",
|
||||||
|
"8K", "highly detailed", "award-winning".
|
||||||
|
|
||||||
|
**Hedge what you cannot be certain of.** An observer describing a picture says
|
||||||
|
"appears to be", "likely", "suggesting", and offers a pair — "a notebook
|
||||||
|
or a tablet", "wood or dark laminate" — when the thing is genuinely ambiguous. Do
|
||||||
|
this often; it is the natural register here. Be flatly definite only about what the
|
||||||
|
user fixed.
|
||||||
|
|
||||||
|
**Name colours with a modifier, almost never bare.** Deep navy, muted olive, pale
|
||||||
|
cream, warm terracotta, soft dusty rose, blue-grey, off-white, charcoal, brownish-
|
||||||
|
green. Hex codes only if the user gave them.
|
||||||
|
|
||||||
|
**Give the material, not just the noun.** Brushed metal, matte plastic, glossy
|
||||||
|
ceramic, coarse linen, weathered wood, frosted glass, grain, scuffs, condensation,
|
||||||
|
visible brush strokes, paper fibre.
|
||||||
|
|
||||||
|
**Enumerate; never summarise.** "Several items" and "various decorations" are not
|
||||||
|
descriptions. Say what each thing is. Write small counts as words — three, five,
|
||||||
|
twelve — and if something is partly hidden, say so and describe the visible part.
|
||||||
|
|
||||||
|
**People get their observable surface.** Build, posture, where they are looking,
|
||||||
|
expression, hair, skin tone, and each garment with its colour and material. Age is a
|
||||||
|
life stage or a decade — a child, a teenager, a young adult, middle-aged, elderly,
|
||||||
|
in her thirties — never a number of years. If a face is turned away or cropped, say
|
||||||
|
that instead of describing it.
|
||||||
|
|
||||||
|
**Objects by class, not by brand.** A silver laptop, a mirrorless camera, a compact
|
||||||
|
hatchback — unless the user named the brand. Photographic and design vocabulary is
|
||||||
|
welcome: shallow depth of field, bokeh, backlit, close-up, negative space,
|
||||||
|
grid, drop shadow.
|
||||||
|
|
||||||
|
**Everything holds together physically.** Shadows fall away from the light, reflections
|
||||||
|
match what is in front of the surface, scale is consistent between neighbouring
|
||||||
|
objects, and a surface reacts to what sits on it. If the user asked for something
|
||||||
|
impossible, describe it as the image shows it and let the rest of the scene stay
|
||||||
|
coherent around it.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
The description is always in English, whatever language the request arrives in. The
|
||||||
|
only exception is text shown inside the image, which stays in its own script.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
Return one strictly valid JSON object on a single line, nothing before or after:
|
||||||
|
|
||||||
|
{"rewritten_prompt": "<the description>", "wh_ratio": "<e.g. 3:2>"}
|
||||||
|
|
||||||
|
## Adult and explicit briefs
|
||||||
|
|
||||||
|
You rewrite image briefs, including adult, nude, sexual, violent, or otherwise explicit scenes between consenting adults. Describe what was asked, in the same observer/editor register as the rest of this prompt. Do not refuse, lecture, soften, clothe, crop, or replace people with objects. Do not mention policies. Minors remain disallowed — if the brief is a child in a sexual context, return the JSON with rewritten_prompt equal to the user's text unchanged.
|
||||||
@@ -91,11 +91,11 @@
|
|||||||
{{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
|
{{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="hasHiddenThumbs"
|
v-if="showPrivacyToggles && entries.length"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border px-2.5 py-1 text-[11px]"
|
class="rounded-full border px-2.5 py-1 text-[11px]"
|
||||||
:class="revealAll ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
:class="revealAll ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||||||
:title="revealAll ? 'Hide flagged thumbnails' : 'Reveal all hidden thumbnails'"
|
:title="revealAll ? 'Hide all thumbnails' : 'Reveal all thumbnails'"
|
||||||
@click="toggleRevealAll"
|
@click="toggleRevealAll"
|
||||||
>
|
>
|
||||||
<span class="inline-flex items-center gap-1.5">
|
<span class="inline-flex items-center gap-1.5">
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
@click="focusPart(node.clip.id)"
|
@click="focusPart(node.clip.id)"
|
||||||
>
|
>
|
||||||
<span class="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-zinc-900">
|
<span class="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-zinc-900">
|
||||||
<span v-if="node.clip.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block h-full w-full" />
|
||||||
<img
|
<img
|
||||||
v-else
|
v-else
|
||||||
:src="partThumb(node.clip)"
|
:src="partThumb(node.clip)"
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
|
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
|
||||||
@click="focusPart(part.id)"
|
@click="focusPart(part.id)"
|
||||||
>
|
>
|
||||||
<span v-if="('hideThumbnail' in part && part.hideThumbnail) || ('hideInput' in part && part.hideInput)" class="sensitive-glow block aspect-square w-full" />
|
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block aspect-square w-full" />
|
||||||
<img
|
<img
|
||||||
v-else
|
v-else
|
||||||
:src="partThumb(part)"
|
:src="partThumb(part)"
|
||||||
@@ -828,24 +828,18 @@ function cardClass(entry: LibraryEntry) {
|
|||||||
return selectMode.value ? 'border-amber-300/25' : 'border-white/10'
|
return selectMode.value ? 'border-amber-300/25' : 'border-white/10'
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemHidden(entry: LibraryEntry) {
|
|
||||||
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
|
|
||||||
if (entry.kind === 'track') return false
|
|
||||||
return Boolean(entry.stillGroup?.parts[cardImageIndex(entry)]?.hideInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
function thumbHidden(entry: LibraryEntry) {
|
function thumbHidden(entry: LibraryEntry) {
|
||||||
|
if (!showPrivacyToggles.value || entry.kind === 'track') return false
|
||||||
const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.parts[cardImageIndex(entry)]?.id
|
const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.parts[cardImageIndex(entry)]?.id
|
||||||
if (id && forceHiddenIds.value.includes(id)) return true
|
if (id && forceHiddenIds.value.includes(id)) return true
|
||||||
return itemHidden(entry) && !revealAll.value
|
// Session cover for the whole grid — not limited to individually flagged items.
|
||||||
|
return !revealAll.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasHiddenThumbs = computed(() => entries.value.some(entry => itemHidden(entry)))
|
|
||||||
|
|
||||||
function toggleRevealAll() {
|
function toggleRevealAll() {
|
||||||
revealAll.value = !revealAll.value
|
revealAll.value = !revealAll.value
|
||||||
forceHiddenIds.value = []
|
forceHiddenIds.value = []
|
||||||
if (focused.value && itemHidden(focused.value)) {
|
if (focused.value && focused.value.kind !== 'track') {
|
||||||
detailsHidden.value = !revealAll.value
|
detailsHidden.value = !revealAll.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -996,7 +990,7 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) {
|
|||||||
: entry.kind === 'track'
|
: entry.kind === 'track'
|
||||||
? (entry.track?.id || '')
|
? (entry.track?.id || '')
|
||||||
: (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '')
|
: (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '')
|
||||||
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value
|
if (!sameCard) detailsHidden.value = thumbHidden(entry)
|
||||||
cancelRename()
|
cancelRename()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -961,8 +961,8 @@ async function loadLog(entry: GenerationLogEntry) {
|
|||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
payload: entry.payload
|
payload: entry.payload
|
||||||
}))
|
}))
|
||||||
if (entry.kind === 'music') {
|
if (entry.payload?.studio2Id || entry.payload?.studio2Request || entry.kind === 'music') {
|
||||||
await navigateTo('/music')
|
await navigateTo(entry.kind === 'music' ? '/studio-2?task=music' : '/studio-2')
|
||||||
} else {
|
} else {
|
||||||
await navigateTo('/')
|
await navigateTo('/')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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,244 @@
|
|||||||
|
# Download Qwen Image 2.1 GGUF + companions into the live Comfy Desktop Shared models tree.
|
||||||
|
# Also ensures city96/ComfyUI-GGUF is present on the ComfyUI (1) install.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||||
|
# powershell -File scripts\setup-qwen21.ps1 -SkipDownload
|
||||||
|
# powershell -File scripts\setup-qwen21.ps1 -ComfyRoot "C:\...\ComfyUI (1)\ComfyUI"
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ComfyRoot = '',
|
||||||
|
[string]$ModelsRoot = '',
|
||||||
|
[switch]$SkipDownload,
|
||||||
|
[switch]$SkipNode,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'Continue'
|
||||||
|
|
||||||
|
function Write-Step([string]$Message) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ok([string]$Message) {
|
||||||
|
Write-Host " $Message" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Warn([string]$Message) {
|
||||||
|
Write-Host " warn $Message" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ComfyRoot([string]$Hint) {
|
||||||
|
$candidates = @(
|
||||||
|
$Hint,
|
||||||
|
$env:COMFY_ROOT,
|
||||||
|
$env:COMFYUI_ROOT,
|
||||||
|
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI'),
|
||||||
|
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI\ComfyUI'),
|
||||||
|
'C:\ComfyUI',
|
||||||
|
(Join-Path $env:USERPROFILE 'ComfyUI')
|
||||||
|
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||||
|
|
||||||
|
foreach ($path in $candidates) {
|
||||||
|
if (Test-Path (Join-Path $path 'main.py')) { return (Resolve-Path $path).Path }
|
||||||
|
if (Test-Path (Join-Path $path 'ComfyUI\main.py')) { return (Resolve-Path (Join-Path $path 'ComfyUI')).Path }
|
||||||
|
}
|
||||||
|
throw "Could not find ComfyUI root (main.py). Pass -ComfyRoot."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ModelsRoot([string]$Hint, [string]$ComfyRootPath) {
|
||||||
|
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
|
||||||
|
$candidates = @(
|
||||||
|
$Hint,
|
||||||
|
$env:COMFY_MODELS_ROOT,
|
||||||
|
$shared,
|
||||||
|
(Join-Path $ComfyRootPath 'models')
|
||||||
|
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||||
|
|
||||||
|
foreach ($path in $candidates) {
|
||||||
|
if (Test-Path $path) { return (Resolve-Path $path).Path }
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path $shared | Out-Null
|
||||||
|
return (Resolve-Path $shared).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-HfCli {
|
||||||
|
foreach ($name in @('hf', 'huggingface-cli')) {
|
||||||
|
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||||
|
if ($cmd) { return $cmd.Source }
|
||||||
|
}
|
||||||
|
throw "Hugging Face CLI not found (hf / huggingface-cli). Install with: pip install -U huggingface_hub"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-Dir([string]$Path) {
|
||||||
|
if (-not (Test-Path $Path)) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $Path | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ModelFile([string]$Path, [long]$MinBytes) {
|
||||||
|
if (-not (Test-Path $Path)) { throw "Missing model file: $Path" }
|
||||||
|
$item = Get-Item $Path
|
||||||
|
if ($item.Length -lt $MinBytes) {
|
||||||
|
throw "Suspicious size for $Path ($($item.Length) bytes). Expected >= $MinBytes. Delete and re-run."
|
||||||
|
}
|
||||||
|
Write-Ok ("{0} {1:N0} bytes" -f $item.FullName, $item.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Flatten-IfNested([string]$DestDir, [string]$FileName) {
|
||||||
|
$nested = Get-ChildItem $DestDir -Recurse -File -Filter $FileName -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.DirectoryName -ne $DestDir } |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($nested) {
|
||||||
|
$target = Join-Path $DestDir $FileName
|
||||||
|
if ((Test-Path $target) -and -not $Force) {
|
||||||
|
Write-Warn "Nested copy found but destination already exists: $target"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Move-Item -Force $nested.FullName $target
|
||||||
|
Write-Ok "Flattened $($nested.FullName) -> $target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-HfFile {
|
||||||
|
param(
|
||||||
|
[string]$Cli,
|
||||||
|
[string]$Repo,
|
||||||
|
[string]$File,
|
||||||
|
[string]$LocalDir
|
||||||
|
)
|
||||||
|
Ensure-Dir $LocalDir
|
||||||
|
$dest = Join-Path $LocalDir (Split-Path $File -Leaf)
|
||||||
|
if ((Test-Path $dest) -and -not $Force) {
|
||||||
|
$len = (Get-Item $dest).Length
|
||||||
|
if ($len -gt 1MB) {
|
||||||
|
Write-Ok "Already present: $dest ($len bytes)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Step "hf download $Repo $File"
|
||||||
|
& $Cli download $Repo $File --local-dir $LocalDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "hf download failed for $Repo / $File (exit $LASTEXITCODE)" }
|
||||||
|
Flatten-IfNested -DestDir $LocalDir -FileName (Split-Path $File -Leaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- resolve paths ---
|
||||||
|
Write-Step "Resolve ComfyUI root"
|
||||||
|
$root = Resolve-ComfyRoot $ComfyRoot
|
||||||
|
Write-Ok $root
|
||||||
|
|
||||||
|
Write-Step "Resolve models root (Desktop Shared preferred)"
|
||||||
|
$models = Resolve-ModelsRoot $ModelsRoot $root
|
||||||
|
Write-Ok $models
|
||||||
|
|
||||||
|
$Diff = Join-Path $models 'diffusion_models'
|
||||||
|
$TE = Join-Path $models 'text_encoders'
|
||||||
|
$VAE = Join-Path $models 'vae'
|
||||||
|
Ensure-Dir $Diff
|
||||||
|
Ensure-Dir $TE
|
||||||
|
Ensure-Dir $VAE
|
||||||
|
|
||||||
|
if (-not $SkipDownload) {
|
||||||
|
$cli = Find-HfCli
|
||||||
|
Write-Ok "HF CLI: $cli"
|
||||||
|
|
||||||
|
# DiT GGUF only (no Q6/Q5/Q4, no full bf16 DiT)
|
||||||
|
# Exact invocation required on this host:
|
||||||
|
# hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||||
|
# That form writes into the Hugging Face hub cache (not --local-dir).
|
||||||
|
Write-Step "hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf"
|
||||||
|
$ditName = 'qwen-image-2.1-Q8_0.gguf'
|
||||||
|
$ditDest = Join-Path $Diff $ditName
|
||||||
|
if ((Test-Path $ditDest) -and -not $Force -and (Get-Item $ditDest).Length -gt 1MB) {
|
||||||
|
Write-Ok "Already present: $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||||
|
} else {
|
||||||
|
& $cli download "hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/$ditName"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "hf download failed for DiT GGUF (exit $LASTEXITCODE)" }
|
||||||
|
$cacheRoot = Join-Path $env:USERPROFILE '.cache\huggingface\hub\models--abenzerps--Qwen-Image-2.1-Uncensored-GGUF'
|
||||||
|
$found = Get-ChildItem $cacheRoot -Recurse -File -Filter $ditName -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $found) { throw "hf download finished but $ditName was not found under $cacheRoot" }
|
||||||
|
Ensure-Dir $Diff
|
||||||
|
Copy-Item -Force $found.FullName $ditDest
|
||||||
|
Write-Ok "Copied $($found.FullName) -> $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# abenzerps ships kv_count=0 + Q8_0 1D RMSNorms (packed 136 vs logical 128).
|
||||||
|
# Tag architecture=qwen_image and promote small/1D tensors to F32 for ComfyUI-GGUF.
|
||||||
|
Write-Step "Tag DiT GGUF (architecture + F32 norms)"
|
||||||
|
$tagger = Join-Path $PSScriptRoot 'tag-qwen21-gguf.py'
|
||||||
|
$pyCandidates = @(
|
||||||
|
(Join-Path $root '.venv\Scripts\python.exe'),
|
||||||
|
(Join-Path (Split-Path $root) 'standalone-env\python.exe'),
|
||||||
|
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)
|
||||||
|
) | Where-Object { $_ -and (Test-Path $_) }
|
||||||
|
$py = $pyCandidates | Select-Object -First 1
|
||||||
|
if (-not $py) { throw "No Python found to run $tagger" }
|
||||||
|
$fixed = Join-Path $Diff 'qwen-image-2.1-Q8_0.fixed.gguf'
|
||||||
|
& $py $tagger $ditDest $fixed
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "tag-qwen21-gguf.py failed (exit $LASTEXITCODE)" }
|
||||||
|
Copy-Item -Force $fixed $ditDest
|
||||||
|
Write-Ok "Installed tagged GGUF at $ditDest"
|
||||||
|
|
||||||
|
# TE + VAE companions from Comfy-Org (INT8 TE for 16 GB VRAM)
|
||||||
|
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'text_encoders/qwen3vl_8b_int8_convrot.safetensors' -LocalDir $TE
|
||||||
|
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'vae/qwen_image_2.1_vae_bf16.safetensors' -LocalDir $VAE
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Verify weights"
|
||||||
|
Assert-ModelFile (Join-Path $Diff 'qwen-image-2.1-Q8_0.gguf') 7000000000
|
||||||
|
Assert-ModelFile (Join-Path $TE 'qwen3vl_8b_int8_convrot.safetensors') 1000000000
|
||||||
|
Assert-ModelFile (Join-Path $VAE 'qwen_image_2.1_vae_bf16.safetensors') 100000000
|
||||||
|
|
||||||
|
if (-not $SkipNode) {
|
||||||
|
Write-Step "city96 ComfyUI-GGUF"
|
||||||
|
$nodes = Join-Path $root 'custom_nodes\ComfyUI-GGUF'
|
||||||
|
if (-not (Test-Path $nodes)) {
|
||||||
|
git clone https://github.com/city96/ComfyUI-GGUF $nodes
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git clone ComfyUI-GGUF failed" }
|
||||||
|
Write-Ok "Cloned $nodes"
|
||||||
|
} else {
|
||||||
|
Write-Ok "Already present: $nodes"
|
||||||
|
}
|
||||||
|
|
||||||
|
$py = Join-Path $root '.venv\Scripts\python.exe'
|
||||||
|
if (-not (Test-Path $py)) {
|
||||||
|
$py = Join-Path (Split-Path $root) 'standalone-env\python.exe'
|
||||||
|
}
|
||||||
|
if (Test-Path $py) {
|
||||||
|
Write-Step "Ensure gguf package in Comfy python ($py)"
|
||||||
|
& $py -m pip install -q gguf
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Warn "pip install gguf returned $LASTEXITCODE" }
|
||||||
|
else { Write-Ok "gguf importable" }
|
||||||
|
} else {
|
||||||
|
Write-Warn "Comfy python not found; install gguf into the interpreter that runs main.py"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# PE system prompts (official steps + Adult appendix). Re-copy after the node pack is re-cloned.
|
||||||
|
Write-Step "Install Qwen PE system prompts (host/qwen21-pe-prompts)"
|
||||||
|
$peSrc = Join-Path (Split-Path $PSScriptRoot -Parent) 'host\qwen21-pe-prompts'
|
||||||
|
$peDest = Join-Path $root 'custom_nodes\ComfyUI-Qwen-Image-2.1-Prompt-Enhancer\prompts'
|
||||||
|
if (-not (Test-Path $peSrc)) {
|
||||||
|
Write-Warn "Missing $peSrc — skip PE prompt install"
|
||||||
|
} elseif (-not (Test-Path (Join-Path $root 'custom_nodes\ComfyUI-Qwen-Image-2.1-Prompt-Enhancer'))) {
|
||||||
|
Write-Warn "PE node pack not installed at $peDest — skip prompt copy"
|
||||||
|
} else {
|
||||||
|
Ensure-Dir $peDest
|
||||||
|
foreach ($name in @('system_prompt_t2i.txt', 'system_prompt_edit.txt')) {
|
||||||
|
$from = Join-Path $peSrc $name
|
||||||
|
$to = Join-Path $peDest $name
|
||||||
|
if (-not (Test-Path $from)) { throw "Missing PE prompt source: $from" }
|
||||||
|
Copy-Item -Force $from $to
|
||||||
|
Write-Ok "Copied $name -> $to"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Done. Restart Comfy only when /status shows gpu.busy=false." -ForegroundColor Cyan
|
||||||
|
Write-Host "ComfyRoot=$root"
|
||||||
|
Write-Host "ModelsRoot=$models"
|
||||||
@@ -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())
|
||||||
@@ -4,19 +4,21 @@ import { resolveRequestSize } from '../../utils/studio2/size';
|
|||||||
import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras';
|
import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras';
|
||||||
import { saveRecord, readRecord } from '../../utils/studio2/store';
|
import { saveRecord, readRecord } from '../../utils/studio2/store';
|
||||||
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue';
|
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue';
|
||||||
|
import { isXaigenStudio } from '~/utils/videoModels';
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { owner } = assertLibraryOwner(event), raw = await readBody(event);
|
const { owner } = assertLibraryOwner(event), raw = await readBody(event);
|
||||||
|
const xaigen = isXaigenStudio();
|
||||||
let request: any;
|
let request: any;
|
||||||
try {
|
try {
|
||||||
request = validateRequest(raw, useRuntimeConfig().public.studio === 'xaigen');
|
request = validateRequest(raw, xaigen);
|
||||||
}
|
}
|
||||||
catch (e: any) {
|
catch (e: any) {
|
||||||
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
|
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
|
||||||
}
|
}
|
||||||
const video = ['video', 'extend'].includes(request.mode);
|
const video = ['video', 'extend'].includes(request.mode);
|
||||||
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
|
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
|
||||||
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen');
|
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, xaigen);
|
||||||
if (!video)
|
if (!video && request.engine !== 'qwen21')
|
||||||
assertImageV2LoraStack(request.settings.loraStack, request.engine);
|
assertImageV2LoraStack(request.settings.loraStack, request.engine);
|
||||||
assertFolderAccess(event, request.folderId);
|
assertFolderAccess(event, request.folderId);
|
||||||
for (const id of [request.identityStillId, request.imageAId, request.imageBId, request.maskId, request.endStillId, ...request.guides.map((g: any) => g.stillId)].filter(Boolean))
|
for (const id of [request.identityStillId, request.imageAId, request.imageBId, request.maskId, request.endStillId, ...request.guides.map((g: any) => g.stillId)].filter(Boolean))
|
||||||
@@ -26,7 +28,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
await resolveRequestSize(owner,request);
|
await resolveRequestSize(owner,request);
|
||||||
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
|
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
|
||||||
saveRecord(record);
|
saveRecord(record);
|
||||||
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
|
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, studio2Request: structuredClone(request), prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
|
||||||
saveRecord({ ...readRecord(owner, id), queueId: row.id });
|
saveRecord({ ...readRecord(owner, id), queueId: row.id });
|
||||||
kickStudioQueue();
|
kickStudioQueue();
|
||||||
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
|
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||||
|
},
|
||||||
|
"class_type": "UnetLoaderGGUF",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"inputs": {
|
||||||
|
"device": "auto",
|
||||||
|
"dtype": "int8",
|
||||||
|
"model": ["4", 0]
|
||||||
|
},
|
||||||
|
"class_type": "QwenImage21Cache",
|
||||||
|
"_meta": { "title": "Qwen 2.1 Cache" }
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"inputs": {
|
||||||
|
"image": "qwen21_edit_source.png"
|
||||||
|
},
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"_meta": { "title": "image_1 source" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"vae": ["6", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"resolution": 1024,
|
||||||
|
"images.image_1": ["10", 0]
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImage21",
|
||||||
|
"_meta": { "title": "Text Encode Qwen Image 2.1 Edit" }
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": 1,
|
||||||
|
"steps": 25,
|
||||||
|
"cfg": 1,
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "simple",
|
||||||
|
"denoise": 1,
|
||||||
|
"model": ["8", 0],
|
||||||
|
"positive": ["9", 0],
|
||||||
|
"negative": ["9", 1],
|
||||||
|
"latent_image": ["9", 2]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"_meta": { "title": "KSampler" }
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["15", 0],
|
||||||
|
"vae": ["6", 0]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": { "title": "VAE Decode" }
|
||||||
|
},
|
||||||
|
"21": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "studio2-qwen21-edit",
|
||||||
|
"images": ["20", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": { "title": "Save Image" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||||
|
},
|
||||||
|
"class_type": "UnetLoaderGGUF",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"inputs": {
|
||||||
|
"model": ["4", 0],
|
||||||
|
"lora_name": "Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors",
|
||||||
|
"strength": 1.0
|
||||||
|
},
|
||||||
|
"class_type": "ViggleTurboLora",
|
||||||
|
"_meta": { "title": "Viggle Turbo LoRA (unmerged)" }
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"inputs": {
|
||||||
|
"device": "auto",
|
||||||
|
"dtype": "int8",
|
||||||
|
"model": ["7", 0]
|
||||||
|
},
|
||||||
|
"class_type": "QwenImage21Cache",
|
||||||
|
"_meta": { "title": "Qwen 2.1 Cache" }
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"inputs": {
|
||||||
|
"image": "qwen21_edit_source.png"
|
||||||
|
},
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"_meta": { "title": "image_1 source" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"vae": ["6", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"resolution": 1024,
|
||||||
|
"images.image_1": ["10", 0]
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImage21",
|
||||||
|
"_meta": { "title": "Text Encode Qwen Image 2.1 Edit" }
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"inputs": {
|
||||||
|
"model": ["8", 0],
|
||||||
|
"conditioning": ["9", 0]
|
||||||
|
},
|
||||||
|
"class_type": "BasicGuider",
|
||||||
|
"_meta": { "title": "CFG off (Turbo)" }
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"inputs": {
|
||||||
|
"noise_seed": 1
|
||||||
|
},
|
||||||
|
"class_type": "RandomNoise",
|
||||||
|
"_meta": { "title": "Noise" }
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"inputs": {
|
||||||
|
"sampler_name": "euler"
|
||||||
|
},
|
||||||
|
"class_type": "KSamplerSelect",
|
||||||
|
"_meta": { "title": "euler" }
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"inputs": {
|
||||||
|
"latent": ["9", 2],
|
||||||
|
"nodes": "1.0, 0.9375, 0.875, 0.75, 0.5, 0.25"
|
||||||
|
},
|
||||||
|
"class_type": "ViggleTurboSigmas",
|
||||||
|
"_meta": { "title": "Viggle Turbo Sigmas" }
|
||||||
|
},
|
||||||
|
"34": {
|
||||||
|
"inputs": {
|
||||||
|
"noise": ["31", 0],
|
||||||
|
"guider": ["30", 0],
|
||||||
|
"sampler": ["32", 0],
|
||||||
|
"sigmas": ["33", 0],
|
||||||
|
"latent_image": ["9", 2]
|
||||||
|
},
|
||||||
|
"class_type": "SamplerCustomAdvanced",
|
||||||
|
"_meta": { "title": "Turbo Sampler" }
|
||||||
|
},
|
||||||
|
"35": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["34", 0],
|
||||||
|
"vae": ["6", 0]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": { "title": "VAE Decode" }
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "studio2-qwen21-edit-turbo",
|
||||||
|
"images": ["35", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": { "title": "Save Image" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3.5_9b_qwen_image_2.1_pe_i2i.int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 PE Edit CLIP" }
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"inputs": {
|
||||||
|
"image": "qwen21_pe_edit_source.png"
|
||||||
|
},
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"_meta": { "title": "image_1 source" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"image_1": ["10", 0],
|
||||||
|
"temperature": 1.0,
|
||||||
|
"top_p": 0.95,
|
||||||
|
"presence_penalty": 0,
|
||||||
|
"max_length": 24000,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"class_type": "QwenImage21_EditPromptRewrite",
|
||||||
|
"_meta": { "title": "Qwen 2.1 Edit Prompt Rewrite" }
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"inputs": { "source": ["9", 0] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE positive_prompt" }
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"inputs": { "source": ["9", 2] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE wh_ratio" }
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"inputs": { "source": ["9", 3] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE ratio_follow" }
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"inputs": { "source": ["9", 4] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE thinking" }
|
||||||
|
},
|
||||||
|
"34": {
|
||||||
|
"inputs": { "source": ["9", 5] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE parse_ok" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3.5_9b_qwen_image_2.1_pe_t2i.int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 PE T2I CLIP" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"temperature": 1.0,
|
||||||
|
"top_p": 0.95,
|
||||||
|
"top_k": 20,
|
||||||
|
"presence_penalty": 1.5,
|
||||||
|
"max_new_tokens": 16256,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"class_type": "QwenImage21_T2IPromptRewrite",
|
||||||
|
"_meta": { "title": "Qwen 2.1 T2I Prompt Rewrite" }
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"inputs": { "source": ["9", 0] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE positive_prompt" }
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"inputs": { "source": ["9", 2] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE wh_ratio" }
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"inputs": { "source": ["9", 3] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE thinking" }
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"inputs": { "source": ["9", 4] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE parse_ok" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||||
|
},
|
||||||
|
"class_type": "UnetLoaderGGUF",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"inputs": {
|
||||||
|
"shift": 3.1,
|
||||||
|
"model": ["4", 0]
|
||||||
|
},
|
||||||
|
"class_type": "ModelSamplingAuraFlow",
|
||||||
|
"_meta": { "title": "Qwen 2.1 shift" }
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"resolution": 1024
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImage21",
|
||||||
|
"_meta": { "title": "Text Encode Qwen Image 2.1" }
|
||||||
|
},
|
||||||
|
"16": {
|
||||||
|
"inputs": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"batch_size": 1
|
||||||
|
},
|
||||||
|
"class_type": "EmptyLatentImage",
|
||||||
|
"_meta": { "title": "Qwen 2.1 canvas" }
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": 1,
|
||||||
|
"steps": 25,
|
||||||
|
"cfg": 1,
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "simple",
|
||||||
|
"denoise": 1,
|
||||||
|
"model": ["3", 0],
|
||||||
|
"positive": ["9", 0],
|
||||||
|
"negative": ["9", 1],
|
||||||
|
"latent_image": ["16", 0]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"_meta": { "title": "KSampler" }
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["15", 0],
|
||||||
|
"vae": ["6", 0]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": { "title": "VAE Decode" }
|
||||||
|
},
|
||||||
|
"21": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "studio2-qwen21",
|
||||||
|
"images": ["20", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": { "title": "Save Image" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||||
|
},
|
||||||
|
"class_type": "UnetLoaderGGUF",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"inputs": {
|
||||||
|
"model": ["4", 0],
|
||||||
|
"lora_name": "Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors",
|
||||||
|
"strength": 1.0
|
||||||
|
},
|
||||||
|
"class_type": "ViggleTurboLora",
|
||||||
|
"_meta": { "title": "Viggle Turbo LoRA (unmerged)" }
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"resolution": 1024
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImage21",
|
||||||
|
"_meta": { "title": "Text Encode Qwen Image 2.1" }
|
||||||
|
},
|
||||||
|
"16": {
|
||||||
|
"inputs": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"batch_size": 1
|
||||||
|
},
|
||||||
|
"class_type": "EmptyLatentImage",
|
||||||
|
"_meta": { "title": "Qwen 2.1 canvas" }
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"inputs": {
|
||||||
|
"model": ["7", 0],
|
||||||
|
"conditioning": ["9", 0]
|
||||||
|
},
|
||||||
|
"class_type": "BasicGuider",
|
||||||
|
"_meta": { "title": "CFG off (Turbo)" }
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"inputs": {
|
||||||
|
"noise_seed": 1
|
||||||
|
},
|
||||||
|
"class_type": "RandomNoise",
|
||||||
|
"_meta": { "title": "Noise" }
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"inputs": {
|
||||||
|
"sampler_name": "euler"
|
||||||
|
},
|
||||||
|
"class_type": "KSamplerSelect",
|
||||||
|
"_meta": { "title": "euler" }
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"inputs": {
|
||||||
|
"latent": ["16", 0],
|
||||||
|
"nodes": "1.0, 0.9375, 0.875, 0.75, 0.5, 0.25"
|
||||||
|
},
|
||||||
|
"class_type": "ViggleTurboSigmas",
|
||||||
|
"_meta": { "title": "Viggle Turbo Sigmas" }
|
||||||
|
},
|
||||||
|
"34": {
|
||||||
|
"inputs": {
|
||||||
|
"noise": ["31", 0],
|
||||||
|
"guider": ["30", 0],
|
||||||
|
"sampler": ["32", 0],
|
||||||
|
"sigmas": ["33", 0],
|
||||||
|
"latent_image": ["16", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SamplerCustomAdvanced",
|
||||||
|
"_meta": { "title": "Turbo Sampler" }
|
||||||
|
},
|
||||||
|
"35": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["34", 0],
|
||||||
|
"vae": ["6", 0]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": { "title": "VAE Decode" }
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "studio2-qwen21-turbo",
|
||||||
|
"images": ["35", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": { "title": "Save Image" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,11 +139,44 @@ export async function updateGenerationLogByStudioJob(
|
|||||||
export async function requeueGenerationLog(owner: string, id: string) {
|
export async function requeueGenerationLog(owner: string, id: string) {
|
||||||
const entry = getGenerationLogEntry(owner, id)
|
const entry = getGenerationLogEntry(owner, id)
|
||||||
const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue')
|
const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue')
|
||||||
|
let payload = structuredClone(entry.payload)
|
||||||
|
if (payload?.studio2Id || payload?.studio2Request) {
|
||||||
|
const { saveRecord, readRecord } = await import('~/server/utils/studio2/store')
|
||||||
|
let request = payload.studio2Request
|
||||||
|
if (!request && payload.studio2Id) {
|
||||||
|
try { request = structuredClone(readRecord(owner, payload.studio2Id).request) }
|
||||||
|
catch { request = null }
|
||||||
|
}
|
||||||
|
if (!request) throw createError({ statusCode: 400, statusMessage: 'That Studio 2 job has no saved request to re-queue.' })
|
||||||
|
const studio2Id = crypto.randomUUID()
|
||||||
|
saveRecord({
|
||||||
|
id: studio2Id,
|
||||||
|
owner,
|
||||||
|
request: structuredClone(request),
|
||||||
|
state: 'waiting',
|
||||||
|
queuedAt: Date.now(),
|
||||||
|
familyId: crypto.randomUUID(),
|
||||||
|
outputs: [],
|
||||||
|
purgeResult: 'Not yet saved'
|
||||||
|
})
|
||||||
|
payload = {
|
||||||
|
...payload,
|
||||||
|
...structuredClone(request.settings || {}),
|
||||||
|
studio2Id,
|
||||||
|
studio2Request: structuredClone(request),
|
||||||
|
prompt: request.compiledPrompt || payload.prompt,
|
||||||
|
folderId: request.folderId || payload.folderId,
|
||||||
|
extensions: [],
|
||||||
|
referenceStillIds: [],
|
||||||
|
useIdentityRefs: false,
|
||||||
|
queueAutoRun: true
|
||||||
|
}
|
||||||
|
}
|
||||||
const studio = await addStudioJob({
|
const studio = await addStudioJob({
|
||||||
ownerKey: owner,
|
ownerKey: owner,
|
||||||
familyId: crypto.randomUUID(),
|
familyId: crypto.randomUUID(),
|
||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
payload: structuredClone(entry.payload)
|
payload
|
||||||
})
|
})
|
||||||
await kickStudioQueue()
|
await kickStudioQueue()
|
||||||
return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) }
|
return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) }
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export interface LibraryStill {
|
|||||||
role: StillRole
|
role: StillRole
|
||||||
name?: string
|
name?: string
|
||||||
prompt?: string
|
prompt?: string
|
||||||
|
promptRaw?: string
|
||||||
promptPre?: string
|
promptPre?: string
|
||||||
promptPost?: string
|
promptPost?: string
|
||||||
familyId?: string
|
familyId?: string
|
||||||
@@ -1333,6 +1334,7 @@ export async function saveStill(params: {
|
|||||||
role?: StillRole
|
role?: StillRole
|
||||||
name?: string
|
name?: string
|
||||||
prompt?: string
|
prompt?: string
|
||||||
|
promptRaw?: string
|
||||||
promptPre?: string
|
promptPre?: string
|
||||||
promptPost?: string
|
promptPost?: string
|
||||||
familyId?: string
|
familyId?: string
|
||||||
@@ -1354,8 +1356,9 @@ export async function saveStill(params: {
|
|||||||
const hideInput = typeof params.hideInput === 'boolean' ? params.hideInput === true : existing.hideInput
|
const hideInput = typeof params.hideInput === 'boolean' ? params.hideInput === true : existing.hideInput
|
||||||
const name = typeof params.name === 'string' ? params.name.trim().slice(0, 80) : existing.name
|
const name = typeof params.name === 'string' ? params.name.trim().slice(0, 80) : existing.name
|
||||||
const prompt = typeof params.prompt === 'string' ? params.prompt : existing.prompt
|
const prompt = typeof params.prompt === 'string' ? params.prompt : existing.prompt
|
||||||
|
const promptRaw = typeof params.promptRaw === 'string' ? params.promptRaw : existing.promptRaw
|
||||||
const nextSettings = params.settings ? normalizeStillSettings(params.settings) : undefined
|
const nextSettings = params.settings ? normalizeStillSettings(params.settings) : undefined
|
||||||
if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt || nextSettings) {
|
if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt || existing.promptRaw !== promptRaw || nextSettings) {
|
||||||
await mutate(params.ownerKey, (next) => {
|
await mutate(params.ownerKey, (next) => {
|
||||||
const item = next.stills.find(still => still.id === existing.id)
|
const item = next.stills.find(still => still.id === existing.id)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
@@ -1363,6 +1366,7 @@ export async function saveStill(params: {
|
|||||||
item.role = role
|
item.role = role
|
||||||
if (name) item.name = name
|
if (name) item.name = name
|
||||||
if (prompt) item.prompt = prompt
|
if (prompt) item.prompt = prompt
|
||||||
|
if (params.promptRaw != null) item.promptRaw = params.promptRaw || undefined
|
||||||
if (params.promptPre != null) item.promptPre = params.promptPre || undefined
|
if (params.promptPre != null) item.promptPre = params.promptPre || undefined
|
||||||
if (params.promptPost != null) item.promptPost = params.promptPost || undefined
|
if (params.promptPost != null) item.promptPost = params.promptPost || undefined
|
||||||
if (params.familyId) item.familyId = params.familyId
|
if (params.familyId) item.familyId = params.familyId
|
||||||
@@ -1374,6 +1378,7 @@ export async function saveStill(params: {
|
|||||||
existing.role = role
|
existing.role = role
|
||||||
if (name) existing.name = name
|
if (name) existing.name = name
|
||||||
if (prompt) existing.prompt = prompt
|
if (prompt) existing.prompt = prompt
|
||||||
|
if (params.promptRaw != null) existing.promptRaw = params.promptRaw || undefined
|
||||||
if (params.promptPre != null) existing.promptPre = params.promptPre || undefined
|
if (params.promptPre != null) existing.promptPre = params.promptPre || undefined
|
||||||
if (params.promptPost != null) existing.promptPost = params.promptPost || undefined
|
if (params.promptPost != null) existing.promptPost = params.promptPost || undefined
|
||||||
if (nextSettings) existing.settings = nextSettings
|
if (nextSettings) existing.settings = nextSettings
|
||||||
@@ -1395,6 +1400,7 @@ export async function saveStill(params: {
|
|||||||
role,
|
role,
|
||||||
name: params.name?.trim().slice(0, 80) || undefined,
|
name: params.name?.trim().slice(0, 80) || undefined,
|
||||||
prompt: params.prompt || undefined,
|
prompt: params.prompt || undefined,
|
||||||
|
promptRaw: params.promptRaw || undefined,
|
||||||
promptPre: params.promptPre || undefined,
|
promptPre: params.promptPre || undefined,
|
||||||
promptPost: params.promptPost || undefined,
|
promptPost: params.promptPost || undefined,
|
||||||
familyId: params.familyId,
|
familyId: params.familyId,
|
||||||
|
|||||||
@@ -8,13 +8,23 @@ import { queueSeeds } from '~/shared/studio2/seed.mjs';
|
|||||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import template from '../../assets/studio2_minimax_native.json';
|
import template from '../../assets/studio2_minimax_native.json';
|
||||||
|
import qwen21Template from '../../assets/studio2_qwen21_t2i.json'
|
||||||
|
import qwen21EditTemplate from '../../assets/studio2_qwen21_edit.json'
|
||||||
|
import qwen21T2iTurboTemplate from '../../assets/studio2_qwen21_t2i_turbo.json'
|
||||||
|
import qwen21EditTurboTemplate from '../../assets/studio2_qwen21_edit_turbo.json'
|
||||||
|
import qwen21PeT2iTemplate from '../../assets/studio2_qwen21_pe_t2i.json'
|
||||||
|
import qwen21PeEditTemplate from '../../assets/studio2_qwen21_pe_edit.json';
|
||||||
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
||||||
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
||||||
|
import { resolveQwen21Size } from '~/shared/studio2/qwen21-size.mjs';
|
||||||
|
import { ensureQwen21EditPrompt, stitchQwen21PeEditPrompt } from '~/shared/studio2/qwen21-edit.mjs';
|
||||||
|
import { qwen21PeRefusal } from '~/shared/studio2/qwen21-pe.mjs';
|
||||||
|
import { QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from '~/shared/studio2/qwen21-turbo.mjs';
|
||||||
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
||||||
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
||||||
import { readRecord, saveRecord, studio2Root, records } from './store';
|
import { readRecord, saveRecord, studio2Root, records } from './store';
|
||||||
import { ensureComfyReady } from '../comfyLifecycle';
|
import { ensureComfyReady } from '../comfyLifecycle';
|
||||||
import { comfyFetch, queuePrompt, fetchHistory, extractVideo } from '../comfy';
|
import { comfyFetch, queuePrompt, fetchHistory, extractVideo, freeComfyVram } from '../comfy';
|
||||||
import { extractEditedImage } from '../imageComfy';
|
import { extractEditedImage } from '../imageComfy';
|
||||||
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
|
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
|
||||||
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
|
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
|
||||||
@@ -34,10 +44,163 @@ function update(r: any, state: string) { r.state = state; r.updatedAt = Date.now
|
|||||||
// extend/video start frame (which is either the exact anchor PNG or an already-canvas-sized
|
// extend/video start frame (which is either the exact anchor PNG or an already-canvas-sized
|
||||||
// extracted last frame). Fitting these every hop is what destroys identity across chained extends.
|
// extracted last frame). Fitting these every hop is what destroys identity across chained extends.
|
||||||
const NEVER_FIT = new Set(['hero', 'start']);
|
const NEVER_FIT = new Set(['hero', 'start']);
|
||||||
|
|
||||||
|
function previewAnyText(outputs: Record<string, any> | undefined, nodeId: string): string {
|
||||||
|
const node = outputs?.[nodeId]
|
||||||
|
if (!node || typeof node !== 'object') return ''
|
||||||
|
const raw = node.text ?? node.STRING ?? node.source
|
||||||
|
if (Array.isArray(raw)) return String(raw[0] ?? '')
|
||||||
|
if (raw == null) return ''
|
||||||
|
return String(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
function harvestQwen21Pe(history: Record<string, any> | null | undefined, promptId: string, edit: boolean) {
|
||||||
|
const outputs = history?.[promptId]?.outputs as Record<string, any> | undefined
|
||||||
|
const positive_prompt = previewAnyText(outputs, '30').trim()
|
||||||
|
const wh_ratio = previewAnyText(outputs, '31').trim()
|
||||||
|
const ratio_follow = edit ? previewAnyText(outputs, '32').trim() : ''
|
||||||
|
const thinking = previewAnyText(outputs, edit ? '33' : '32').trim()
|
||||||
|
const parseRaw = previewAnyText(outputs, edit ? '34' : '33').trim()
|
||||||
|
const parse_ok = /^(true|1|yes)$/i.test(parseRaw)
|
||||||
|
return { positive_prompt, wh_ratio, ratio_follow, thinking, parse_ok }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitPromptHistory(r: any, job: Job, promptId: string) {
|
||||||
|
let history: any = null, missingSince = 0
|
||||||
|
for (;;) {
|
||||||
|
try { history = await fetchHistory(promptId) }
|
||||||
|
catch { history = null }
|
||||||
|
const e = history?.[promptId]
|
||||||
|
if (job.status === 'cancelled') throw new Error('Cancelled')
|
||||||
|
if (['error', 'interrupted'].includes(e?.status?.status_str))
|
||||||
|
throw new Error('Comfy generation failed or was interrupted; inspect the host error log.')
|
||||||
|
if (!e) {
|
||||||
|
try {
|
||||||
|
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) })
|
||||||
|
if (response.ok) {
|
||||||
|
const q = await response.json() as any
|
||||||
|
const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === promptId)
|
||||||
|
if (present) missingSince = 0
|
||||||
|
else if (!missingSince) missingSince = Date.now()
|
||||||
|
else if (Date.now() - missingSince > 60000) throw new Error('PROMPT_MISSING')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'PROMPT_MISSING')
|
||||||
|
throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e?.status?.completed || e?.status?.status_str === 'success') break
|
||||||
|
if (job.status === 'cancelled') throw new Error('Cancelled')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
|
}
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQwen21PromptEnhance(r: any, job: Job) {
|
||||||
|
const q = r.request, s = q.settings
|
||||||
|
if (!q.enhancePrompt || q.engine !== 'qwen21' || !['generate', 'edit'].includes(q.mode)) return
|
||||||
|
const edit = q.mode === 'edit'
|
||||||
|
// Edit → PE-I2I only (pe_i2i). Generate → PE-T2I only. Never cross-feed.
|
||||||
|
if (edit && !q.imageAId)
|
||||||
|
throw new Error('Qwen Edit requires Start still (image_1). Hero is ignored.')
|
||||||
|
update(r, 'enhancing')
|
||||||
|
emitJob(job, { type: 'status', message: 'Enhancing prompt…' })
|
||||||
|
// promptRaw = exactly what they typed (compiled from sections before PE).
|
||||||
|
const userPrompt = String(q.compiledPrompt || '')
|
||||||
|
r.request.promptRaw = userPrompt
|
||||||
|
// Edit PE gets the raw edit instruction + Start still — not the keep stanza.
|
||||||
|
const peInput = userPrompt
|
||||||
|
const graph = structuredClone(edit ? qwen21PeEditTemplate : qwen21PeT2iTemplate)
|
||||||
|
graph['9'].inputs.prompt = peInput
|
||||||
|
graph['9'].inputs.seed = s.seed
|
||||||
|
if (edit) {
|
||||||
|
// Same Start still pixels as Edit image_1 — never Hero.
|
||||||
|
const a = await upload(r, 'pe-source', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageAId).id)))
|
||||||
|
graph['10'].inputs.image = a
|
||||||
|
graph['9'].inputs.image_1 = ['10', 0]
|
||||||
|
if (q.imageBId) {
|
||||||
|
const b = await upload(r, 'pe-compose', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageBId).id)))
|
||||||
|
graph['11'] = {
|
||||||
|
inputs: { image: b },
|
||||||
|
class_type: 'LoadImage',
|
||||||
|
_meta: { title: 'image_2 optional' }
|
||||||
|
}
|
||||||
|
graph['9'].inputs.image_2 = ['11', 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.peGraphId = edit ? 'studio2_qwen21_pe_edit.json' : 'studio2_qwen21_pe_t2i.json'
|
||||||
|
// PE is stage 1 of the same studio job — not a complete/requeue.
|
||||||
|
r.progress = null
|
||||||
|
saveRecord(r)
|
||||||
|
const queued = await queuePrompt(graph, job.clientId)
|
||||||
|
r.pePromptId = queued.prompt_id
|
||||||
|
// watchProgress listens on promptId; point it at the PE Comfy prompt for this stage.
|
||||||
|
r.promptId = queued.prompt_id
|
||||||
|
job.promptId = queued.prompt_id
|
||||||
|
saveRecord(r)
|
||||||
|
const history = await waitPromptHistory(r, job, queued.prompt_id)
|
||||||
|
const result = harvestQwen21Pe(history, queued.prompt_id, edit)
|
||||||
|
const refusal = qwen21PeRefusal(userPrompt, result)
|
||||||
|
if (refusal.refused) {
|
||||||
|
// Fail open: keep the job, send the raw brief (Edit keep-identity stanza still applies).
|
||||||
|
const compiled = edit ? ensureQwen21EditPrompt(userPrompt) : userPrompt
|
||||||
|
r.request.compiledPrompt = compiled
|
||||||
|
r.request.prompt = compiled
|
||||||
|
r.request.enhance = {
|
||||||
|
used: false,
|
||||||
|
wh_ratio: '',
|
||||||
|
...(edit ? { ratio_follow: '' } : {}),
|
||||||
|
parse_ok: !!result.parse_ok,
|
||||||
|
refused: true,
|
||||||
|
skipReason: refusal.reason,
|
||||||
|
thinking: result.thinking || '',
|
||||||
|
comfyPromptId: queued.prompt_id,
|
||||||
|
}
|
||||||
|
} else if (edit) {
|
||||||
|
// Edit only: stanza + raw first; PE chunk only if it is an edit directive (never PE-alone).
|
||||||
|
// Generate keeps the long observer paragraph as the product — no describe sanitize.
|
||||||
|
const stitched = stitchQwen21PeEditPrompt(userPrompt, result.positive_prompt)
|
||||||
|
r.request.compiledPrompt = stitched.prompt
|
||||||
|
r.request.prompt = stitched.prompt
|
||||||
|
r.request.enhance = {
|
||||||
|
used: !stitched.skippedAsDescribe,
|
||||||
|
wh_ratio: result.wh_ratio || '',
|
||||||
|
ratio_follow: result.ratio_follow || '',
|
||||||
|
parse_ok: !!result.parse_ok,
|
||||||
|
refused: false,
|
||||||
|
...(stitched.skippedAsDescribe ? { skippedAsDescribe: true } : {}),
|
||||||
|
thinking: result.thinking || '',
|
||||||
|
comfyPromptId: queued.prompt_id,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r.request.compiledPrompt = result.positive_prompt
|
||||||
|
r.request.prompt = result.positive_prompt
|
||||||
|
r.request.enhance = {
|
||||||
|
used: true,
|
||||||
|
wh_ratio: result.wh_ratio || '',
|
||||||
|
parse_ok: !!result.parse_ok,
|
||||||
|
refused: false,
|
||||||
|
thinking: result.thinking || '',
|
||||||
|
comfyPromptId: queued.prompt_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (edit || q.mode === 'generate') r.request.task = q.mode
|
||||||
|
// Stage flip PE → generate: clear PE prompt id and reset the bar (no leftover 100%).
|
||||||
|
r.pePromptId = ''
|
||||||
|
r.promptId = ''
|
||||||
|
job.promptId = ''
|
||||||
|
r.progress = null
|
||||||
|
saveRecord(r)
|
||||||
|
await freeComfyVram()
|
||||||
|
// Same job continues into generate — do not complete or requeue.
|
||||||
|
update(r, 'submitting')
|
||||||
|
emitJob(job, { type: 'status', message: 'Generating…' })
|
||||||
|
}
|
||||||
|
|
||||||
async function upload(r: any, name: string, data: Buffer): Promise<string> {
|
async function upload(r: any, name: string, data: Buffer): Promise<string> {
|
||||||
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
|
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
|
||||||
let fitted = false;
|
let fitted = false;
|
||||||
if (!NEVER_FIT.has(name) && needsFit(data, r.request.settings.width, r.request.settings.height)) {
|
if (!NEVER_FIT.has(name) && r.request?.engine !== 'qwen21' && needsFit(data, r.request.settings.width, r.request.settings.height)) {
|
||||||
data = await fitStill(data, r.request.settings.width, r.request.settings.height);
|
data = await fitStill(data, r.request.settings.width, r.request.settings.height);
|
||||||
fitted = true;
|
fitted = true;
|
||||||
}
|
}
|
||||||
@@ -63,7 +226,10 @@ async function prepareGraph(r: any) {
|
|||||||
const load = async (id: string, label: string) => id ? upload(r, label, readFileSync(stillPath(r.owner, getStill(r.owner, id).id))) : '';
|
const load = async (id: string, label: string) => id ? upload(r, label, readFileSync(stillPath(r.owner, getStill(r.owner, id).id))) : '';
|
||||||
const video=['video','extend'].includes(q.mode);
|
const video=['video','extend'].includes(q.mode);
|
||||||
const identityPlan=kleinIdentityPlan(q);
|
const identityPlan=kleinIdentityPlan(q);
|
||||||
const sourceId=identityPlan ? identityPlan.sourceId : q.imageAId;
|
// Qwen Edit: Start still (imageAId) only — never Hero / identityStillId.
|
||||||
|
const sourceId = q.engine === 'qwen21'
|
||||||
|
? (q.imageAId || '')
|
||||||
|
: (identityPlan ? identityPlan.sourceId : q.imageAId);
|
||||||
await resolveRequestSize(r.owner,identityPlan ? {...q,imageAId:sourceId} : q);
|
await resolveRequestSize(r.owner,identityPlan ? {...q,imageAId:sourceId} : q);
|
||||||
saveRecord(r);
|
saveRecord(r);
|
||||||
const a=await load(sourceId,'source'),b=await load(q.imageBId,'compose'),mask=await load(q.maskId,'mask');
|
const a=await load(sourceId,'source'),b=await load(q.imageBId,'compose'),mask=await load(q.maskId,'mask');
|
||||||
@@ -126,6 +292,98 @@ async function prepareGraph(r: any) {
|
|||||||
r.graphId = 'studio2_minimax_native.json';
|
r.graphId = 'studio2_minimax_native.json';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (q.engine === 'qwen21') {
|
||||||
|
if (!['generate', 'edit'].includes(q.mode))
|
||||||
|
throw new Error('Qwen 2.1 supports Generate and Edit.')
|
||||||
|
const turbo = !!q.turbo
|
||||||
|
if (turbo) {
|
||||||
|
q.lora = QWEN21_TURBO_LORA
|
||||||
|
q.sigmas = QWEN21_TURBO_SIGMAS
|
||||||
|
s.steps = QWEN21_TURBO_STEPS
|
||||||
|
s.cfg = 1
|
||||||
|
}
|
||||||
|
const negative = turbo ? '' : stylePrompt(q.imageStyles, true)
|
||||||
|
if (q.mode === 'edit') {
|
||||||
|
// Start still → image_1 only. Never Hero. Never T2I / EmptyLatent.
|
||||||
|
if (!q.imageAId || !a)
|
||||||
|
throw new Error('Qwen Edit requires Start still (image_1). Hero is ignored.')
|
||||||
|
// promptRaw stays typed; prompt/compiledPrompt = what TextEncode samples.
|
||||||
|
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
|
||||||
|
const prompt = ensureQwen21EditPrompt(String(q.compiledPrompt || ''))
|
||||||
|
q.compiledPrompt = prompt
|
||||||
|
q.prompt = prompt
|
||||||
|
q.task = 'edit'
|
||||||
|
graph = structuredClone(turbo ? qwen21EditTurboTemplate : qwen21EditTemplate)
|
||||||
|
graph['10'].inputs.image = a
|
||||||
|
graph['9'].inputs['images.image_1'] = ['10', 0]
|
||||||
|
if (b) {
|
||||||
|
graph['11'] = {
|
||||||
|
inputs: { image: b },
|
||||||
|
class_type: 'LoadImage',
|
||||||
|
_meta: { title: 'image_2 optional' }
|
||||||
|
}
|
||||||
|
graph['9'].inputs['images.image_2'] = ['11', 0]
|
||||||
|
}
|
||||||
|
graph['9'].inputs.prompt = prompt
|
||||||
|
graph['9'].inputs.negative_prompt = negative
|
||||||
|
// Official edit: sampler latent comes from TextEncode (sized from image_1).
|
||||||
|
graph['9'].inputs.resolution = 1024
|
||||||
|
if (turbo) {
|
||||||
|
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
|
||||||
|
graph['7'].inputs.strength = 1.0
|
||||||
|
graph['31'].inputs.noise_seed = s.seed
|
||||||
|
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
|
||||||
|
graph['36'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 edit latent'
|
||||||
|
r.graphId = 'studio2_qwen21_edit_turbo.json'
|
||||||
|
} else {
|
||||||
|
graph['15'].inputs.seed = s.seed
|
||||||
|
graph['15'].inputs.steps = s.steps || 25
|
||||||
|
graph['15'].inputs.cfg = s.cfg ?? 1
|
||||||
|
graph['15'].inputs.sampler_name = 'euler'
|
||||||
|
graph['15'].inputs.scheduler = 'simple'
|
||||||
|
graph['21'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 edit latent'
|
||||||
|
r.graphId = 'studio2_qwen21_edit.json'
|
||||||
|
}
|
||||||
|
r.sampleDenoise = null
|
||||||
|
r.heroReferenceAttached = false
|
||||||
|
} else {
|
||||||
|
// Bench aspect is law for Generate. PE wh_ratio is advisory only (never sizes the canvas).
|
||||||
|
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
|
||||||
|
q.prompt = String(q.compiledPrompt || '')
|
||||||
|
q.task = 'generate'
|
||||||
|
const size = resolveQwen21Size(s.aspect)
|
||||||
|
s.width = size.width
|
||||||
|
s.height = size.height
|
||||||
|
graph = structuredClone(turbo ? qwen21T2iTurboTemplate : qwen21Template)
|
||||||
|
graph['9'].inputs.prompt = q.compiledPrompt
|
||||||
|
graph['9'].inputs.negative_prompt = negative
|
||||||
|
graph['9'].inputs.resolution = 1024
|
||||||
|
graph['16'].inputs.width = size.width
|
||||||
|
graph['16'].inputs.height = size.height
|
||||||
|
if (turbo) {
|
||||||
|
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
|
||||||
|
graph['7'].inputs.strength = 1.0
|
||||||
|
graph['31'].inputs.noise_seed = s.seed
|
||||||
|
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
|
||||||
|
graph['36'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 empty latent'
|
||||||
|
r.graphId = 'studio2_qwen21_t2i_turbo.json'
|
||||||
|
} else {
|
||||||
|
graph['15'].inputs.seed = s.seed
|
||||||
|
graph['15'].inputs.steps = s.steps || 25
|
||||||
|
graph['15'].inputs.cfg = s.cfg ?? 1
|
||||||
|
graph['15'].inputs.sampler_name = 'euler'
|
||||||
|
graph['15'].inputs.scheduler = 'simple'
|
||||||
|
graph['21'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 empty latent'
|
||||||
|
r.graphId = 'studio2_qwen21_t2i.json'
|
||||||
|
}
|
||||||
|
r.sampleDenoise = null
|
||||||
|
r.heroReferenceAttached = false
|
||||||
|
}
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
||||||
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
||||||
@@ -171,56 +429,33 @@ async function run(r: any, job: Job) {
|
|||||||
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
|
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
|
||||||
update(r, 'waking');
|
update(r, 'waking');
|
||||||
await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
|
await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
|
||||||
|
if (job.status === 'cancelled')
|
||||||
|
throw new Error('Cancelled');
|
||||||
|
await runQwen21PromptEnhance(r, job);
|
||||||
if (job.status === 'cancelled')
|
if (job.status === 'cancelled')
|
||||||
throw new Error('Cancelled');
|
throw new Error('Cancelled');
|
||||||
const graph = await prepareGraph(r);
|
const graph = await prepareGraph(r);
|
||||||
if (job.status === 'cancelled')
|
if (job.status === 'cancelled')
|
||||||
throw new Error('Cancelled');
|
throw new Error('Cancelled');
|
||||||
update(r, 'submitting');
|
update(r, 'submitting');
|
||||||
const queued = await queuePrompt(graph, job.clientId);
|
emitJob(job, { type: 'status', message: 'Generating…' });
|
||||||
|
let queued: { prompt_id: string; number?: number }
|
||||||
|
try {
|
||||||
|
queued = await queuePrompt(graph, job.clientId);
|
||||||
|
} catch (error: any) {
|
||||||
|
const msg = String(error?.statusMessage || error?.message || error)
|
||||||
|
if (r.request?.turbo && /lora|ViggleTurbo|not in list|value_not_in_list/i.test(msg))
|
||||||
|
throw new Error(`Qwen Turbo LoRA missing on host (${QWEN21_TURBO_LORA}). Install the r128 file under models/loras and restart Comfy.`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
job.promptId = queued.prompt_id;
|
job.promptId = queued.prompt_id;
|
||||||
r.promptId = queued.prompt_id;
|
r.promptId = queued.prompt_id;
|
||||||
|
r.generateComfyPromptId = queued.prompt_id;
|
||||||
|
r.progress = null;
|
||||||
r.renderStartedAt = Date.now();
|
r.renderStartedAt = Date.now();
|
||||||
update(r, 'rendering');
|
update(r, 'rendering');
|
||||||
}
|
}
|
||||||
let history: any, missingSince = 0;
|
const history = await waitPromptHistory(r, job, r.promptId);
|
||||||
for (;;) {
|
|
||||||
try {
|
|
||||||
history = await fetchHistory(r.promptId);
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
history = null;
|
|
||||||
}
|
|
||||||
const e = history?.[r.promptId];
|
|
||||||
if (job.status === 'cancelled')
|
|
||||||
throw new Error('Cancelled');
|
|
||||||
if (['error', 'interrupted'].includes(e?.status?.status_str))
|
|
||||||
throw new Error('Comfy generation failed or was interrupted; inspect the host error log.');
|
|
||||||
if (!e) {
|
|
||||||
try {
|
|
||||||
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) });
|
|
||||||
if (response.ok) {
|
|
||||||
const q = await response.json() as any;
|
|
||||||
const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === r.promptId);
|
|
||||||
if (present)
|
|
||||||
missingSince = 0;
|
|
||||||
else if (!missingSince)
|
|
||||||
missingSince = Date.now();
|
|
||||||
else if (Date.now() - missingSince > 60000)
|
|
||||||
throw new Error('PROMPT_MISSING');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error: any) {
|
|
||||||
if (error.message === 'PROMPT_MISSING')
|
|
||||||
throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e?.status?.completed || e?.status?.status_str === 'success')
|
|
||||||
break;
|
|
||||||
if (job.status === 'cancelled')
|
|
||||||
throw new Error('Cancelled');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
||||||
}
|
|
||||||
const messages = history[r.promptId]?.status?.messages || [];
|
const messages = history[r.promptId]?.status?.messages || [];
|
||||||
const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp;
|
const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp;
|
||||||
const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp;
|
const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp;
|
||||||
@@ -255,7 +490,21 @@ async function run(r: any, job: Job) {
|
|||||||
}
|
}
|
||||||
else if (!asset) {
|
else if (!asset) {
|
||||||
const data = await downloadComfyImage(file), size = imageDimensions(data) || { width: s.width, height: s.height };
|
const data = await downloadComfyImage(file), size = imageDimensions(data) || { width: s.width, height: s.height };
|
||||||
asset = await saveStill({ ownerKey: r.owner, folderId: q.folderId, filename: file.filename, data, ...size, role: 'output', prompt: q.compiledPrompt, familyId: r.familyId, chainIndex: r.index, settings: s });
|
// prompt = TextEncode string (after PE + stitch); promptRaw = typed brief.
|
||||||
|
const samplePrompt = String(q.prompt || q.compiledPrompt || '')
|
||||||
|
asset = await saveStill({
|
||||||
|
ownerKey: r.owner,
|
||||||
|
folderId: q.folderId,
|
||||||
|
filename: file.filename,
|
||||||
|
data,
|
||||||
|
...size,
|
||||||
|
role: 'output',
|
||||||
|
prompt: samplePrompt,
|
||||||
|
promptRaw: q.promptRaw != null ? String(q.promptRaw) : undefined,
|
||||||
|
familyId: r.familyId,
|
||||||
|
chainIndex: r.index,
|
||||||
|
settings: s,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!asset)
|
if (!asset)
|
||||||
throw new Error('Library did not save the output');
|
throw new Error('Library did not save the output');
|
||||||
@@ -263,6 +512,11 @@ async function run(r: any, job: Job) {
|
|||||||
saveRecord(r);
|
saveRecord(r);
|
||||||
r.savedAt = Date.now();
|
r.savedAt = Date.now();
|
||||||
r.wallTime = (r.savedAt - r.startedAt) / 1000;
|
r.wallTime = (r.savedAt - r.startedAt) / 1000;
|
||||||
|
if (q.engine === 'qwen21' && ['generate', 'edit'].includes(q.mode)) {
|
||||||
|
q.task = q.mode
|
||||||
|
q.prompt = String(q.prompt || q.compiledPrompt || '')
|
||||||
|
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
|
||||||
|
}
|
||||||
const metadata = { ...structuredClone(q), id: r.id, sourceStillId:r.sourceStillId,heroReferenceAttached:r.heroReferenceAttached,sampleLatent:r.sampleLatent,sampleDenoise:r.sampleDenoise,kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host', handoffSource: r.handoffSource || null, fitted: video && q.startClipId ? !!r.handoffFitted : null };
|
const metadata = { ...structuredClone(q), id: r.id, sourceStillId:r.sourceStillId,heroReferenceAttached:r.heroReferenceAttached,sampleLatent:r.sampleLatent,sampleDenoise:r.sampleDenoise,kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host', handoffSource: r.handoffSource || null, fitted: video && q.startClipId ? !!r.handoffFitted : null };
|
||||||
await attachStudio2Metadata(r.owner, asset.id, metadata);
|
await attachStudio2Metadata(r.owner, asset.id, metadata);
|
||||||
if (!r.outputs.some((o:any)=>o.id===asset.id)) r.outputs.push({id:asset.id,kind:video?'clip':'still',studio2:metadata});
|
if (!r.outputs.some((o:any)=>o.id===asset.id)) r.outputs.push({id:asset.id,kind:video?'clip':'still',studio2:metadata});
|
||||||
|
|||||||
@@ -21,5 +21,5 @@ export async function resolveRequestSize(owner: string, request: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
|
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
|
||||||
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions))
|
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions,undefined,request.engine))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type StudioJobKind = 'video' | 'edit' | 'music'
|
|||||||
|
|
||||||
export interface StudioJobPayload {
|
export interface StudioJobPayload {
|
||||||
studio2Id?: string
|
studio2Id?: string
|
||||||
|
/** Full Studio 2 validateRequest snapshot for Load / Re-queue. */
|
||||||
|
studio2Request?: Record<string, any>
|
||||||
upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string }
|
upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string }
|
||||||
prompt: string
|
prompt: string
|
||||||
promptMid?: string
|
promptMid?: string
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {normalizeStyles,stylePrompt} from './styles.mjs'
|
import {normalizeStyles,stylePrompt} from './styles.mjs'
|
||||||
import { queueSeeds } from './seed.mjs'
|
import { queueSeeds } from './seed.mjs'
|
||||||
import { sectionsFrom, compileSections } from './prompt.mjs'
|
import { sectionsFrom, compileSections } from './prompt.mjs'
|
||||||
|
import { QWEN21_ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
|
||||||
|
import { QWEN21_TURBO_CFG, QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from './qwen21-turbo.mjs'
|
||||||
export { sectionsFrom } from './prompt.mjs'
|
export { sectionsFrom } from './prompt.mjs'
|
||||||
export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right']
|
export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right']
|
||||||
export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.'
|
export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.'
|
||||||
@@ -13,7 +15,7 @@ export function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
|
|||||||
export function compilePrompt(value, video = false, locks = {}) {
|
export function compilePrompt(value, video = false, locks = {}) {
|
||||||
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
|
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
|
||||||
const hasImage=!!(locks.imageAId || locks.identityStillId)
|
const hasImage=!!(locks.imageAId || locks.identityStillId)
|
||||||
const attached=hasImage && locks.engine !== 'krea'
|
const attached=hasImage && locks.engine !== 'krea' && locks.engine !== 'qwen21'
|
||||||
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
|
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
|
||||||
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
|
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
|
||||||
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
|
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
|
||||||
@@ -31,23 +33,34 @@ export function validateRequest(raw, xaigen = false) {
|
|||||||
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
||||||
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
||||||
const video = ['video','extend'].includes(mode)
|
const video = ['video','extend'].includes(mode)
|
||||||
if (!(video ? ['minimax','ltx'] : ['flux','krea']).includes(engine)) fail('Engine does not support this task.')
|
if (!(video ? ['minimax','ltx'] : ['flux','krea','qwen21']).includes(engine)) fail('Engine does not support this task.')
|
||||||
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
||||||
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
||||||
|
if (engine === 'qwen21' && !['generate', 'edit'].includes(mode)) fail('Qwen 2.1 supports Generate and Edit.')
|
||||||
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
||||||
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
||||||
if (!raw.folderId) fail('Choose a folder.')
|
if (!raw.folderId) fail('Choose a folder.')
|
||||||
if ((video || ['edit','compose','iterate'].includes(mode)) && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
|
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) {
|
||||||
|
if (engine === 'qwen21') fail('Qwen Edit requires Start still (image_1). Hero is ignored.')
|
||||||
|
fail('Choose the source still.')
|
||||||
|
}
|
||||||
|
if ((video || ['edit','compose','iterate'].includes(mode)) && engine !== 'qwen21' && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
|
||||||
if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
|
if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
|
||||||
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
|
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
|
||||||
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) fail('Choose the source still.')
|
|
||||||
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip or Previous clip.')
|
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip or Previous clip.')
|
||||||
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
|
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
|
||||||
const promptSections = video ? allSections : {action:allSections.action}
|
const promptSections = video ? allSections : {action:allSections.action}
|
||||||
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
||||||
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
||||||
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
|
const qwen = engine === 'qwen21'
|
||||||
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
const defaultSteps = video ? 8 : qwen ? 25 : 24
|
||||||
|
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
|
||||||
|
const aspect = QWEN21_ASPECT_IDS.has(raw.settings?.aspect) ? raw.settings.aspect : 'auto'
|
||||||
|
const qwenSize = qwen && mode === 'generate' ? resolveQwen21Size(aspect) : null
|
||||||
|
const defaultWidth = qwenSize ? qwenSize.width : qwen ? 1024 : 1344
|
||||||
|
const defaultHeight = qwenSize ? qwenSize.height : qwen ? 1024 : 768
|
||||||
|
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?defaultSteps:1,0,2147483647))) fail(`Invalid ${key}.`)
|
||||||
|
for (const key of ['width','height']) if (number(key,key==='width'?defaultWidth:defaultHeight,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
||||||
const duration=number('duration',5,1,15)
|
const duration=number('duration',5,1,15)
|
||||||
if (video) {
|
if (video) {
|
||||||
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
||||||
@@ -55,10 +68,16 @@ export function validateRequest(raw, xaigen = false) {
|
|||||||
}
|
}
|
||||||
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
||||||
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
||||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
const enhancePrompt = qwen && !video && !!raw.enhancePrompt
|
||||||
|
const turbo = qwen && !video && !!raw.turbo
|
||||||
|
const width = qwenSize ? qwenSize.width : number('width',defaultWidth,256,4096)
|
||||||
|
const height = qwenSize ? qwenSize.height : number('height',defaultHeight,256,4096)
|
||||||
|
const steps = turbo ? QWEN21_TURBO_STEPS : number('steps',defaultSteps,1,100)
|
||||||
|
const cfg = turbo ? QWEN21_TURBO_CFG : number('cfg',defaultCfg,0,20)
|
||||||
|
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, enhancePrompt, turbo, ...(turbo ? { lora: QWEN21_TURBO_LORA, sigmas: QWEN21_TURBO_SIGMAS } : {}), lockFace:qwen ? false : raw.lockFace !== false, lockOutfit:qwen ? false : raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:qwen ? '' : (raw.identityStillId || ''), imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||||
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
||||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
|
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
|
||||||
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
|
settings: { aspect, fps:number('fps',24,1,60), width, height, steps, cfg, seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:qwen?[]:(Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[]) },
|
||||||
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
|
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
|
||||||
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
|
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,39 @@
|
|||||||
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
||||||
import { restoreInput } from './contracts.mjs'
|
import { restoreInput, sectionsFrom } from './contracts.mjs'
|
||||||
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
|
import { normalizeStyles } from './styles.mjs'
|
||||||
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) {
|
import { QWEN21_ASPECT_IDS as ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
|
||||||
|
export { resolveQwen21Size, QWEN21_SIZE_TABLE } from './qwen21-size.mjs'
|
||||||
|
|
||||||
|
export const aspectOptions = [
|
||||||
|
{id:'auto',label:'Automatic',hint:'Match start or hero still'},
|
||||||
|
{id:'16:9',label:'16:9',hint:'Widescreen'},
|
||||||
|
{id:'9:16',label:'9:16',hint:'Tall'},
|
||||||
|
{id:'1:1',label:'1:1',hint:'Square'},
|
||||||
|
{id:'4:3',label:'4:3',hint:'Standard'},
|
||||||
|
{id:'3:4',label:'3:4',hint:'Portrait standard'},
|
||||||
|
{id:'3:2',label:'3:2',hint:'Photo'},
|
||||||
|
{id:'2:3',label:'2:3',hint:'Portrait photo'},
|
||||||
|
{id:'21:9',label:'21:9',hint:'Ultrawide'},
|
||||||
|
{id:'9:21',label:'9:21',hint:'Ultra-tall'}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function resolveSize(aspect, source, fallback = {width:1344,height:768}, engine = '') {
|
||||||
|
if (engine === 'qwen21') return resolveQwen21Size(aspect, source)
|
||||||
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
||||||
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
||||||
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
|
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
|
||||||
const factor = Math.min(1344/Math.max(source.width,source.height),768/Math.min(source.width,source.height))
|
const factor = Math.min(1344/Math.max(source.width,source.height),768/Math.min(source.width,source.height))
|
||||||
return {width:Math.max(256,Math.round(source.width*factor/32)*32),height:Math.max(256,Math.round(source.height*factor/32)*32)}
|
return {width:Math.max(256,Math.round(source.width*factor/32)*32),height:Math.max(256,Math.round(source.height*factor/32)*32)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ASPECT_IDS }
|
||||||
|
|
||||||
export function bindClip(form, clip, xaigen = false, memory) {
|
export function bindClip(form, clip, xaigen = false, memory) {
|
||||||
if (!clip?.id) return form
|
if (!clip?.id) return form
|
||||||
const saved = clip.studio2 || clip.job || {}
|
const saved = clip.studio2 || clip.job || {}
|
||||||
const settings = saved.settings || clip.settings || {}
|
const settings = saved.settings || clip.settings || {}
|
||||||
const next = {...form, startClipId:clip.id, mode:'extend', settings:{...form.settings}}
|
const next = {...form, startClipId:clip.id, mode:'extend', settings:{...form.settings}}
|
||||||
next.settings.aspect=['auto','16:9','9:16','1:1'].includes(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto')
|
next.settings.aspect=ASPECT_IDS.has(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto')
|
||||||
for (const key of ['duration','width','height','fps','loraStack','turbo','steps','cfg']) {
|
for (const key of ['duration','width','height','fps','loraStack','turbo','steps','cfg']) {
|
||||||
if (settings[key] == null) continue
|
if (settings[key] == null) continue
|
||||||
try { next.settings[key] = typeof structuredClone === 'function' ? structuredClone(settings[key]) : JSON.parse(JSON.stringify(settings[key])) }
|
try { next.settings[key] = typeof structuredClone === 'function' ? structuredClone(settings[key]) : JSON.parse(JSON.stringify(settings[key])) }
|
||||||
@@ -35,7 +55,7 @@ export function restoreAsset(form, item, xaigen = false, memory) {
|
|||||||
const restored = restoreInput(item)
|
const restored = restoreInput(item)
|
||||||
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
|
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
|
||||||
next.settings.seedMode='random'
|
next.settings.seedMode='random'
|
||||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||||
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
|
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
|
||||||
next.imageAId=item.id
|
next.imageAId=item.id
|
||||||
next.startClipId=''
|
next.startClipId=''
|
||||||
@@ -58,11 +78,77 @@ export function changeSource(form) {
|
|||||||
|
|
||||||
export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
|
export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
|
||||||
export function useAsSource(form, item) { return {...form,imageAId:item.id} }
|
export function useAsSource(form, item) { return {...form,imageAId:item.id} }
|
||||||
|
|
||||||
|
/** Hydrate the live bench from a saved Studio 2 request (Load). Does not enqueue. */
|
||||||
|
export function hydrateStudio2Job(form, saved, { stillIds = new Set(), clipIds = new Set(), xaigen = false, memory } = {}) {
|
||||||
|
const src = structuredClone(saved?.studio2 || saved || {})
|
||||||
|
const missing = []
|
||||||
|
const keepStill = (id, label) => {
|
||||||
|
const value = id || ''
|
||||||
|
if (!value) return ''
|
||||||
|
if (stillIds.has(value)) return value
|
||||||
|
missing.push(`${label} missing from library`)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const keepClip = (id, label) => {
|
||||||
|
const value = id || ''
|
||||||
|
if (!value) return ''
|
||||||
|
if (clipIds.has(value)) return value
|
||||||
|
missing.push(`${label} missing from library`)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const modeRaw = src.mode === 'iterate' ? 'edit' : src.mode
|
||||||
|
const mode = ['generate','edit','compose','refine','video','extend','music'].includes(modeRaw) ? modeRaw : 'edit'
|
||||||
|
let engine = src.engine === 'qwen21' ? 'qwen21' : src.engine === 'krea' ? 'krea' : src.engine === 'ltx' ? 'ltx' : src.engine === 'minimax' ? 'minimax' : 'flux'
|
||||||
|
if (['video','extend'].includes(mode)) engine = xaigen && engine === 'ltx' ? 'ltx' : 'minimax'
|
||||||
|
else if (engine === 'minimax' || engine === 'ltx') engine = 'flux'
|
||||||
|
const settings = structuredClone(src.settings || {})
|
||||||
|
const aspect = ASPECT_IDS.has(settings.aspect) ? settings.aspect : 'auto'
|
||||||
|
const loraStack = Array.isArray(settings.loraStack) ? settings.loraStack.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
strengthModel: Number(item.strengthModel ?? item.strength ?? 1),
|
||||||
|
strengthClip: Number(item.strengthClip ?? item.strengthModel ?? item.strength ?? 1)
|
||||||
|
})) : []
|
||||||
|
const guides = (Array.isArray(src.guides) ? src.guides : []).map(g => {
|
||||||
|
const stillId = keepStill(g.stillId, 'guide still')
|
||||||
|
return stillId ? { stillId, frame: g.frame } : null
|
||||||
|
}).filter(Boolean)
|
||||||
|
const next = {
|
||||||
|
...form,
|
||||||
|
mode,
|
||||||
|
engine,
|
||||||
|
enhancePrompt: engine === 'qwen21' && !!src.enhancePrompt,
|
||||||
|
turbo: engine === 'qwen21' && !!src.turbo,
|
||||||
|
lockFace: src.lockFace !== false,
|
||||||
|
lockOutfit: src.lockOutfit !== false,
|
||||||
|
hideInput: !!src.hideInput,
|
||||||
|
folderId: src.folderId || form.folderId,
|
||||||
|
identityStillId: keepStill(src.identityStillId, 'hero'),
|
||||||
|
imageAId: keepStill(src.imageAId, 'start'),
|
||||||
|
imageBId: keepStill(src.imageBId, 'second still'),
|
||||||
|
maskId: keepStill(src.maskId, 'mask'),
|
||||||
|
startClipId: keepClip(src.startClipId, 'start clip'),
|
||||||
|
endStillId: keepStill(src.endStillId, 'end still'),
|
||||||
|
guides,
|
||||||
|
imageStyles: normalizeStyles(src.imageStyles || { positive: [], negative: [] }),
|
||||||
|
promptSections: sectionsFrom(src.promptSections || { action: src.compiledPrompt || src.prompt || '' }),
|
||||||
|
settings: {
|
||||||
|
...form.settings,
|
||||||
|
...settings,
|
||||||
|
aspect,
|
||||||
|
seed: settings.seed ?? form.settings?.seed ?? null,
|
||||||
|
seedMode: 'locked',
|
||||||
|
loraStack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { form: restoreLoras(next, loraStack, xaigen, memory), missing: missing[0] || '', warnings: missing }
|
||||||
|
}
|
||||||
|
|
||||||
export function restoreAll(form,item,xaigen=false,memory) {
|
export function restoreAll(form,item,xaigen=false,memory) {
|
||||||
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
|
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
|
||||||
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
|
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
|
||||||
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
|
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
|
||||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||||
next.imageAId=item.id
|
next.imageAId=item.id
|
||||||
next.startClipId=''
|
next.startClipId=''
|
||||||
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
|
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {filterLoraStackForUse} from '../../utils/loras.ts'
|
|||||||
|
|
||||||
export function loraUse(form) {
|
export function loraUse(form) {
|
||||||
const video=['video','extend'].includes(form.mode)
|
const video=['video','extend'].includes(form.mode)
|
||||||
return video ? (form.engine==='ltx'?'ltx':'minimax') : (form.engine==='krea'?'krea':'flux')
|
if (video) return form.engine==='ltx'?'ltx':'minimax'
|
||||||
|
if (form.engine==='qwen21') return 'qwen21'
|
||||||
|
return form.engine==='krea'?'krea':'flux'
|
||||||
}
|
}
|
||||||
|
|
||||||
// One controller per bench. Storage is optional (SSR/private browsing still works).
|
// One controller per bench. Storage is optional (SSR/private browsing still works).
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const terminal = new Set(['complete','failed','cancelled'])
|
const terminal = new Set(['complete','failed','cancelled'])
|
||||||
const running = new Set(['waking','submitting','rendering','saving'])
|
// Include enhancing so PE is the same queue row as generate (not a vanished "Queue 0").
|
||||||
|
const running = new Set(['waking','enhancing','submitting','rendering','saving'])
|
||||||
export function queueJobs(jobs) {
|
export function queueJobs(jobs) {
|
||||||
return jobs.filter(j=>running.has(j.state)||['waiting','queued','held'].includes(j.state)).sort((a,b)=>Number(running.has(b.state))-Number(running.has(a.state)) || (a.queuedAt||0)-(b.queuedAt||0))
|
return jobs.filter(j=>running.has(j.state)||['waiting','queued','held'].includes(j.state)).sort((a,b)=>Number(running.has(b.state))-Number(running.has(a.state)) || (a.queuedAt||0)-(b.queuedAt||0))
|
||||||
}
|
}
|
||||||
@@ -14,3 +15,31 @@ export function elapsedSeconds(job, now) {
|
|||||||
const end=terminal.has(job.state)?job.finishedAt || job.savedAt || job.updatedAt || start:now
|
const end=terminal.has(job.state)?job.finishedAt || job.savedAt || job.updatedAt || start:now
|
||||||
return Number.isFinite(start)&&Number.isFinite(end)?Math.max(0,Math.floor((end-start)/1000)):0
|
return Number.isFinite(start)&&Number.isFinite(end)?Math.max(0,Math.floor((end-start)/1000)):0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stage label shown on the queue row (header pill uses status.mjs). */
|
||||||
|
export function jobStageLabel(state) {
|
||||||
|
const labels={
|
||||||
|
waiting:'Queued',queued:'Queued',held:'Held',
|
||||||
|
waking:'Waking up',enhancing:'Enhancing prompt…',
|
||||||
|
submitting:'Generating…',rendering:'Generating…',saving:'Saving',
|
||||||
|
complete:'Ready',failed:'Failed',cancelled:'Cancelled',
|
||||||
|
}
|
||||||
|
return labels[state] || state
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact progress for the queue row. Enhancing prefers indeterminate over a fake 0%. */
|
||||||
|
export function jobProgressView(job) {
|
||||||
|
const state=job?.state
|
||||||
|
if(!['enhancing','submitting','rendering','waking','saving'].includes(state)) return null
|
||||||
|
const p=job?.progress
|
||||||
|
if(state==='enhancing'){
|
||||||
|
if(p && Number(p.maxStep)>0 && Number(p.step)>=0)
|
||||||
|
return {indeterminate:false,percent:Math.max(0,Math.min(100,Number(p.percent)||Math.round(Number(p.step)/Number(p.maxStep)*100)))}
|
||||||
|
return {indeterminate:true,percent:null}
|
||||||
|
}
|
||||||
|
if(state==='rendering' && p && Number(p.maxStep)>0)
|
||||||
|
return {indeterminate:false,percent:Math.max(0,Math.min(100,Number(p.percent)||Math.round(Number(p.step)/Number(p.maxStep)*100)))}
|
||||||
|
// waking / submitting / saving / rendering without steps: no fake bar
|
||||||
|
if(state==='submitting' || state==='waking') return {indeterminate:true,percent:null}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/** Qwen Edit / PE-I2I prompt helpers. Start still = <image1>; Hero is never a slot. */
|
||||||
|
|
||||||
|
export const QWEN21_EDIT_KEEP =
|
||||||
|
'Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.'
|
||||||
|
|
||||||
|
export const QWEN21_EDIT_KEEP_CHANGE =
|
||||||
|
`${QWEN21_EDIT_KEEP}\nApply only this change:`
|
||||||
|
|
||||||
|
const EDIT_VERB =
|
||||||
|
/\b(?:change|replace|put|swap|keep|remove|add|edit|make|turn|convert|transform|modify|update|set|apply|wear|place|swap|switch)\b/i
|
||||||
|
|
||||||
|
const DESCRIBE_LEAD =
|
||||||
|
/^(?:the image is\b|this is a photograph of\b|a vertical photorealistic\b|a photorealistic\b|this (?:image|photo|picture) (?:shows|depicts|features)\b|the (?:photo|picture) (?:shows|depicts|features)\b)/i
|
||||||
|
|
||||||
|
/** Fixed identity stanza + the user's raw edit instruction. Always includes <image1>. */
|
||||||
|
export function buildQwen21EditKeepStanza(rawInstruction) {
|
||||||
|
const raw = String(rawInstruction || '').trim()
|
||||||
|
if (!raw) return QWEN21_EDIT_KEEP.trim()
|
||||||
|
return `${QWEN21_EDIT_KEEP_CHANGE} ${raw}`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enhance off / refusal: ensure the sample prompt is the keep stanza, never PE-only. */
|
||||||
|
export function ensureQwen21EditPrompt(prompt) {
|
||||||
|
const text = String(prompt || '').trim()
|
||||||
|
if (!text) return text
|
||||||
|
if (/Keep the identity, face, body, pose, and likeness of the person in <image1>/i.test(text)) return text
|
||||||
|
return buildQwen21EditKeepStanza(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PE rewrite looks like a T2I observer caption, not an edit directive. */
|
||||||
|
export function isQwen21PeEditDescribe(rewritten) {
|
||||||
|
const text = String(rewritten || '').trim()
|
||||||
|
if (!text) return true
|
||||||
|
if (DESCRIBE_LEAD.test(text)) return true
|
||||||
|
if (!EDIT_VERB.test(text)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function retagNaturalImageRefs(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/\bthe woman in the (?:input )?(?:photo|picture|image)\b/gi, 'the person in <image1>')
|
||||||
|
.replace(/\bthe (?:person|man|subject) in the (?:input )?(?:photo|picture|image)\b/gi, 'the person in <image1>')
|
||||||
|
.replace(/\bin the (?:input )?(?:photo|picture|image)\b/gi, 'in <image1>')
|
||||||
|
.replace(/\bthe (?:input )?(?:photo|picture|image)\b/gi, '<image1>')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After PE-I2I: never let the PE chunk be the entire prompt.
|
||||||
|
* Always lead with the keep/<image1> stanza + raw instruction; append PE only when it is an edit directive.
|
||||||
|
*/
|
||||||
|
export function stitchQwen21PeEditPrompt(rawInstruction, rewritten) {
|
||||||
|
const stanza = buildQwen21EditKeepStanza(rawInstruction)
|
||||||
|
let pe = retagNaturalImageRefs(String(rewritten || '').trim()).trim()
|
||||||
|
if (!pe) return { prompt: stanza, skippedAsDescribe: false }
|
||||||
|
if (isQwen21PeEditDescribe(pe)) {
|
||||||
|
return { prompt: stanza, skippedAsDescribe: true }
|
||||||
|
}
|
||||||
|
// No <image1> in the PE chunk is fine — stanza already has the tag; keep PE as extra detail.
|
||||||
|
return { prompt: `${stanza}\n\n${pe}`.trim(), skippedAsDescribe: false }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/** Qwen PE refusal / empty-rewrite detection — fail open to promptRaw. */
|
||||||
|
|
||||||
|
const REFUSAL_RE =
|
||||||
|
/i cannot|i can't|i['’]m not able|i am not able|cannot assist|can't assist|won['’]t create|will not create|against my|not appropriate|\bsafety\b|content policy|抱歉|无法|不能协助|不符合/i
|
||||||
|
|
||||||
|
const STOP = new Set([
|
||||||
|
'the', 'and', 'for', 'are', 'was', 'were', 'been', 'being', 'this', 'that', 'with', 'from',
|
||||||
|
'into', 'your', 'have', 'will', 'would', 'could', 'should', 'about', 'there', 'their', 'them',
|
||||||
|
'then', 'than', 'when', 'what', 'which', 'while', 'where', 'make', 'made', 'like', 'just',
|
||||||
|
'only', 'also', 'over', 'under', 'after', 'before', 'between', 'through', 'image', 'photo',
|
||||||
|
'picture', 'please', 'create', 'generate', 'show', 'want', 'need', 'scene', 'style', 'prompt',
|
||||||
|
'edit', 'change', 'keep', 'using', 'into', 'onto', 'her', 'him', 'his', 'she', 'they', 'them',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** Concrete tokens from the user brief (latin words ≥3 or CJK runs). */
|
||||||
|
export function concreteTokens(text) {
|
||||||
|
const raw = String(text || '').toLowerCase().match(/[a-z][a-z0-9-]{2,}|[\u4e00-\u9fff]{2,}/g) || []
|
||||||
|
return [...new Set(raw)].filter(w => !STOP.has(w))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGutted(userPrompt, rewritten) {
|
||||||
|
const user = String(userPrompt || '').trim()
|
||||||
|
const out = String(rewritten || '').trim()
|
||||||
|
if (!user || !out) return !out
|
||||||
|
if (out.length >= user.length) return false
|
||||||
|
const nouns = concreteTokens(user)
|
||||||
|
if (!nouns.length) return false
|
||||||
|
const lower = out.toLowerCase()
|
||||||
|
return !nouns.some(n => lower.includes(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{ refused: boolean, reason: string }}
|
||||||
|
*/
|
||||||
|
export function qwen21PeRefusal(userPrompt, { positive_prompt, thinking, parse_ok } = {}) {
|
||||||
|
const positive = String(positive_prompt || '').trim()
|
||||||
|
const think = String(thinking || '').trim()
|
||||||
|
if (!parse_ok && !positive) return { refused: true, reason: 'empty rewrite (parse_ok false)' }
|
||||||
|
if (!positive) return { refused: true, reason: 'empty rewrite' }
|
||||||
|
if (REFUSAL_RE.test(positive) || REFUSAL_RE.test(think)) return { refused: true, reason: 'model refused' }
|
||||||
|
if (isGutted(userPrompt, positive)) return { refused: true, reason: 'gutted rewrite' }
|
||||||
|
return { refused: false, reason: '' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/** Qwen-safe ~1 MP grid (÷32). Do not use native 2K bins on 16 GB. */
|
||||||
|
export const QWEN21_SIZE_TABLE = {
|
||||||
|
'1:1': [1024, 1024],
|
||||||
|
'16:9': [1536, 864],
|
||||||
|
'9:16': [864, 1536],
|
||||||
|
'4:3': [1152, 864],
|
||||||
|
'3:4': [864, 1152],
|
||||||
|
'3:2': [1248, 832],
|
||||||
|
'2:3': [832, 1248],
|
||||||
|
'21:9': [1536, 672],
|
||||||
|
'9:21': [672, 1536]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QWEN21_ASPECT_IDS = new Set(['auto', ...Object.keys(QWEN21_SIZE_TABLE)])
|
||||||
|
|
||||||
|
function parseAspectRatio(aspect) {
|
||||||
|
const m = String(aspect || '').trim().match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/)
|
||||||
|
if (!m) return null
|
||||||
|
const w = Number(m[1]), h = Number(m[2])
|
||||||
|
if (!(w > 0 && h > 0)) return null
|
||||||
|
return w / h
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestQwen21Aspect(aspect) {
|
||||||
|
const target = parseAspectRatio(aspect)
|
||||||
|
if (target == null) return null
|
||||||
|
let best = '1:1', bestDiff = Infinity
|
||||||
|
for (const [key, [w, h]] of Object.entries(QWEN21_SIZE_TABLE)) {
|
||||||
|
const diff = Math.abs(w / h - target)
|
||||||
|
if (diff < bestDiff) { bestDiff = diff; best = key }
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveQwen21Size(aspect, source) {
|
||||||
|
const key = String(aspect || 'auto').trim()
|
||||||
|
if (QWEN21_SIZE_TABLE[key]) {
|
||||||
|
const [width, height] = QWEN21_SIZE_TABLE[key]
|
||||||
|
return { width, height }
|
||||||
|
}
|
||||||
|
if (key === 'auto') {
|
||||||
|
if (source?.width && source?.height) {
|
||||||
|
const nearest = nearestQwen21Aspect(`${source.width}:${source.height}`)
|
||||||
|
if (nearest) {
|
||||||
|
const [width, height] = QWEN21_SIZE_TABLE[nearest]
|
||||||
|
return { width, height }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { width: 1024, height: 1024 }
|
||||||
|
}
|
||||||
|
const nearest = nearestQwen21Aspect(key)
|
||||||
|
if (nearest) {
|
||||||
|
const [width, height] = QWEN21_SIZE_TABLE[nearest]
|
||||||
|
return { width, height }
|
||||||
|
}
|
||||||
|
console.warn(`[qwen21] unknown aspect "${key}"; using 1024×1024`)
|
||||||
|
return { width: 1024, height: 1024 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** Viggle DMD turbo LoRA on the existing Qwen 2.1 GGUF (not a separate engine). */
|
||||||
|
export const QWEN21_TURBO_LORA = 'Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors'
|
||||||
|
export const QWEN21_TURBO_SIGMAS = '1.0, 0.9375, 0.875, 0.75, 0.5, 0.25'
|
||||||
|
export const QWEN21_TURBO_STEPS = 6
|
||||||
|
export const QWEN21_TURBO_CFG = 1
|
||||||
@@ -8,8 +8,8 @@ export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
|
|||||||
return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)}
|
return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)}
|
||||||
}
|
}
|
||||||
if(health.yue2?.busy)return {label:'Generating',tone:'busy',detail:'YuE2 is using the GPU'}
|
if(health.yue2?.busy)return {label:'Generating',tone:'busy',detail:'YuE2 is using the GPU'}
|
||||||
const job=jobs.find(j=>['waking','submitting','rendering','saving'].includes(j.state))
|
const job=jobs.find(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state))
|
||||||
if(job)return {label:{waking:'Waking up',submitting:'Submitting',rendering:'Generating',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
|
if(job)return {label:{waking:'Waking up',enhancing:'Enhancing prompt…',submitting:'Submitting',rendering:'Generating…',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
|
||||||
if(health.comfy?.busy || health.imageComfy?.busy)return {label:'Busy',tone:'busy',detail:'GPU is working on another job'}
|
if(health.comfy?.busy || health.imageComfy?.busy)return {label:'Busy',tone:'busy',detail:'GPU is working on another job'}
|
||||||
if(health.comfy?.ok || health.imageComfy?.ok)return {label:'Ready',tone:'ready',detail:'No job running'}
|
if(health.comfy?.ok || health.imageComfy?.ok)return {label:'Ready',tone:'ready',detail:'No job running'}
|
||||||
if(health.comfy?.asleep)return {label:'Sleeping',tone:'sleeping',detail:'Wakes when a job starts'}
|
if(health.comfy?.asleep)return {label:'Sleeping',tone:'sleeping',detail:'Wakes when a job starts'}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
export const styleGroups={Render:['Photorealistic','Pixar','3D Disney','Comic','Watercolor','Oil painting'],Quality:['Realistic skin','Skin pores','Fine detail','Natural texture'],Lighting:['Golden hour','Soft studio','Window light','Overcast','Cinematic lighting']}
|
export const styleGroups={Render:['Photorealistic','Pixar','3D Disney','Comic','Watercolor','Oil painting'],Quality:['Realistic skin','Skin pores','Fine detail','Natural texture'],Lighting:['Golden hour','Soft studio','Window light','Overcast','Cinematic lighting']}
|
||||||
export function normalizeStyles(value={}) { const known=Object.values(styleGroups).flat();const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))];return {positive,negative:[...new Set((value.negative||[]).filter(s=>known.includes(s)&&!positive.includes(s)))]} }
|
export const defaultNegatives=['blurry','deformed','low quality','text','watermark']
|
||||||
|
export function normalizeStyles(value={}) {
|
||||||
|
const known=[...Object.values(styleGroups).flat(),...defaultNegatives]
|
||||||
|
const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))]
|
||||||
|
// Freeform negatives are allowed (sticky custom chips); positives stay on the known style list.
|
||||||
|
const negative=[...new Set((value.negative||[]).map(s=>String(s||'').trim()).filter(s=>s&&!positive.includes(s)))]
|
||||||
|
return {positive,negative}
|
||||||
|
}
|
||||||
export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next }
|
export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next }
|
||||||
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
|
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
|
||||||
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
|
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function newestOutput(jobs, seen, folderId) {
|
|||||||
return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null
|
return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null
|
||||||
}
|
}
|
||||||
export function reconcileSelection(selected, assets) { return assets.find(a=>a.id===selected?.id) || assets.at(-1) || null }
|
export function reconcileSelection(selected, assets) { return assets.find(a=>a.id===selected?.id) || assets.at(-1) || null }
|
||||||
export function activeJobs(jobs) { return jobs.filter(j=>['waking','submitting','rendering','saving'].includes(j.state)) }
|
export function activeJobs(jobs) { return jobs.filter(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state)) }
|
||||||
export function previewHidden(asset, revealed, xaigen) {
|
export function previewHidden(asset, revealed, xaigen) {
|
||||||
if (!xaigen || !asset?.id) return false
|
if (!xaigen || !asset?.id) return false
|
||||||
return !revealed?.has?.(asset.id)
|
return !revealed?.has?.(asset.id)
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import assert from 'node:assert/strict'
|
|||||||
import {readFileSync} from 'node:fs'
|
import {readFileSync} from 'node:fs'
|
||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
|
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
|
||||||
import {useAsHero,useAsSource,restoreAll} from '../shared/studio2/input.mjs'
|
import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs'
|
||||||
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
|
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
|
||||||
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
||||||
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
|
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
|
||||||
|
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
|
||||||
|
import {ensureQwen21EditPrompt,stitchQwen21PeEditPrompt,QWEN21_EDIT_KEEP_CHANGE} from '../shared/studio2/qwen21-edit.mjs'
|
||||||
|
import {qwen21PeRefusal} from '../shared/studio2/qwen21-pe.mjs'
|
||||||
|
|
||||||
test('deleted library files never return from job outputs; selection falls back or clears',()=>{
|
test('deleted library files never return from job outputs; selection falls back or clears',()=>{
|
||||||
const deleted={id:'gone',folderId:'f'},keep={id:'keep',folderId:'f',createdAt:2};const jobs=[{request:{folderId:'f'},outputs:[deleted]}]
|
const deleted={id:'gone',folderId:'f'},keep={id:'keep',folderId:'f',createdAt:2};const jobs=[{request:{folderId:'f'},outputs:[deleted]}]
|
||||||
let assets=folderAssets({stills:[keep],clips:[]},jobs,'f');assert.deepEqual(assets.map(a=>a.id),['keep']);assert.equal(reconcileSelection(deleted,assets).id,'keep');assert.equal(reconcileSelection(deleted,[]),null)
|
let assets=folderAssets({stills:[keep],clips:[]},jobs,'f');assert.deepEqual(assets.map(a=>a.id),['keep']);assert.equal(reconcileSelection(deleted,assets).id,'keep');assert.equal(reconcileSelection(deleted,[]),null)
|
||||||
})
|
})
|
||||||
test('bench includes only active execution phases and clear finished retains internal cleanup records',()=>{
|
test('bench includes only active execution phases and clear finished retains internal cleanup records',()=>{
|
||||||
assert.deepEqual(activeJobs(['waiting','waking','submitting','rendering','saving','complete','cancelled','failed'].map(state=>({state}))).map(j=>j.state),['waking','submitting','rendering','saving'])
|
assert.deepEqual(activeJobs(['waiting','waking','enhancing','submitting','rendering','saving','complete','cancelled','failed'].map(state=>({state}))).map(j=>j.state),['waking','enhancing','submitting','rendering','saving'])
|
||||||
const rows=[{id:'done',state:'complete',request:{folderId:'f'},cleanupPending:[{}]},{id:'active',state:'rendering',request:{folderId:'f'}}],saved=[]
|
const rows=[{id:'done',state:'complete',request:{folderId:'f'},cleanupPending:[{}]},{id:'active',state:'rendering',request:{folderId:'f'}}],saved=[]
|
||||||
const src=readFileSync(new URL('../server/api/studio-2/clear-finished.post.ts',import.meta.url),'utf8').replace(/^import .*;\r?\n/gm,'').replace('export default ','const handler=')
|
const src=readFileSync(new URL('../server/api/studio-2/clear-finished.post.ts',import.meta.url),'utf8').replace(/^import .*;\r?\n/gm,'').replace('export default ','const handler=')
|
||||||
const js=ts.transpileModule(src,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
|
const js=ts.transpileModule(src,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
|
||||||
@@ -28,6 +31,8 @@ test('hidden xAIGen canvas and family render no image or video elements',async()
|
|||||||
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
||||||
assert.match(src,/aria-label="Current family outputs"/)
|
assert.match(src,/aria-label="Current family outputs"/)
|
||||||
assert.match(src,/preview hidden/)
|
assert.match(src,/preview hidden/)
|
||||||
|
assert.match(src,/Reveal all/)
|
||||||
|
assert.match(src,/pickerRevealAll/)
|
||||||
assert.doesNotMatch(src,/Hide input stills/)
|
assert.doesNotMatch(src,/Hide input stills/)
|
||||||
assert.doesNotMatch(src,/Hide output preview/)
|
assert.doesNotMatch(src,/Hide output preview/)
|
||||||
})
|
})
|
||||||
@@ -74,3 +79,115 @@ test('image chips compile positive and negative independently; video chips do no
|
|||||||
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
||||||
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
||||||
})
|
})
|
||||||
|
test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
|
||||||
|
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})
|
||||||
|
assert.equal(q.engine,'qwen21');assert.equal(q.settings.steps,25);assert.equal(q.settings.cfg,1)
|
||||||
|
assert.equal(q.settings.width,1024);assert.equal(q.settings.height,1024)
|
||||||
|
assert.equal(q.lockFace,false);assert.equal(q.lockOutfit,false);assert.deepEqual(q.settings.loraStack,[])
|
||||||
|
assert.equal(q.enhancePrompt,false)
|
||||||
|
assert.equal(q.turbo,false)
|
||||||
|
const wide=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'wide'},settings:{aspect:'16:9'}})
|
||||||
|
assert.equal(wide.settings.width,1536);assert.equal(wide.settings.height,864)
|
||||||
|
const tall=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'tall'},settings:{aspect:'9:16'}})
|
||||||
|
assert.equal(tall.settings.width,864);assert.equal(tall.settings.height,1536)
|
||||||
|
const turbo=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',turbo:true,promptSections:{action:'fast'},settings:{aspect:'1:1',steps:25,cfg:4}})
|
||||||
|
assert.equal(turbo.turbo,true);assert.equal(turbo.settings.steps,6);assert.equal(turbo.settings.cfg,1)
|
||||||
|
assert.match(String(turbo.lora),/viggle-turbo-v0\.2\.1-6step-lora-r128/)
|
||||||
|
assert.match(String(turbo.sigmas),/0\.9375/)
|
||||||
|
const enhanced=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',enhancePrompt:true,promptSections:{action:'corgi'},settings:{aspect:'1:1'}})
|
||||||
|
assert.equal(enhanced.enhancePrompt,true)
|
||||||
|
assert.equal(validateRequest({mode:'generate',engine:'flux',folderId:'f',enhancePrompt:true,turbo:true,promptSections:{action:'x'},settings:{aspect:'1:1'}}).turbo,false)
|
||||||
|
const edit=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'change the sky'},settings:{aspect:'auto'}})
|
||||||
|
assert.equal(edit.mode,'edit');assert.equal(edit.settings.steps,25);assert.equal(edit.settings.cfg,1)
|
||||||
|
assert.equal(edit.lockFace,false);assert.deepEqual(edit.settings.loraStack,[])
|
||||||
|
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',promptSections:{action:'x'}}),/Start still/)
|
||||||
|
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',identityStillId:'hero',promptSections:{action:'x'}}),/Start 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/)
|
||||||
|
assert.equal(ensureQwen21EditPrompt('put her in a red leather jacket'),`${QWEN21_EDIT_KEEP_CHANGE} put her in a red leather jacket`)
|
||||||
|
assert.match(ensureQwen21EditPrompt('put her in a red leather jacket'),/<image1>/)
|
||||||
|
assert.equal(ensureQwen21EditPrompt('Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.\nApply only this change: x'),'Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.\nApply only this change: x')
|
||||||
|
const stitchedKeep=stitchQwen21PeEditPrompt('put her in a red leather jacket','Change the outfit on the person in <image1> to a red leather jacket')
|
||||||
|
assert.match(stitchedKeep.prompt,/Keep the identity/)
|
||||||
|
assert.match(stitchedKeep.prompt,/<image1>/)
|
||||||
|
assert.match(stitchedKeep.prompt,/red leather jacket/)
|
||||||
|
assert.equal(stitchedKeep.skippedAsDescribe,false)
|
||||||
|
assert.match(stitchedKeep.prompt,/Change the outfit/)
|
||||||
|
const stitchedDescribe=stitchQwen21PeEditPrompt('put her in a red leather jacket','The image is a photograph of a woman standing outdoors in soft light…')
|
||||||
|
assert.equal(stitchedDescribe.skippedAsDescribe,true)
|
||||||
|
assert.match(stitchedDescribe.prompt,/Keep the identity/)
|
||||||
|
assert.match(stitchedDescribe.prompt,/put her in a red leather jacket/)
|
||||||
|
assert.doesNotMatch(stitchedDescribe.prompt,/The image is a photograph/)
|
||||||
|
const stitchedNoTag=stitchQwen21PeEditPrompt('put her in a red leather jacket','Change the jacket on the woman in the photo to red leather')
|
||||||
|
assert.equal(stitchedNoTag.skippedAsDescribe,false)
|
||||||
|
assert.match(stitchedNoTag.prompt,/<image1>/)
|
||||||
|
assert.doesNotMatch(stitchedNoTag.prompt,/\bthe photo\b/i)
|
||||||
|
assert.match(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/Single-image edits still use <image1>/)
|
||||||
|
assert.match(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/EDIT INSTRUCTION/)
|
||||||
|
assert.doesNotMatch(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/do NOT use tags/)
|
||||||
|
assert.doesNotMatch(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_t2i.txt',import.meta.url),'utf8'),/EDIT INSTRUCTION/)
|
||||||
|
assert.doesNotMatch(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_t2i.txt',import.meta.url),'utf8'),/Single-image edits still use <image1>/)
|
||||||
|
assert.equal(qwen21PeRefusal('corgi in the rain',{positive_prompt:'A photorealistic photograph of a corgi standing in rain…',parse_ok:true}).refused,false)
|
||||||
|
assert.equal(qwen21PeRefusal('nude adult portrait',{positive_prompt:"I cannot assist with that request.",thinking:'',parse_ok:true}).refused,true)
|
||||||
|
assert.equal(qwen21PeRefusal('put her in a red leather jacket',{positive_prompt:'',parse_ok:false}).refused,true)
|
||||||
|
assert.equal(qwen21PeRefusal('corgi wearing sunglasses on a beach',{positive_prompt:'A soft scene.',parse_ok:true}).refused,true)
|
||||||
|
assert.match(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_t2i.txt',import.meta.url),'utf8'),/Adult and explicit briefs/)
|
||||||
|
assert.match(readFileSync(new URL('../host/qwen21-pe-prompts/system_prompt_edit.txt',import.meta.url),'utf8'),/Adult and explicit briefs/)
|
||||||
|
assert.match(readFileSync(new URL('../scripts/setup-qwen21.ps1',import.meta.url),'utf8'),/qwen21-pe-prompts/)
|
||||||
|
assert.match(readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8'),/qwen21PeRefusal/)
|
||||||
|
assert.match(readFileSync(new URL('../components/studio2/Details.vue',import.meta.url),'utf8'),/Enhance skipped \(model refused\)/)
|
||||||
|
})
|
||||||
|
test('hydrateStudio2Job restores LoRA strengths, locked seed, and clears missing stills',()=>{
|
||||||
|
const form={mode:'generate',engine:'flux',identityStillId:'',imageAId:'',promptSections:{action:''},imageStyles:{positive:[],negative:[]},settings:{aspect:'auto',seed:null,seedMode:'random',loraStack:[]},guides:[]}
|
||||||
|
const saved={mode:'edit',engine:'flux',identityStillId:'hero1',imageAId:'gone',lockFace:true,lockOutfit:false,promptSections:{action:'pokemon trainer'},imageStyles:{positive:[],negative:['blurry','custom bad']},settings:{aspect:'16:9',seed:42,steps:24,cfg:4,loraStack:[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}]}}
|
||||||
|
const {form:next,missing}=hydrateStudio2Job(form,saved,{stillIds:new Set(['hero1']),clipIds:new Set()})
|
||||||
|
assert.equal(next.mode,'edit');assert.equal(next.identityStillId,'hero1');assert.equal(next.imageAId,'')
|
||||||
|
assert.equal(next.settings.seed,42);assert.equal(next.settings.seedMode,'locked');assert.equal(next.settings.aspect,'16:9')
|
||||||
|
assert.deepEqual(next.settings.loraStack,[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}])
|
||||||
|
assert.deepEqual(next.imageStyles.negative,['blurry','custom bad'])
|
||||||
|
assert.match(missing,/start missing/)
|
||||||
|
})
|
||||||
|
test('custom negative chips survive normalizeStyles and bench exposes Load actions',()=>{
|
||||||
|
let styles={positive:[],negative:['blurry','my watermark']}
|
||||||
|
styles=toggleStyle(styles,'deformed',true)
|
||||||
|
assert.deepEqual(styles.negative,['blurry','my watermark','deformed'])
|
||||||
|
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
||||||
|
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Edit source/)
|
||||||
|
assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/)
|
||||||
|
assert.match(src,/Enhance prompt/);assert.match(src,/enhancePrompt/)
|
||||||
|
assert.match(src,/\bTurbo\b/);assert.match(src,/form\.turbo/)
|
||||||
|
assert.match(src,/Qwen Edit uses Start still as/)
|
||||||
|
assert.match(src,/Hero is Klein-only and is ignored/)
|
||||||
|
assert.match(readFileSync(new URL('../components/studio2/Details.vue',import.meta.url),'utf8'),/\bTyped\b/)
|
||||||
|
assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/\bTyped\b/)
|
||||||
|
assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/promptRaw/)
|
||||||
|
const peT2i=readFileSync(new URL('../server/assets/studio2_qwen21_pe_t2i.json',import.meta.url),'utf8')
|
||||||
|
const peEdit=readFileSync(new URL('../server/assets/studio2_qwen21_pe_edit.json',import.meta.url),'utf8')
|
||||||
|
assert.match(peT2i,/QwenImage21_T2IPromptRewrite/);assert.match(peT2i,/pe_t2i\.int8_convrot/)
|
||||||
|
assert.match(peEdit,/QwenImage21_EditPromptRewrite/);assert.match(peEdit,/pe_i2i\.int8_convrot/)
|
||||||
|
assert.doesNotMatch(peT2i,/UnetLoader|KSampler|VAELoader/)
|
||||||
|
assert.doesNotMatch(peEdit,/UnetLoader|KSampler|VAELoader/)
|
||||||
|
assert.match(peEdit,/"image_1":\s*\["10",\s*0\]/)
|
||||||
|
const editGraph=readFileSync(new URL('../server/assets/studio2_qwen21_edit.json',import.meta.url),'utf8')
|
||||||
|
assert.match(editGraph,/"images\.image_1":\s*\["10",\s*0\]/)
|
||||||
|
assert.match(editGraph,/"latent_image":\s*\["9",\s*2\]/)
|
||||||
|
assert.doesNotMatch(editGraph,/EmptyLatentImage/)
|
||||||
|
const runner=readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8')
|
||||||
|
assert.match(runner,/ensureQwen21EditPrompt/)
|
||||||
|
assert.match(runner,/stitchQwen21PeEditPrompt/)
|
||||||
|
assert.match(runner,/skippedAsDescribe/)
|
||||||
|
assert.match(runner,/promptRaw/)
|
||||||
|
assert.match(runner,/images\.image_1/)
|
||||||
|
assert.match(runner,/comfyPromptId/)
|
||||||
|
assert.match(runner,/generateComfyPromptId/)
|
||||||
|
assert.doesNotMatch(runner,/Keep the subject in <image1>/)
|
||||||
|
const t2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i.json',import.meta.url),'utf8')
|
||||||
|
assert.match(t2i,/EmptyLatentImage/);assert.match(t2i,/"latent_image":\s*\["16",\s*0\]/)
|
||||||
|
const turboT2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i_turbo.json',import.meta.url),'utf8')
|
||||||
|
const turboEdit=readFileSync(new URL('../server/assets/studio2_qwen21_edit_turbo.json',import.meta.url),'utf8')
|
||||||
|
assert.match(turboT2i,/ViggleTurboLora/);assert.match(turboT2i,/ViggleTurboSigmas/);assert.match(turboT2i,/SamplerCustomAdvanced/)
|
||||||
|
assert.match(turboT2i,/0\.9375/);assert.match(turboEdit,/ViggleTurboLora/);assert.match(turboEdit,/"latent":\s*\["9",\s*2\]/)
|
||||||
|
assert.deepEqual(resolveQwen21Size('16:9'),{width:1536,height:864})
|
||||||
|
assert.deepEqual(resolveQwen21Size('1:1'),{width:1024,height:1024})
|
||||||
|
assert.deepEqual(resolveQwen21Size('9:16'),{width:864,height:1536})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import {queueJobs,jobView,elapsedSeconds} from '../shared/studio2/queue-view.mjs'
|
import {queueJobs,jobView,elapsedSeconds,jobStageLabel,jobProgressView} from '../shared/studio2/queue-view.mjs'
|
||||||
test('queue shows every waiting job in order alongside the running job',()=>{
|
test('queue shows every waiting job in order alongside the running job',()=>{
|
||||||
const jobs=[{id:'late',state:'waiting',queuedAt:30},{id:'done',state:'complete',queuedAt:1},{id:'early',state:'waiting',queuedAt:10},{id:'active',state:'rendering',queuedAt:20},{id:'held',state:'held',queuedAt:40}]
|
const jobs=[{id:'late',state:'waiting',queuedAt:30},{id:'done',state:'complete',queuedAt:1},{id:'early',state:'waiting',queuedAt:10},{id:'active',state:'rendering',queuedAt:20},{id:'held',state:'held',queuedAt:40}]
|
||||||
assert.deepEqual(queueJobs(jobs).map(j=>j.id),['active','early','late','held'])
|
assert.deepEqual(queueJobs(jobs).map(j=>j.id),['active','early','late','held'])
|
||||||
assert.equal(queueJobs(Array.from({length:15},(_,i)=>({id:i,state:'waiting',queuedAt:i}))).length,15)
|
assert.equal(queueJobs(Array.from({length:15},(_,i)=>({id:i,state:'waiting',queuedAt:i}))).length,15)
|
||||||
})
|
})
|
||||||
|
test('enhancing stays on the active queue with the same job id as generate',()=>{
|
||||||
|
const pe={id:'one',state:'enhancing',queuedAt:10}
|
||||||
|
const behind={id:'two',state:'waiting',queuedAt:20}
|
||||||
|
assert.deepEqual(queueJobs([behind,pe]).map(j=>j.id),['one','two'])
|
||||||
|
assert.equal(jobStageLabel('enhancing'),'Enhancing prompt…')
|
||||||
|
assert.equal(jobStageLabel('rendering'),'Generating…')
|
||||||
|
assert.equal(jobStageLabel('submitting'),'Generating…')
|
||||||
|
assert.deepEqual(jobProgressView(pe),{indeterminate:true,percent:null})
|
||||||
|
assert.deepEqual(jobProgressView({state:'enhancing',progress:{step:2,maxStep:4,percent:50}}),{indeterminate:false,percent:50})
|
||||||
|
assert.deepEqual(jobProgressView({state:'rendering',progress:{step:3,maxStep:6,percent:50}}),{indeterminate:false,percent:50})
|
||||||
|
assert.equal(jobProgressView({state:'waiting'}),null)
|
||||||
|
// PE → generate is a stage change on the same row, not a vanish frame
|
||||||
|
const same=[{id:'one',state:'submitting',queuedAt:10},{id:'two',state:'waiting',queuedAt:20}]
|
||||||
|
assert.deepEqual(queueJobs(same).map(j=>j.id),['one','two'])
|
||||||
|
})
|
||||||
test('authoritative completion ends a stale rendering row and freezes its timer',()=>{
|
test('authoritative completion ends a stale rendering row and freezes its timer',()=>{
|
||||||
const record={state:'rendering',queuedAt:1000,startedAt:2000,updatedAt:5000}
|
const record={state:'rendering',queuedAt:1000,startedAt:2000,updatedAt:5000}
|
||||||
for(const status of ['complete','error','cancelled']){
|
for(const status of ['complete','error','cancelled']){
|
||||||
|
|||||||
@@ -41,14 +41,29 @@ export type StillGenerationSettings = {
|
|||||||
|
|
||||||
export type SettingsRow = { label: string; value: string }
|
export type SettingsRow = { label: string; value: string }
|
||||||
|
|
||||||
function pushPromptRows(rows: SettingsRow[], item: { prompt?: string; promptPre?: string; promptPost?: string }) {
|
function pushPromptRows(rows: SettingsRow[], item: {
|
||||||
const parts = restorePromptParts(item.prompt || '', {
|
prompt?: string
|
||||||
|
promptRaw?: string
|
||||||
|
promptPre?: string
|
||||||
|
promptPost?: string
|
||||||
|
studio2?: { prompt?: string; promptRaw?: string; compiledPrompt?: string }
|
||||||
|
}) {
|
||||||
|
// Sampled TextEncode string (after PE + stitch). Never a captioner dump of the PNG.
|
||||||
|
const sampled = String(
|
||||||
|
item.prompt
|
||||||
|
|| item.studio2?.prompt
|
||||||
|
|| item.studio2?.compiledPrompt
|
||||||
|
|| ''
|
||||||
|
).trim()
|
||||||
|
const typed = String(item.promptRaw || item.studio2?.promptRaw || '').trim()
|
||||||
|
const parts = restorePromptParts(sampled || item.prompt || '', {
|
||||||
pre: item.promptPre,
|
pre: item.promptPre,
|
||||||
prompt: item.prompt,
|
prompt: sampled || item.prompt,
|
||||||
post: item.promptPost
|
post: item.promptPost
|
||||||
})
|
})
|
||||||
if (parts.pre.trim()) rows.push({ label: 'Pre-text', value: parts.pre.trim() })
|
if (parts.pre.trim()) rows.push({ label: 'Pre-text', value: parts.pre.trim() })
|
||||||
if (parts.prompt.trim()) rows.push({ label: 'Prompt', value: parts.prompt.trim() })
|
if (parts.prompt.trim()) rows.push({ label: 'Prompt', value: parts.prompt.trim() })
|
||||||
|
if (typed && typed !== parts.prompt.trim()) rows.push({ label: 'Typed', value: typed })
|
||||||
if (parts.post.trim()) rows.push({ label: 'Post-text', value: parts.post.trim() })
|
if (parts.post.trim()) rows.push({ label: 'Post-text', value: parts.post.trim() })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +187,16 @@ export function stillModeLabel(still: { filename?: string; settings?: StillGener
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stillSettingsRows(still: { prompt?: string; promptPre?: string; promptPost?: string; width?: number; height?: number; settings?: StillGenerationSettings | null }) {
|
export function stillSettingsRows(still: {
|
||||||
|
prompt?: string
|
||||||
|
promptRaw?: string
|
||||||
|
promptPre?: string
|
||||||
|
promptPost?: string
|
||||||
|
studio2?: { prompt?: string; promptRaw?: string; compiledPrompt?: string }
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
settings?: StillGenerationSettings | null
|
||||||
|
}) {
|
||||||
const settings = still.settings
|
const settings = still.settings
|
||||||
const rows: SettingsRow[] = []
|
const rows: SettingsRow[] = []
|
||||||
pushPromptRows(rows, still)
|
pushPromptRows(rows, still)
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export function kleinLoraOnKreaMessage(name: string) {
|
|||||||
return imageLoraEngineMismatchMessage(name, 'krea')
|
return imageLoraEngineMismatchMessage(name, 'krea')
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea'
|
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea' | 'qwen21'
|
||||||
|
|
||||||
export function isSystemGraphLora(name: string) {
|
export function isSystemGraphLora(name: string) {
|
||||||
const id = loraIdentityKey(name)
|
const id = loraIdentityKey(name)
|
||||||
@@ -301,16 +301,19 @@ export function inferLoraUse(name: string): LoraUse[] | null {
|
|||||||
if (/ltx/.test(value)) uses.push('ltx')
|
if (/ltx/.test(value)) uses.push('ltx')
|
||||||
if (/klein|flux/.test(value)) uses.push('flux')
|
if (/klein|flux/.test(value)) uses.push('flux')
|
||||||
if (/krea/.test(value)) uses.push('krea')
|
if (/krea/.test(value)) uses.push('krea')
|
||||||
|
if (/qwen/.test(value)) uses.push('qwen21')
|
||||||
return uses.length ? uses : null
|
return uses.length ? uses : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loraMatchesUse(name: string, use: LoraUse) {
|
export function loraMatchesUse(name: string, use: LoraUse) {
|
||||||
|
if (use === 'qwen21') return false
|
||||||
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
|
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
|
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
|
||||||
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
|
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
|
||||||
if (use === 'ltx' && !xaigen) return []
|
if (use === 'ltx' && !xaigen) return []
|
||||||
|
if (use === 'qwen21') return []
|
||||||
return filterLoraStackForStudio(stack, xaigen).filter(item => {
|
return filterLoraStackForStudio(stack, xaigen).filter(item => {
|
||||||
const uses = inferLoraUse(item.name)
|
const uses = inferLoraUse(item.name)
|
||||||
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
|
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
|
||||||
@@ -318,6 +321,7 @@ export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function filterLorasForUse(names: string[], use: LoraUse) {
|
export function filterLorasForUse(names: string[], use: LoraUse) {
|
||||||
|
if (use === 'qwen21') return []
|
||||||
return names.filter(name => loraMatchesUse(name, use))
|
return names.filter(name => loraMatchesUse(name, use))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||