Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,693 @@
|
|||||||
/* Studio 2 display skins — Classic | Studio. Layout tokens only; palette matches main.css. */
|
/* Pixel grid tokens — Classic | Studio. Match TARGET-classic-layout.png */
|
||||||
|
|
||||||
:root,
|
:root,
|
||||||
[data-display='classic'],
|
|
||||||
[data-display='studio'] {
|
[data-display='studio'] {
|
||||||
--display-bg: #09090b;
|
--bg: #0b0b0d;
|
||||||
--display-fg: #e4e4e7;
|
--panel: #141416;
|
||||||
--display-muted: #a1a1aa;
|
--panel-raise: #1a1a1d;
|
||||||
--display-border: rgba(255, 255, 255, 0.1);
|
--line: #2a2a2e;
|
||||||
--display-amber: #fbbf24;
|
--text: #f3f1ea;
|
||||||
--display-amber-fg: #09090b;
|
--muted: #9a9588;
|
||||||
--display-radius: 8px;
|
--amber: #f5a524;
|
||||||
--display-focus: rgba(252, 211, 77, 0.5);
|
--amber-on: #1a1204;
|
||||||
|
--chip-bg: #2a2418;
|
||||||
|
--chip-line: #5a4a28;
|
||||||
|
--chip-text: #e8c37a;
|
||||||
|
--success: #7dda6f;
|
||||||
|
--radius: 8px;
|
||||||
|
--page-gap: 12px;
|
||||||
|
--field-gap: 12px;
|
||||||
|
--header-h: 48px;
|
||||||
|
--rail-task-h: 40px;
|
||||||
|
--rail-model-h: 36px;
|
||||||
|
--left-w: 260px;
|
||||||
|
--right-w: 280px;
|
||||||
|
--type: 14px;
|
||||||
|
--type-sm: 12px;
|
||||||
|
--type-label: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display='classic'] {
|
[data-display='classic'] {
|
||||||
--chrome-gap: 0.5rem;
|
--field-gap: 8px;
|
||||||
--rail-h: 2.25rem;
|
--type: 13px;
|
||||||
--card-pad: 0.75rem;
|
|
||||||
--display-type-title: 1.75rem;
|
|
||||||
--display-type-body: 0.875rem;
|
|
||||||
--display-viewer-radius: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display='studio'] {
|
[data-display] {
|
||||||
--chrome-gap: 1rem;
|
background: var(--bg);
|
||||||
--rail-h: 3rem;
|
color: var(--text);
|
||||||
--card-pad: 1rem;
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
--display-type-title: 1.875rem;
|
font-size: var(--type);
|
||||||
--display-type-body: 0.9375rem;
|
|
||||||
--display-viewer-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-title {
|
/* Skin toggles */
|
||||||
font-size: var(--display-type-title);
|
[data-display='classic'] .rail-task,
|
||||||
|
[data-display='classic'] .rail-model,
|
||||||
|
[data-display='classic'] .identity-slots,
|
||||||
|
[data-display='classic'] .eye-viewer,
|
||||||
|
[data-display='classic'] .eye-tile {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
[data-display='studio'] .classic-task-model,
|
||||||
|
[data-display='studio'] .viewer-lock-bar {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-body {
|
.bench-root {
|
||||||
font-size: var(--display-type-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-display] .display-stack {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: 100dvh;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--chrome-gap);
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-card {
|
.bench-root[data-mobile="1"] {
|
||||||
border-radius: var(--display-radius);
|
position: relative;
|
||||||
border: 1px solid var(--display-border);
|
|
||||||
padding: var(--card-pad);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-viewer {
|
.bench-header {
|
||||||
border-radius: var(--display-viewer-radius);
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--header-h);
|
||||||
|
min-height: var(--header-h);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-rail {
|
.bench-brand {
|
||||||
display: flex;
|
font-weight: 650;
|
||||||
flex-wrap: wrap;
|
letter-spacing: 0.02em;
|
||||||
gap: 0.5rem;
|
|
||||||
min-height: var(--rail-h);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-rail__seg {
|
.bench-crumb {
|
||||||
min-height: var(--rail-h);
|
display: inline-flex;
|
||||||
border-radius: var(--display-radius);
|
align-items: center;
|
||||||
border: 1px solid var(--display-border);
|
gap: 6px;
|
||||||
padding: 0.5rem 1rem;
|
color: var(--muted);
|
||||||
font-size: var(--display-type-body);
|
font-size: var(--type-sm);
|
||||||
color: var(--display-fg);
|
}
|
||||||
|
|
||||||
|
.bench-crumb select {
|
||||||
|
max-width: 14rem;
|
||||||
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-rail__seg:focus-visible {
|
.bench-header-right {
|
||||||
box-shadow: 0 0 0 2px var(--display-focus);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-rail__seg[aria-pressed='true'],
|
.bench-nav {
|
||||||
[data-display] .display-rail__seg[data-active='true'] {
|
display: flex;
|
||||||
border-color: var(--display-amber);
|
gap: 14px;
|
||||||
background: var(--display-amber);
|
font-size: var(--type-sm);
|
||||||
color: var(--display-amber-fg);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-rail__seg:disabled,
|
.bench-nav a {
|
||||||
[data-display] .display-rail__seg[aria-disabled='true'] {
|
color: inherit;
|
||||||
opacity: 0.55;
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-nav a:hover,
|
||||||
|
.bench-nav a.router-link-active {
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-raise);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-chip .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-chip[data-tone='ready'] .dot { background: var(--success); }
|
||||||
|
.gpu-chip[data-tone='busy'] .dot { background: var(--amber); animation: pulse 1.2s infinite; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
50% { opacity: 0.45; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-task { min-height: var(--rail-task-h); align-items: center; padding-top: 8px; }
|
||||||
|
.rail-model { min-height: var(--rail-model-h); align-items: center; padding-bottom: 8px; }
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 0 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-model .seg { height: 32px; font-weight: 500; }
|
||||||
|
|
||||||
|
.seg[aria-pressed='true'] {
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: var(--amber);
|
||||||
|
color: var(--amber-on);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display='studio'] .display-rail__seg {
|
.seg:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-grid {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: var(--left-w) minmax(0, 1fr) var(--right-w);
|
||||||
|
gap: var(--page-gap);
|
||||||
|
padding: var(--page-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-panel {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--field-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-label);
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-control {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-raise);
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-control:focus-visible,
|
||||||
|
.field-area:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-area {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 88px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-raise);
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--chip-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--chip-bg);
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: var(--chip-text);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-chip button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-row .field-control { flex: 1; }
|
||||||
|
|
||||||
|
.dice-btn {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-raise);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-lock-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-stack {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage: fills center column above filmstrip + transport */
|
||||||
|
.viewer-frame {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0b0b0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */
|
||||||
|
.viewer-frame > img,
|
||||||
|
.viewer-frame > video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye-viewer {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #000a;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filmstrip {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
height: 80px;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
position: relative;
|
||||||
|
width: 96px;
|
||||||
|
height: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1440px) {
|
||||||
|
.tile { width: 120px; height: 80px; }
|
||||||
|
.filmstrip { height: 96px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile[data-active='true'] {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile button.pick {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile .eye-tile,
|
||||||
|
.tile .tile-trash {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
right: 3px;
|
||||||
|
display: grid;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #000a;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile .tile-trash { right: 22px; }
|
||||||
|
|
||||||
|
.strip-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-actions button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-actions button:hover {
|
||||||
|
border-color: var(--amber);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-add-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-add-row .field-control {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport {
|
||||||
|
display: flex;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport .ops {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-col {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-head {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 12px 8px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-badge {
|
||||||
|
display: inline-grid;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--amber);
|
||||||
|
color: var(--amber-on);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-raise);
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card[data-active='true'] {
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-id {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-title {
|
||||||
|
margin-top: 2px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.625rem 1.25rem;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display='classic'] .display-rail__seg {
|
.job-meta {
|
||||||
min-height: 2.25rem;
|
margin-top: 4px;
|
||||||
padding: 0.375rem 0.75rem;
|
color: var(--muted);
|
||||||
font-weight: 500;
|
font-size: var(--type-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-cta {
|
.job-status {
|
||||||
border-radius: var(--display-radius);
|
display: flex;
|
||||||
background: var(--display-amber);
|
margin-top: 8px;
|
||||||
color: var(--display-amber-fg);
|
align-items: center;
|
||||||
font-weight: 600;
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: var(--type-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-cta:focus-visible {
|
.job-status .left {
|
||||||
box-shadow: 0 0 0 2px var(--display-focus);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display] .display-control {
|
.job-status .dot {
|
||||||
border-radius: var(--display-radius);
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status[data-state='queued'] .dot,
|
||||||
|
.job-status[data-state='waiting'] .dot {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-foot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--amber);
|
||||||
|
color: var(--amber-on);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-cta:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-foot .tiny {
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-foot .tiny button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-block { width: 100%; }
|
||||||
|
|
||||||
|
.slot-thumb {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #0a0a0c;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-thumb--hero { height: 148px; }
|
||||||
|
.slot-thumb--slot { height: 96px; }
|
||||||
|
.slot-thumb--empty {
|
||||||
|
height: 72px;
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-caption {
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-upload {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-still {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,21 @@
|
|||||||
<li
|
<li
|
||||||
v-for="(item, index) in model"
|
v-for="(item, index) in model"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
class="rounded-xl border bg-zinc-950/80"
|
class="rounded-lg border border-[#2A2A2E] bg-[#1A1A1D] p-2.5"
|
||||||
:class="[
|
:class="[
|
||||||
compact ? 'px-2 py-1.5' : 'px-3 py-2',
|
dragOverIndex === index ? 'border-amber-300/50' : '',
|
||||||
dragOverIndex === index ? 'border-amber-300/50' : 'border-white/10',
|
|
||||||
draggingIndex === index ? 'opacity-60' : ''
|
draggingIndex === index ? 'opacity-60' : ''
|
||||||
]"
|
]"
|
||||||
@dragover.prevent="onDragOver(index)"
|
@dragover.prevent="onDragOver(index)"
|
||||||
@drop.prevent="onDrop(index)"
|
@drop.prevent="onDrop(index)"
|
||||||
@dragleave="onDragLeave(index)"
|
@dragleave="onDragLeave(index)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 flex-1 items-start gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="model.length > 1"
|
v-if="model.length > 1"
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
|
class="mt-0.5 shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
title="Drag to reorder"
|
title="Drag to reorder"
|
||||||
@@ -31,61 +30,91 @@
|
|||||||
>
|
>
|
||||||
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
|
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="min-w-0"><span class="block truncate text-zinc-200">{{ labelFor(item.name) }}</span><span v-if="filenameSubtitle" class="block truncate text-[11px] text-zinc-500" :title="item.name">{{ item.name }}</span></span>
|
<span class="min-w-0 flex-1">
|
||||||
<LoraTriggerTip
|
<span class="flex min-w-0 items-center gap-1.5">
|
||||||
:text="triggerFor(item.name)"
|
<span class="block truncate font-medium text-[#F3F1EA]" :title="labelFor(item.name)">{{ labelFor(item.name) }}</span>
|
||||||
:aria-label="'Activation for ' + labelFor(item.name)"
|
<LoraTriggerTip
|
||||||
@insert="emit('insert-trigger', $event)"
|
:text="triggerFor(item.name)"
|
||||||
@save="saveTrigger(item.name, $event)"
|
:aria-label="'Activation for ' + labelFor(item.name)"
|
||||||
/>
|
@insert="emit('insert-trigger', $event)"
|
||||||
|
@save="saveTrigger(item.name, $event)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span v-if="filenameSubtitle" class="mt-0.5 block truncate text-[11px] text-[#9A9588]" :title="item.name">{{ stemFor(item.name) }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 text-[11px] text-zinc-500 hover:text-red-300 disabled:opacity-50"
|
class="shrink-0 text-[11px] text-[#9A9588] hover:text-red-300 disabled:opacity-50"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@click="removeAt(index)"
|
@click="removeAt(index)"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1.5 flex flex-wrap gap-3">
|
|
||||||
<Thumbwheel
|
<div class="mt-2 space-y-1.5">
|
||||||
:model-value="item.strengthModel"
|
<label class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]">
|
||||||
label="Model"
|
<span>{{ clipStrength ? 'Model' : 'Strength' }}</span>
|
||||||
:lock-key="`lora-model:${item.name}`"
|
<input
|
||||||
:min="LORA_STRENGTH_MIN"
|
type="range"
|
||||||
:max="LORA_STRENGTH_MAX"
|
class="lora-strength-range min-w-0"
|
||||||
:step="LORA_STRENGTH_STEP"
|
:min="0"
|
||||||
:fine-step="LORA_STRENGTH_STEP"
|
:max="2"
|
||||||
:coarse-step="0.25"
|
:step="LORA_STRENGTH_STEP"
|
||||||
:digits="2"
|
:disabled="disabled"
|
||||||
compact
|
:value="displayStrength(item.strengthModel)"
|
||||||
:disabled="disabled"
|
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' for ' + labelFor(item.name)"
|
||||||
@update:model-value="setStrength(index, 'strengthModel', $event)"
|
@input="onRange(index, 'strengthModel', $event)"
|
||||||
/>
|
>
|
||||||
<Thumbwheel
|
<input
|
||||||
:model-value="item.strengthClip"
|
type="number"
|
||||||
label="CLIP"
|
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
|
||||||
:lock-key="`lora-clip:${item.name}`"
|
:min="0"
|
||||||
:min="LORA_STRENGTH_MIN"
|
:max="2"
|
||||||
:max="LORA_STRENGTH_MAX"
|
:step="LORA_STRENGTH_STEP"
|
||||||
:step="LORA_STRENGTH_STEP"
|
:disabled="disabled"
|
||||||
:fine-step="LORA_STRENGTH_STEP"
|
:value="displayStrength(item.strengthModel).toFixed(2)"
|
||||||
:coarse-step="0.25"
|
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' value'"
|
||||||
:digits="2"
|
@change="onNumber(index, 'strengthModel', $event)"
|
||||||
compact
|
>
|
||||||
:disabled="disabled || !clipStrength"
|
</label>
|
||||||
@update:model-value="setStrength(index, 'strengthClip', $event)"
|
<label
|
||||||
/>
|
v-if="clipStrength"
|
||||||
|
class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]"
|
||||||
|
>
|
||||||
|
<span>CLIP</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="lora-strength-range min-w-0"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
|
:step="LORA_STRENGTH_STEP"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="displayStrength(item.strengthClip)"
|
||||||
|
:aria-label="'CLIP for ' + labelFor(item.name)"
|
||||||
|
@input="onRange(index, 'strengthClip', $event)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
|
:step="LORA_STRENGTH_STEP"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="displayStrength(item.strengthClip).toFixed(2)"
|
||||||
|
:aria-label="'CLIP value'"
|
||||||
|
@change="onNumber(index, 'strengthClip', $event)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-if="!clipStrength && model.length" class="mt-1 text-[11px] text-zinc-500">CLIP unused on this graph — model strength only.</p>
|
|
||||||
<select
|
<select
|
||||||
v-if="model.length < LORA_STACK_MAX"
|
v-if="model.length < LORA_STACK_MAX"
|
||||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 outline-none ring-amber-300/40 focus:ring-2 disabled:opacity-50"
|
class="w-full rounded-lg border border-[#2A2A2E] bg-[#0B0B0D] text-[#F3F1EA] outline-none ring-[#F5A524]/40 focus:ring-2 disabled:opacity-50"
|
||||||
:class="[
|
:class="[
|
||||||
compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm',
|
compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
|
||||||
model.length ? 'mt-2' : ''
|
model.length ? 'mt-2' : ''
|
||||||
]"
|
]"
|
||||||
:disabled="disabled || !availableNames.length"
|
:disabled="disabled || !availableNames.length"
|
||||||
@@ -103,13 +132,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lora-strength-range {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2a2a2e;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f5a524;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-moz-range-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2a2a2e;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f5a524;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lora-strength-range:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-webkit-slider-runnable-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2a2a2e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
LORA_STACK_MAX,
|
LORA_STACK_MAX,
|
||||||
LORA_STRENGTH_MAX,
|
LORA_STRENGTH_DEFAULT,
|
||||||
LORA_STRENGTH_MIN,
|
|
||||||
LORA_STRENGTH_STEP,
|
LORA_STRENGTH_STEP,
|
||||||
clampLoraStrength,
|
loraDisplayName,
|
||||||
loraFriendlyName,
|
loraFriendlyName,
|
||||||
loraIdentityKey,
|
loraIdentityKey,
|
||||||
loraMatchesImageEngine,
|
loraMatchesImageEngine,
|
||||||
@@ -120,6 +191,9 @@ import {
|
|||||||
type LoraStackItem
|
type LoraStackItem
|
||||||
} from '~/utils/loras'
|
} from '~/utils/loras'
|
||||||
|
|
||||||
|
const UI_MIN = 0
|
||||||
|
const UI_MAX = 2
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
names: string[]
|
names: string[]
|
||||||
filenameSubtitle?: boolean
|
filenameSubtitle?: boolean
|
||||||
@@ -175,6 +249,17 @@ const selectPlaceholder = computed(() => {
|
|||||||
return model.value.length ? props.addLabel : props.emptyLabel
|
return model.value.length ? props.addLabel : props.emptyLabel
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function clampUi(raw: unknown) {
|
||||||
|
const value = Number(raw)
|
||||||
|
if (!Number.isFinite(value)) return LORA_STRENGTH_DEFAULT
|
||||||
|
const snapped = Math.round(value / LORA_STRENGTH_STEP) * LORA_STRENGTH_STEP
|
||||||
|
return Math.min(UI_MAX, Math.max(UI_MIN, Math.round(snapped * 100) / 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayStrength(value: unknown) {
|
||||||
|
return clampUi(value ?? LORA_STRENGTH_DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
function onAdd(event: Event) {
|
function onAdd(event: Event) {
|
||||||
const name = (event.target as HTMLSelectElement).value
|
const name = (event.target as HTMLSelectElement).value
|
||||||
;(event.target as HTMLSelectElement).value = ''
|
;(event.target as HTMLSelectElement).value = ''
|
||||||
@@ -186,7 +271,11 @@ function addLora(name: string) {
|
|||||||
if (!item || model.value.length >= LORA_STACK_MAX) return
|
if (!item || model.value.length >= LORA_STACK_MAX) return
|
||||||
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
|
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
|
||||||
if (taken.has(loraIdentityKey(item.name))) return
|
if (taken.has(loraIdentityKey(item.name))) return
|
||||||
model.value = [...model.value, item]
|
model.value = [...model.value, {
|
||||||
|
...item,
|
||||||
|
strengthModel: clampUi(item.strengthModel ?? LORA_STRENGTH_DEFAULT),
|
||||||
|
strengthClip: clampUi(item.strengthClip ?? LORA_STRENGTH_DEFAULT)
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAt(index: number) {
|
function removeAt(index: number) {
|
||||||
@@ -234,8 +323,22 @@ function onDragEnd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
|
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
|
||||||
const next = clampLoraStrength(value)
|
const next = clampUi(value)
|
||||||
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item)
|
model.value = model.value.map((item, i) => {
|
||||||
|
if (i !== index) return item
|
||||||
|
if (!props.clipStrength) {
|
||||||
|
return { ...item, strengthModel: next, strengthClip: next }
|
||||||
|
}
|
||||||
|
return { ...item, [key]: next }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRange(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||||
|
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNumber(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||||
|
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerFor(name: string) {
|
function triggerFor(name: string) {
|
||||||
@@ -246,6 +349,10 @@ function labelFor(name: string) {
|
|||||||
return loraFriendlyName(name, props.aliases)
|
return loraFriendlyName(name, props.aliases)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stemFor(name: string) {
|
||||||
|
return loraDisplayName(name)
|
||||||
|
}
|
||||||
|
|
||||||
function saveTrigger(name: string, trigger: string) {
|
function saveTrigger(name: string, trigger: string) {
|
||||||
emit('update-trigger', name, trigger)
|
emit('update-trigger', name, trigger)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,17 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=
|
|||||||
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
|
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
|
||||||
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
|
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
|
||||||
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
|
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
|
||||||
{label:'Compiled prompt',value:m.compiledPrompt},{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
|
{label:'Compiled prompt',value:m.compiledPrompt},
|
||||||
|
{label:'Prompt (raw)',value:m.promptRaw || null},
|
||||||
|
{label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'},
|
||||||
|
{label:'Turbo',value:m.turbo==null?null:m.turbo?'On':'Off'},
|
||||||
|
{label:'Turbo LoRA',value:m.lora || null},
|
||||||
|
{label:'Turbo sigmas',value:m.sigmas || null},
|
||||||
|
{label:'Enhance wh_ratio',value:m.enhance?.wh_ratio || null},
|
||||||
|
{label:'Enhance ratio_follow',value:m.enhance?.ratio_follow || null},
|
||||||
|
{label:'Enhance parse_ok',value:m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Yes':'No'},
|
||||||
|
{label:'Enhance thinking',value:m.enhance?.thinking || null},
|
||||||
|
{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
|
||||||
{label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult}
|
{label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult}
|
||||||
]})
|
]})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,585 @@
|
|||||||
|
<template>
|
||||||
|
<div class="m-shell" data-mobile="1">
|
||||||
|
<header class="m-header">
|
||||||
|
<span class="m-brand">{{ ctx.instanceName }}</span>
|
||||||
|
<label class="m-crumb">
|
||||||
|
<select v-model="ctx.form.folderId" aria-label="Folder">
|
||||||
|
<option v-for="f in ctx.library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="m-grow" />
|
||||||
|
<span class="m-gpu" :data-tone="ctx.gpu.tone" :title="ctx.gpu.detail">● {{ ctx.gpuChipLabel }}</span>
|
||||||
|
<button type="button" class="m-chip" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
|
||||||
|
<details class="m-more">
|
||||||
|
<summary aria-label="More">⋯</summary>
|
||||||
|
<div class="m-more-menu">
|
||||||
|
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
|
||||||
|
<NuxtLink to="/queue" @click="closeMenus">Queue</NuxtLink>
|
||||||
|
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="m-chips" role="tablist" aria-label="Tasks">
|
||||||
|
<button
|
||||||
|
v-for="task in ctx.tasks"
|
||||||
|
:key="task"
|
||||||
|
type="button"
|
||||||
|
class="m-chip capitalize"
|
||||||
|
:class="{ on: ctx.form.mode===task }"
|
||||||
|
:aria-pressed="ctx.form.mode===task"
|
||||||
|
@click="ctx.chooseTask(task)"
|
||||||
|
>{{ task }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ctx.form.mode!=='music'" class="m-chips" role="group" aria-label="Model">
|
||||||
|
<button
|
||||||
|
v-for="opt in ctx.engineOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
class="m-chip"
|
||||||
|
:class="{ on: ctx.form.engine===opt.value }"
|
||||||
|
:disabled="opt.disabled"
|
||||||
|
:title="opt.hint || undefined"
|
||||||
|
:aria-pressed="ctx.form.engine===opt.value"
|
||||||
|
@click="!opt.disabled && (ctx.form.engine=opt.value)"
|
||||||
|
>{{ opt.label }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="m-chips"><span class="m-chip on">YuE2</span></div>
|
||||||
|
|
||||||
|
<div class="m-stage">
|
||||||
|
<button
|
||||||
|
v-if="ctx.selected && ctx.xaigen"
|
||||||
|
type="button"
|
||||||
|
class="m-eye"
|
||||||
|
:aria-label="ctx.isHidden(ctx.selected) ? 'Show preview' : 'Hide preview'"
|
||||||
|
@click.stop="ctx.toggleReveal(ctx.selected.id)"
|
||||||
|
>
|
||||||
|
<img :src="ctx.isHidden(ctx.selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
|
||||||
|
</button>
|
||||||
|
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
|
||||||
|
<p class="m-muted">{{ ctx.selected.kind==='track' ? 'Track loaded · preview hidden' : 'Preview hidden' }}</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="ctx.selected?.kind==='track'">
|
||||||
|
<div class="m-audio">
|
||||||
|
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<video
|
||||||
|
v-else-if="ctx.selected?.kind==='clip'"
|
||||||
|
:key="ctx.selected.id"
|
||||||
|
:src="`/api/library/clips/${ctx.selected.id}/video`"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
/>
|
||||||
|
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still">
|
||||||
|
<p v-else class="m-muted">Select a still or clip</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-strip" aria-label="Family outputs">
|
||||||
|
<div
|
||||||
|
v-for="o in ctx.family"
|
||||||
|
:key="o.id"
|
||||||
|
class="m-tile"
|
||||||
|
:class="{ on: ctx.selected?.id===o.id }"
|
||||||
|
>
|
||||||
|
<button type="button" class="m-tile-pick" @click="ctx.selectOutput(o)">
|
||||||
|
<span v-if="ctx.isHidden(o)" class="m-tile-hid">Hidden</span>
|
||||||
|
<span v-else-if="o.kind==='track'" class="m-tile-hid">Audio</span>
|
||||||
|
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : ctx.stillUrl(o.id)" alt="">
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="ctx.xaigen"
|
||||||
|
type="button"
|
||||||
|
class="m-tile-eye"
|
||||||
|
:aria-label="ctx.isHidden(o) ? 'Show tile' : 'Hide tile'"
|
||||||
|
@click.stop="ctx.toggleReveal(o.id)"
|
||||||
|
>
|
||||||
|
<img :src="ctx.isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="ctx.selected?.id===o.id"
|
||||||
|
type="button"
|
||||||
|
class="m-tile-trash"
|
||||||
|
aria-label="Delete selected"
|
||||||
|
@click.stop="ctx.deleteOutput"
|
||||||
|
>🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ctx.selected && ctx.selected.kind!=='track'" class="m-strip-actions">
|
||||||
|
<button type="button" @click="ctx.applyUseAsSource">Use as start</button>
|
||||||
|
<button type="button" @click="ctx.applyUseAsHero">Use as hero</button>
|
||||||
|
<button type="button" @click="ctx.copySelectedPrompt">Copy prompt</button>
|
||||||
|
<button type="button" @click="ctx.loadSelectedJob">Load</button>
|
||||||
|
<button type="button" @click="ctx.deleteOutput">Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-dock">
|
||||||
|
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Slots</button>
|
||||||
|
<button type="button" :class="{ on: sheet==='prompt' }" @click="toggleSheet('prompt')">Prompt</button>
|
||||||
|
<button type="button" :class="{ on: sheet==='look' }" @click="toggleSheet('look')">Look</button>
|
||||||
|
<button type="button" :class="{ on: sheet==='gen' }" @click="toggleSheet('gen')">Gen</button>
|
||||||
|
<button type="button" class="m-cta" :disabled="ctx.submitting" @click="ctx.queue">{{ ctx.submitting ? '…' : 'Generate' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sheets -->
|
||||||
|
<div v-if="sheet" class="m-sheet-backdrop" @click="sheet=''" />
|
||||||
|
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Slots">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Slots</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<template v-if="ctx.form.mode==='music'">
|
||||||
|
<p class="m-muted">Music uses tags + lyrics in Prompt / Gen — no hero slot.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Studio2AssetSlot size="hero" :label="ctx.heroSlotLabel" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.identityStillId)" :hidden="ctx.slotHidden(ctx.form.identityStillId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker('identityStillId')" @upload="ctx.upload($event,'hero')" />
|
||||||
|
<Studio2AssetSlot :label="ctx.sourceSlotLabel" :asset="ctx.sourceSlot" :hidden="ctx.slotHidden(ctx.form.mode==='extend'?ctx.form.startClipId:ctx.form.imageAId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker(ctx.form.mode==='extend'?'startClipId':'imageAId')" @upload="ctx.upload($event,'source')" />
|
||||||
|
<p v-if="ctx.editSourceRequired && !ctx.form.imageAId" class="m-hint">Required for Edit</p>
|
||||||
|
<p v-if="ctx.slotPairHint" class="m-hint">{{ ctx.slotPairHint }}</p>
|
||||||
|
<Studio2AssetSlot v-if="ctx.form.mode==='compose'" label="Second still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.imageBId)" :hidden="ctx.slotHidden(ctx.form.imageBId)" @pick="ctx.setPicker('imageBId')" />
|
||||||
|
<Studio2AssetSlot v-if="ctx.form.mode==='refine'" label="Mask" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.maskId)" :hidden="ctx.slotHidden(ctx.form.maskId)" @pick="ctx.setPicker('maskId')" />
|
||||||
|
<Studio2AssetSlot v-if="ctx.video" label="End still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.endStillId)" :hidden="ctx.slotHidden(ctx.form.endStillId)" @pick="ctx.setPicker('endStillId')" />
|
||||||
|
<label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label>
|
||||||
|
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label>
|
||||||
|
<p class="m-hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
||||||
|
<p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside v-if="sheet==='prompt'" class="m-sheet" role="dialog" aria-label="Prompt">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Prompt</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<template v-if="ctx.form.mode==='music'">
|
||||||
|
<label class="m-label">Genre / style tags<textarea v-model="ctx.music.tags" rows="3" class="m-area" /></label>
|
||||||
|
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label>
|
||||||
|
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label>
|
||||||
|
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label>
|
||||||
|
<p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
|
||||||
|
<div>
|
||||||
|
<span class="m-label">Negative prompt</span>
|
||||||
|
<div class="m-neg">
|
||||||
|
<span v-for="chip in ctx.negativeChips" :key="chip" class="m-neg-chip">
|
||||||
|
{{ chip }}
|
||||||
|
<button type="button" :aria-label="'Remove '+chip" @click="ctx.removeNegative(chip)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-neg-add">
|
||||||
|
<input
|
||||||
|
:value="ctx.negativeDraft"
|
||||||
|
type="text"
|
||||||
|
class="m-input"
|
||||||
|
placeholder="Add negative chip"
|
||||||
|
@input="ctx.setNegativeDraft(($event.target as HTMLInputElement).value)"
|
||||||
|
@keydown.enter.prevent="ctx.addNegative"
|
||||||
|
>
|
||||||
|
<button type="button" @click="ctx.addNegative">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Studio2StyleChips v-if="!ctx.video" v-model="ctx.form.imageStyles" />
|
||||||
|
</template>
|
||||||
|
<p v-if="ctx.error" class="m-err">{{ ctx.error }}</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside v-if="sheet==='look'" class="m-sheet" role="dialog" aria-label="Look">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Look</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<template v-if="ctx.form.mode==='music'">
|
||||||
|
<label class="m-label">Seed<input v-model="ctx.music.seed" class="m-input" placeholder="random"></label>
|
||||||
|
<label class="m-label">Track name<input v-model="ctx.music.name" class="m-input" placeholder="Optional"></label>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div>
|
||||||
|
<span class="m-label">LoRA</span>
|
||||||
|
<LoraSelect v-model="ctx.form.settings.loraStack" compact :names="ctx.pickerLoras" :use="ctx.currentLoraUse" :clip-strength="!ctx.video" filename-subtitle :aliases="ctx.loraAliases" :triggers="ctx.loraTriggers" missing-label="None" @insert-trigger="ctx.form.promptSections.action += ' '+$event" />
|
||||||
|
</div>
|
||||||
|
<label class="m-label">Seed
|
||||||
|
<div class="m-seed">
|
||||||
|
<input
|
||||||
|
:value="ctx.form.settings.seed ?? ''"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="2147483647"
|
||||||
|
class="m-input"
|
||||||
|
@input="ctx.form.settings.seed=Number(($event.target as HTMLInputElement).value);ctx.form.settings.seedMode='locked'"
|
||||||
|
>
|
||||||
|
<button type="button" class="m-dice" aria-label="Random seed" @click="ctx.randomizeSeed">🎲</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="m-label">Aspect
|
||||||
|
<select v-model="ctx.form.settings.aspect" class="m-input">
|
||||||
|
<option v-for="opt in ctx.aspectOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="m-label">Steps<input v-model.number="ctx.form.settings.steps" type="number" class="m-input"></label>
|
||||||
|
<label class="m-label">CFG<input v-model.number="ctx.form.settings.cfg" type="number" class="m-input"></label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside v-if="sheet==='gen'" class="m-sheet" role="dialog" aria-label="Gen extras">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Gen</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<template v-if="ctx.form.mode==='music'">
|
||||||
|
<label class="m-label">Target length (sec)<input v-model.number="ctx.music.duration" type="number" min="15" max="150" class="m-input"></label>
|
||||||
|
<p class="m-hint">Caps semantic tokens (~25/s). Actual length follows the model end token.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="ctx.video">
|
||||||
|
<label class="m-label">Duration (sec)<input v-model.number="ctx.form.settings.duration" type="number" class="m-input"></label>
|
||||||
|
<label class="m-label">FPS<input v-model.number="ctx.form.settings.fps" type="number" class="m-input"></label>
|
||||||
|
<label class="m-check"><input v-model="ctx.form.settings.turbo" type="checkbox"> Turbo</label>
|
||||||
|
<Studio2PromptControls v-model="ctx.form.promptSections" />
|
||||||
|
<details class="m-details">
|
||||||
|
<summary>Queue more shots</summary>
|
||||||
|
<textarea :value="ctx.batchText" rows="4" class="m-area" placeholder="First additional prompt Next" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="m-muted">Image extras live in Look (steps / CFG / seed / aspect).</p>
|
||||||
|
<details v-if="ctx.form.mode==='edit'" class="m-details">
|
||||||
|
<summary>Queue extra edit passes</summary>
|
||||||
|
<textarea :value="ctx.batchText" rows="4" class="m-area" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside v-if="sheet==='queue'" class="m-sheet" role="dialog" aria-label="Queue">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Queue</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<div
|
||||||
|
v-for="card in ctx.queueCards"
|
||||||
|
:key="card.id"
|
||||||
|
class="m-job"
|
||||||
|
:class="{ on: ctx.focusedJobId===card.id }"
|
||||||
|
@click="ctx.focusQueueCard(card)"
|
||||||
|
>
|
||||||
|
<div class="m-job-id">{{ card.code }}</div>
|
||||||
|
<div class="m-job-title">{{ card.title }}</div>
|
||||||
|
<div class="m-muted">{{ card.meta }}</div>
|
||||||
|
<div class="m-job-status">
|
||||||
|
<span>● {{ card.statusLabel }}</span>
|
||||||
|
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
|
||||||
|
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
export type Studio2MobileCtx = Record<string, any>
|
||||||
|
|
||||||
|
const props = defineProps<{ ctx: Studio2MobileCtx }>()
|
||||||
|
const sheet = ref('')
|
||||||
|
|
||||||
|
function toggleSheet(id: string) {
|
||||||
|
sheet.value = sheet.value === id ? '' : id
|
||||||
|
}
|
||||||
|
function closeMenus() {
|
||||||
|
sheet.value = ''
|
||||||
|
}
|
||||||
|
watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.m-shell {
|
||||||
|
--bg: #0b0b0d;
|
||||||
|
--panel: #141416;
|
||||||
|
--line: #2a2a2e;
|
||||||
|
--amber: #f5a524;
|
||||||
|
--amber-on: #1a1204;
|
||||||
|
--text: #f3f1ea;
|
||||||
|
--muted: #9a9588;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.m-header {
|
||||||
|
display: flex;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
.m-brand { font-weight: 700; }
|
||||||
|
.m-crumb select {
|
||||||
|
max-width: 7rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.m-grow { flex: 1; }
|
||||||
|
.m-gpu {
|
||||||
|
border: 1px solid #2d4a2a;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
color: #b8e0a8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.m-gpu[data-tone='busy'] { border-color: color-mix(in srgb, var(--amber) 40%, transparent); color: var(--amber); }
|
||||||
|
.m-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.m-chip {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1a1d;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.m-chip.on, .m-dock button.on {
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: var(--amber);
|
||||||
|
color: var(--amber-on);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.m-chip:disabled { opacity: 0.45; }
|
||||||
|
.m-more { position: relative; }
|
||||||
|
.m-more summary { list-style: none; cursor: pointer; padding: 4px 8px; color: var(--muted); }
|
||||||
|
.m-more-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
min-width: 8rem;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.m-more-menu a { color: var(--text); text-decoration: none; padding: 6px 8px; }
|
||||||
|
.m-stage {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #080808;
|
||||||
|
}
|
||||||
|
.m-stage > img,
|
||||||
|
.m-stage > video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
|
||||||
|
.m-eye {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #000a;
|
||||||
|
}
|
||||||
|
.m-strip {
|
||||||
|
display: flex;
|
||||||
|
height: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.m-tile {
|
||||||
|
position: relative;
|
||||||
|
width: 72px;
|
||||||
|
height: 48px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
.m-tile.on { border-color: var(--amber); }
|
||||||
|
.m-tile-pick { display: block; width: 100%; height: 100%; border: 0; padding: 0; background: transparent; }
|
||||||
|
.m-tile img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.m-tile-hid { display: grid; height: 100%; place-items: center; color: var(--muted); font-size: 10px; }
|
||||||
|
.m-tile-eye, .m-tile-trash {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
display: grid;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #000a;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.m-tile-trash { right: 20px; }
|
||||||
|
.m-dock {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr) 120px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 6px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.m-dock button {
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1a1d;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.m-cta {
|
||||||
|
background: var(--amber) !important;
|
||||||
|
color: var(--amber-on) !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
.m-cta:disabled { opacity: 0.5; }
|
||||||
|
.m-sheet-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background: #0008;
|
||||||
|
}
|
||||||
|
.m-sheet {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 31;
|
||||||
|
display: flex;
|
||||||
|
max-height: min(85vh, 100%);
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 10px 14px 20px;
|
||||||
|
}
|
||||||
|
.m-handle {
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
margin: 4px auto 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
.m-sheet-head {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.m-sheet-head button { border: 0; background: transparent; color: var(--amber); }
|
||||||
|
.m-sheet-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.m-label { display: block; font-size: 11px; color: var(--muted); }
|
||||||
|
.m-area, .m-input {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1a1d;
|
||||||
|
padding: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.m-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
|
||||||
|
.m-hint, .m-muted { color: var(--muted); font-size: 12px; }
|
||||||
|
.m-amber { color: var(--amber); font-size: 12px; }
|
||||||
|
.m-err { color: #fca5a5; font-size: 12px; }
|
||||||
|
.m-neg { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||||
|
.m-neg-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid #5a4a28;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2a2418;
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: #e8c37a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.m-neg-chip button { border: 0; background: transparent; color: inherit; }
|
||||||
|
.m-neg-add { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||||
|
.m-neg-add .m-input { flex: 1; margin-top: 0; }
|
||||||
|
.m-strip-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.m-strip-actions button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.m-seed { display: flex; gap: 8px; margin-top: 4px; }
|
||||||
|
.m-seed .m-input { margin-top: 0; flex: 1; }
|
||||||
|
.m-dice {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1a1d;
|
||||||
|
}
|
||||||
|
.m-details { border: 1px solid var(--line); border-radius: 8px; padding: 8px; }
|
||||||
|
.m-job {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.m-job.on { border-color: var(--amber); border-width: 2px; }
|
||||||
|
.m-job-id { color: var(--muted); font-size: 12px; }
|
||||||
|
.m-job-title { font-weight: 600; }
|
||||||
|
.m-job-status { display: flex; justify-content: space-between; margin-top: 6px; font-size: 12px; }
|
||||||
|
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
|
||||||
|
</style>
|
||||||
@@ -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,48 @@
|
|||||||
|
# Qwen Image 2.1 (engine `qwen21`) — host weights + Comfy graph
|
||||||
|
|
||||||
|
## Host paths (this RTX 5080 box)
|
||||||
|
|
||||||
|
| Role | Resolved path |
|
||||||
|
|---|---|
|
||||||
|
| ComfyUI root (Klein / host agent `ComfyUI (1)`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI` |
|
||||||
|
| Models root (Desktop Shared via `shared_model_paths.yaml`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Shared\models` |
|
||||||
|
|
||||||
|
Weights must land under Shared so every Desktop instance sees them:
|
||||||
|
|
||||||
|
- `diffusion_models\qwen-image-2.1-Q8_0.gguf` (~7.59 GiB) — from `abenzerps/Qwen-Image-2.1-Uncensored-GGUF`
|
||||||
|
- `text_encoders\qwen3vl_8b_int8_convrot.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (INT8; BF16 8B VL OOMs on 16 GB)
|
||||||
|
- `vae\qwen_image_2.1_vae_bf16.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (**not** the old Qwen-Image 1.0 VAE)
|
||||||
|
|
||||||
|
Custom node: `custom_nodes\ComfyUI-GGUF` (`UnetLoaderGGUF`). Do not install a second GGUF pack.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
DiT download uses the exact host invocation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||||
|
```
|
||||||
|
|
||||||
|
That lands in the Hugging Face hub cache; the setup script copies it into Shared `diffusion_models`. The abenzerps GGUF ships with `kv_count=0` (no `general.architecture`) **and** Q8_0-quantized 1D RMSNorm weights (logical 128 → packed 136), which breaks Comfy’s fused `rms_rope`. `scripts/tag-qwen21-gguf.py` rewrites the file with `general.architecture=qwen_image` and promotes small/1D tensors to F32 so city96 `UnetLoaderGGUF` can load it.
|
||||||
|
|
||||||
|
Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false`). Confirm `object_info` lists `UnetLoaderGGUF` and the three filenames.
|
||||||
|
|
||||||
|
## App
|
||||||
|
|
||||||
|
- Engine key: `qwen21` · UI label: **Qwen 2.1**
|
||||||
|
- **Generate** (T2I) and **Edit** (same checkpoint, second graph). Compose / Iterate / Video / Extend / Music stay disabled unless a graph exists.
|
||||||
|
- Edit requires Start still → `images.image_1`; prompt slots use `<image1>` / `<image2>`. If the user omits `<image1>`, the runner prepends `Keep the subject in <image1>.`
|
||||||
|
- Face / outfit locks stay Klein semantics — they do not drive Qwen slots.
|
||||||
|
- Sampler defaults: euler / simple / cfg **1** / steps **25**. Edit uses `QwenImage21Cache` (device auto, dtype int8). T2I uses `ModelSamplingAuraFlow` shift **3.1**.
|
||||||
|
- Default Generate canvas follows the bench Aspect control on the Qwen-safe ~1 MP grid (`EmptyLatentImage`): 1:1 → 1024×1024, 16:9 → 1536×864, 9:16 → 864×1536 (and the other table rows). PE `wh_ratio` is advisory only and never sizes the canvas. Do not use native 2K bins on 16 GB.
|
||||||
|
- Edit follows `image_1` via the encode node’s latent (resolution long-edge ~1024). Do not inject a picker EmptyLatentImage onto the edit sampler.
|
||||||
|
- Optional **Enhance prompt** (off by default): runs a separate PE-only Comfy graph (`CLIPLoader` + rewrite node), then frees VRAM and queues the existing T2I/Edit graph with the rewritten prompt. Never loads PE CLIP + DiT together on 16 GB. Fail closed if `parse_ok` is false or the rewrite is empty.
|
||||||
|
- PE weights (int8 only): `text_encoders\qwen3.5_9b_qwen_image_2.1_pe_{t2i,i2i}.int8_convrot.safetensors` — do not replace the image TE `qwen3vl_8b_int8_convrot.safetensors`.
|
||||||
|
- Optional **Turbo** (off by default): Viggle DMD LoRA `Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors` on the same Q8 GGUF via `ViggleTurboLora` + `ViggleTurboSigmas` (`1.0, 0.9375, 0.875, 0.75, 0.5, 0.25`), 6 steps, CFG 1, empty negative. Not a new engine. Enhance prompt stays compatible and is recommended with Turbo.
|
||||||
|
- Custom node: `ComfyUI-Viggle-Turbo` (`viggle_turbo.py`). Do not merge the LoRA with stock `LoraLoaderModelOnly` (lossy on int8/bf16).
|
||||||
|
|
||||||
|
Graphs: `server/assets/studio2_qwen21_t2i.json`, `server/assets/studio2_qwen21_edit.json`, `server/assets/studio2_qwen21_t2i_turbo.json`, `server/assets/studio2_qwen21_edit_turbo.json`, `server/assets/studio2_qwen21_pe_t2i.json`, `server/assets/studio2_qwen21_pe_edit.json`.
|
||||||
@@ -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,225 @@
|
|||||||
|
# Download Qwen Image 2.1 GGUF + companions into the live Comfy Desktop Shared models tree.
|
||||||
|
# Also ensures city96/ComfyUI-GGUF is present on the ComfyUI (1) install.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||||
|
# powershell -File scripts\setup-qwen21.ps1 -SkipDownload
|
||||||
|
# powershell -File scripts\setup-qwen21.ps1 -ComfyRoot "C:\...\ComfyUI (1)\ComfyUI"
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ComfyRoot = '',
|
||||||
|
[string]$ModelsRoot = '',
|
||||||
|
[switch]$SkipDownload,
|
||||||
|
[switch]$SkipNode,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'Continue'
|
||||||
|
|
||||||
|
function Write-Step([string]$Message) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ok([string]$Message) {
|
||||||
|
Write-Host " $Message" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Warn([string]$Message) {
|
||||||
|
Write-Host " warn $Message" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ComfyRoot([string]$Hint) {
|
||||||
|
$candidates = @(
|
||||||
|
$Hint,
|
||||||
|
$env:COMFY_ROOT,
|
||||||
|
$env:COMFYUI_ROOT,
|
||||||
|
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI'),
|
||||||
|
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI\ComfyUI'),
|
||||||
|
'C:\ComfyUI',
|
||||||
|
(Join-Path $env:USERPROFILE 'ComfyUI')
|
||||||
|
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||||
|
|
||||||
|
foreach ($path in $candidates) {
|
||||||
|
if (Test-Path (Join-Path $path 'main.py')) { return (Resolve-Path $path).Path }
|
||||||
|
if (Test-Path (Join-Path $path 'ComfyUI\main.py')) { return (Resolve-Path (Join-Path $path 'ComfyUI')).Path }
|
||||||
|
}
|
||||||
|
throw "Could not find ComfyUI root (main.py). Pass -ComfyRoot."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ModelsRoot([string]$Hint, [string]$ComfyRootPath) {
|
||||||
|
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
|
||||||
|
$candidates = @(
|
||||||
|
$Hint,
|
||||||
|
$env:COMFY_MODELS_ROOT,
|
||||||
|
$shared,
|
||||||
|
(Join-Path $ComfyRootPath 'models')
|
||||||
|
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||||
|
|
||||||
|
foreach ($path in $candidates) {
|
||||||
|
if (Test-Path $path) { return (Resolve-Path $path).Path }
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path $shared | Out-Null
|
||||||
|
return (Resolve-Path $shared).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-HfCli {
|
||||||
|
foreach ($name in @('hf', 'huggingface-cli')) {
|
||||||
|
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||||
|
if ($cmd) { return $cmd.Source }
|
||||||
|
}
|
||||||
|
throw "Hugging Face CLI not found (hf / huggingface-cli). Install with: pip install -U huggingface_hub"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-Dir([string]$Path) {
|
||||||
|
if (-not (Test-Path $Path)) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $Path | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ModelFile([string]$Path, [long]$MinBytes) {
|
||||||
|
if (-not (Test-Path $Path)) { throw "Missing model file: $Path" }
|
||||||
|
$item = Get-Item $Path
|
||||||
|
if ($item.Length -lt $MinBytes) {
|
||||||
|
throw "Suspicious size for $Path ($($item.Length) bytes). Expected >= $MinBytes. Delete and re-run."
|
||||||
|
}
|
||||||
|
Write-Ok ("{0} {1:N0} bytes" -f $item.FullName, $item.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Flatten-IfNested([string]$DestDir, [string]$FileName) {
|
||||||
|
$nested = Get-ChildItem $DestDir -Recurse -File -Filter $FileName -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.DirectoryName -ne $DestDir } |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($nested) {
|
||||||
|
$target = Join-Path $DestDir $FileName
|
||||||
|
if ((Test-Path $target) -and -not $Force) {
|
||||||
|
Write-Warn "Nested copy found but destination already exists: $target"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Move-Item -Force $nested.FullName $target
|
||||||
|
Write-Ok "Flattened $($nested.FullName) -> $target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-HfFile {
|
||||||
|
param(
|
||||||
|
[string]$Cli,
|
||||||
|
[string]$Repo,
|
||||||
|
[string]$File,
|
||||||
|
[string]$LocalDir
|
||||||
|
)
|
||||||
|
Ensure-Dir $LocalDir
|
||||||
|
$dest = Join-Path $LocalDir (Split-Path $File -Leaf)
|
||||||
|
if ((Test-Path $dest) -and -not $Force) {
|
||||||
|
$len = (Get-Item $dest).Length
|
||||||
|
if ($len -gt 1MB) {
|
||||||
|
Write-Ok "Already present: $dest ($len bytes)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Step "hf download $Repo $File"
|
||||||
|
& $Cli download $Repo $File --local-dir $LocalDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "hf download failed for $Repo / $File (exit $LASTEXITCODE)" }
|
||||||
|
Flatten-IfNested -DestDir $LocalDir -FileName (Split-Path $File -Leaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- resolve paths ---
|
||||||
|
Write-Step "Resolve ComfyUI root"
|
||||||
|
$root = Resolve-ComfyRoot $ComfyRoot
|
||||||
|
Write-Ok $root
|
||||||
|
|
||||||
|
Write-Step "Resolve models root (Desktop Shared preferred)"
|
||||||
|
$models = Resolve-ModelsRoot $ModelsRoot $root
|
||||||
|
Write-Ok $models
|
||||||
|
|
||||||
|
$Diff = Join-Path $models 'diffusion_models'
|
||||||
|
$TE = Join-Path $models 'text_encoders'
|
||||||
|
$VAE = Join-Path $models 'vae'
|
||||||
|
Ensure-Dir $Diff
|
||||||
|
Ensure-Dir $TE
|
||||||
|
Ensure-Dir $VAE
|
||||||
|
|
||||||
|
if (-not $SkipDownload) {
|
||||||
|
$cli = Find-HfCli
|
||||||
|
Write-Ok "HF CLI: $cli"
|
||||||
|
|
||||||
|
# DiT GGUF only (no Q6/Q5/Q4, no full bf16 DiT)
|
||||||
|
# Exact invocation required on this host:
|
||||||
|
# hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||||
|
# That form writes into the Hugging Face hub cache (not --local-dir).
|
||||||
|
Write-Step "hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf"
|
||||||
|
$ditName = 'qwen-image-2.1-Q8_0.gguf'
|
||||||
|
$ditDest = Join-Path $Diff $ditName
|
||||||
|
if ((Test-Path $ditDest) -and -not $Force -and (Get-Item $ditDest).Length -gt 1MB) {
|
||||||
|
Write-Ok "Already present: $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||||
|
} else {
|
||||||
|
& $cli download "hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/$ditName"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "hf download failed for DiT GGUF (exit $LASTEXITCODE)" }
|
||||||
|
$cacheRoot = Join-Path $env:USERPROFILE '.cache\huggingface\hub\models--abenzerps--Qwen-Image-2.1-Uncensored-GGUF'
|
||||||
|
$found = Get-ChildItem $cacheRoot -Recurse -File -Filter $ditName -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $found) { throw "hf download finished but $ditName was not found under $cacheRoot" }
|
||||||
|
Ensure-Dir $Diff
|
||||||
|
Copy-Item -Force $found.FullName $ditDest
|
||||||
|
Write-Ok "Copied $($found.FullName) -> $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# abenzerps ships kv_count=0 + Q8_0 1D RMSNorms (packed 136 vs logical 128).
|
||||||
|
# Tag architecture=qwen_image and promote small/1D tensors to F32 for ComfyUI-GGUF.
|
||||||
|
Write-Step "Tag DiT GGUF (architecture + F32 norms)"
|
||||||
|
$tagger = Join-Path $PSScriptRoot 'tag-qwen21-gguf.py'
|
||||||
|
$pyCandidates = @(
|
||||||
|
(Join-Path $root '.venv\Scripts\python.exe'),
|
||||||
|
(Join-Path (Split-Path $root) 'standalone-env\python.exe'),
|
||||||
|
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)
|
||||||
|
) | Where-Object { $_ -and (Test-Path $_) }
|
||||||
|
$py = $pyCandidates | Select-Object -First 1
|
||||||
|
if (-not $py) { throw "No Python found to run $tagger" }
|
||||||
|
$fixed = Join-Path $Diff 'qwen-image-2.1-Q8_0.fixed.gguf'
|
||||||
|
& $py $tagger $ditDest $fixed
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "tag-qwen21-gguf.py failed (exit $LASTEXITCODE)" }
|
||||||
|
Copy-Item -Force $fixed $ditDest
|
||||||
|
Write-Ok "Installed tagged GGUF at $ditDest"
|
||||||
|
|
||||||
|
# TE + VAE companions from Comfy-Org (INT8 TE for 16 GB VRAM)
|
||||||
|
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'text_encoders/qwen3vl_8b_int8_convrot.safetensors' -LocalDir $TE
|
||||||
|
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'vae/qwen_image_2.1_vae_bf16.safetensors' -LocalDir $VAE
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Verify weights"
|
||||||
|
Assert-ModelFile (Join-Path $Diff 'qwen-image-2.1-Q8_0.gguf') 7000000000
|
||||||
|
Assert-ModelFile (Join-Path $TE 'qwen3vl_8b_int8_convrot.safetensors') 1000000000
|
||||||
|
Assert-ModelFile (Join-Path $VAE 'qwen_image_2.1_vae_bf16.safetensors') 100000000
|
||||||
|
|
||||||
|
if (-not $SkipNode) {
|
||||||
|
Write-Step "city96 ComfyUI-GGUF"
|
||||||
|
$nodes = Join-Path $root 'custom_nodes\ComfyUI-GGUF'
|
||||||
|
if (-not (Test-Path $nodes)) {
|
||||||
|
git clone https://github.com/city96/ComfyUI-GGUF $nodes
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git clone ComfyUI-GGUF failed" }
|
||||||
|
Write-Ok "Cloned $nodes"
|
||||||
|
} else {
|
||||||
|
Write-Ok "Already present: $nodes"
|
||||||
|
}
|
||||||
|
|
||||||
|
$py = Join-Path $root '.venv\Scripts\python.exe'
|
||||||
|
if (-not (Test-Path $py)) {
|
||||||
|
$py = Join-Path (Split-Path $root) 'standalone-env\python.exe'
|
||||||
|
}
|
||||||
|
if (Test-Path $py) {
|
||||||
|
Write-Step "Ensure gguf package in Comfy python ($py)"
|
||||||
|
& $py -m pip install -q gguf
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Warn "pip install gguf returned $LASTEXITCODE" }
|
||||||
|
else { Write-Ok "gguf importable" }
|
||||||
|
} else {
|
||||||
|
Write-Warn "Comfy python not found; install gguf into the interpreter that runs main.py"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Done. Restart Comfy only when /status shows gpu.busy=false." -ForegroundColor Cyan
|
||||||
|
Write-Host "ComfyRoot=$root"
|
||||||
|
Write-Host "ModelsRoot=$models"
|
||||||
@@ -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) }
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ import { queueSeeds } from '~/shared/studio2/seed.mjs';
|
|||||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import template from '../../assets/studio2_minimax_native.json';
|
import template from '../../assets/studio2_minimax_native.json';
|
||||||
|
import qwen21Template from '../../assets/studio2_qwen21_t2i.json'
|
||||||
|
import qwen21EditTemplate from '../../assets/studio2_qwen21_edit.json'
|
||||||
|
import qwen21T2iTurboTemplate from '../../assets/studio2_qwen21_t2i_turbo.json'
|
||||||
|
import qwen21EditTurboTemplate from '../../assets/studio2_qwen21_edit_turbo.json'
|
||||||
|
import qwen21PeT2iTemplate from '../../assets/studio2_qwen21_pe_t2i.json'
|
||||||
|
import qwen21PeEditTemplate from '../../assets/studio2_qwen21_pe_edit.json';
|
||||||
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
||||||
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
||||||
|
import { resolveQwen21Size } from '~/shared/studio2/qwen21-size.mjs';
|
||||||
|
import { QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from '~/shared/studio2/qwen21-turbo.mjs';
|
||||||
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
||||||
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
||||||
import { readRecord, saveRecord, studio2Root, records } from './store';
|
import { readRecord, saveRecord, studio2Root, records } from './store';
|
||||||
import { ensureComfyReady } from '../comfyLifecycle';
|
import { ensureComfyReady } from '../comfyLifecycle';
|
||||||
import { comfyFetch, queuePrompt, fetchHistory, extractVideo } from '../comfy';
|
import { comfyFetch, queuePrompt, fetchHistory, extractVideo, freeComfyVram } from '../comfy';
|
||||||
import { extractEditedImage } from '../imageComfy';
|
import { extractEditedImage } from '../imageComfy';
|
||||||
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
|
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
|
||||||
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
|
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
|
||||||
@@ -34,10 +42,111 @@ function update(r: any, state: string) { r.state = state; r.updatedAt = Date.now
|
|||||||
// extend/video start frame (which is either the exact anchor PNG or an already-canvas-sized
|
// extend/video start frame (which is either the exact anchor PNG or an already-canvas-sized
|
||||||
// extracted last frame). Fitting these every hop is what destroys identity across chained extends.
|
// extracted last frame). Fitting these every hop is what destroys identity across chained extends.
|
||||||
const NEVER_FIT = new Set(['hero', 'start']);
|
const NEVER_FIT = new Set(['hero', 'start']);
|
||||||
|
|
||||||
|
function previewAnyText(outputs: Record<string, any> | undefined, nodeId: string): string {
|
||||||
|
const node = outputs?.[nodeId]
|
||||||
|
if (!node || typeof node !== 'object') return ''
|
||||||
|
const raw = node.text ?? node.STRING ?? node.source
|
||||||
|
if (Array.isArray(raw)) return String(raw[0] ?? '')
|
||||||
|
if (raw == null) return ''
|
||||||
|
return String(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
function harvestQwen21Pe(history: Record<string, any> | null | undefined, promptId: string, edit: boolean) {
|
||||||
|
const outputs = history?.[promptId]?.outputs as Record<string, any> | undefined
|
||||||
|
const positive_prompt = previewAnyText(outputs, '30').trim()
|
||||||
|
const wh_ratio = previewAnyText(outputs, '31').trim()
|
||||||
|
const ratio_follow = edit ? previewAnyText(outputs, '32').trim() : ''
|
||||||
|
const thinking = previewAnyText(outputs, edit ? '33' : '32').trim()
|
||||||
|
const parseRaw = previewAnyText(outputs, edit ? '34' : '33').trim()
|
||||||
|
const parse_ok = /^(true|1|yes)$/i.test(parseRaw)
|
||||||
|
return { positive_prompt, wh_ratio, ratio_follow, thinking, parse_ok }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitPromptHistory(r: any, job: Job, promptId: string) {
|
||||||
|
let history: any = null, missingSince = 0
|
||||||
|
for (;;) {
|
||||||
|
try { history = await fetchHistory(promptId) }
|
||||||
|
catch { history = null }
|
||||||
|
const e = history?.[promptId]
|
||||||
|
if (job.status === 'cancelled') throw new Error('Cancelled')
|
||||||
|
if (['error', 'interrupted'].includes(e?.status?.status_str))
|
||||||
|
throw new Error('Comfy generation failed or was interrupted; inspect the host error log.')
|
||||||
|
if (!e) {
|
||||||
|
try {
|
||||||
|
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) })
|
||||||
|
if (response.ok) {
|
||||||
|
const q = await response.json() as any
|
||||||
|
const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === promptId)
|
||||||
|
if (present) missingSince = 0
|
||||||
|
else if (!missingSince) missingSince = Date.now()
|
||||||
|
else if (Date.now() - missingSince > 60000) throw new Error('PROMPT_MISSING')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'PROMPT_MISSING')
|
||||||
|
throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e?.status?.completed || e?.status?.status_str === 'success') break
|
||||||
|
if (job.status === 'cancelled') throw new Error('Cancelled')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
|
}
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQwen21PromptEnhance(r: any, job: Job) {
|
||||||
|
const q = r.request, s = q.settings
|
||||||
|
if (!q.enhancePrompt || q.engine !== 'qwen21' || !['generate', 'edit'].includes(q.mode)) return
|
||||||
|
const edit = q.mode === 'edit'
|
||||||
|
if (edit && !q.imageAId) throw new Error('Choose the source still.')
|
||||||
|
update(r, 'enhancing')
|
||||||
|
emitJob(job, { type: 'status', message: 'Enhancing prompt…' })
|
||||||
|
let prompt = String(q.compiledPrompt || '')
|
||||||
|
if (edit && !/<image1>/i.test(prompt))
|
||||||
|
prompt = `Keep the subject in <image1>. ${prompt}`.trim()
|
||||||
|
const graph = structuredClone(edit ? qwen21PeEditTemplate : qwen21PeT2iTemplate)
|
||||||
|
graph['9'].inputs.prompt = prompt
|
||||||
|
graph['9'].inputs.seed = s.seed
|
||||||
|
if (edit) {
|
||||||
|
const a = await upload(r, 'pe-source', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageAId).id)))
|
||||||
|
graph['10'].inputs.image = a
|
||||||
|
graph['9'].inputs.image_1 = ['10', 0]
|
||||||
|
if (q.imageBId) {
|
||||||
|
const b = await upload(r, 'pe-compose', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageBId).id)))
|
||||||
|
graph['11'] = {
|
||||||
|
inputs: { image: b },
|
||||||
|
class_type: 'LoadImage',
|
||||||
|
_meta: { title: 'image_2 optional' }
|
||||||
|
}
|
||||||
|
graph['9'].inputs.image_2 = ['11', 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.peGraphId = edit ? 'studio2_qwen21_pe_edit.json' : 'studio2_qwen21_pe_t2i.json'
|
||||||
|
saveRecord(r)
|
||||||
|
const queued = await queuePrompt(graph, job.clientId)
|
||||||
|
r.pePromptId = queued.prompt_id
|
||||||
|
saveRecord(r)
|
||||||
|
const history = await waitPromptHistory(r, job, queued.prompt_id)
|
||||||
|
const result = harvestQwen21Pe(history, queued.prompt_id, edit)
|
||||||
|
if (!result.parse_ok || !result.positive_prompt)
|
||||||
|
throw new Error('Prompt enhance failed to parse a rewrite. Try again or turn Enhance prompt off.')
|
||||||
|
r.request.promptRaw = q.compiledPrompt
|
||||||
|
r.request.compiledPrompt = result.positive_prompt
|
||||||
|
r.request.enhance = {
|
||||||
|
wh_ratio: result.wh_ratio || '',
|
||||||
|
...(edit ? { ratio_follow: result.ratio_follow || '' } : {}),
|
||||||
|
parse_ok: true,
|
||||||
|
thinking: result.thinking || ''
|
||||||
|
}
|
||||||
|
r.pePromptId = ''
|
||||||
|
saveRecord(r)
|
||||||
|
await freeComfyVram()
|
||||||
|
}
|
||||||
|
|
||||||
async function upload(r: any, name: string, data: Buffer): Promise<string> {
|
async function upload(r: any, name: string, data: Buffer): Promise<string> {
|
||||||
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
|
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
|
||||||
let fitted = false;
|
let fitted = false;
|
||||||
if (!NEVER_FIT.has(name) && needsFit(data, r.request.settings.width, r.request.settings.height)) {
|
if (!NEVER_FIT.has(name) && r.request?.engine !== 'qwen21' && needsFit(data, r.request.settings.width, r.request.settings.height)) {
|
||||||
data = await fitStill(data, r.request.settings.width, r.request.settings.height);
|
data = await fitStill(data, r.request.settings.width, r.request.settings.height);
|
||||||
fitted = true;
|
fitted = true;
|
||||||
}
|
}
|
||||||
@@ -126,6 +235,89 @@ async function prepareGraph(r: any) {
|
|||||||
r.graphId = 'studio2_minimax_native.json';
|
r.graphId = 'studio2_minimax_native.json';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (q.engine === 'qwen21') {
|
||||||
|
if (!['generate', 'edit'].includes(q.mode))
|
||||||
|
throw new Error('Qwen 2.1 supports Generate and Edit.')
|
||||||
|
const turbo = !!q.turbo
|
||||||
|
if (turbo) {
|
||||||
|
q.lora = QWEN21_TURBO_LORA
|
||||||
|
q.sigmas = QWEN21_TURBO_SIGMAS
|
||||||
|
s.steps = QWEN21_TURBO_STEPS
|
||||||
|
s.cfg = 1
|
||||||
|
}
|
||||||
|
const negative = turbo ? '' : stylePrompt(q.imageStyles, true)
|
||||||
|
if (q.mode === 'edit') {
|
||||||
|
if (!a) throw new Error('Choose the source still.')
|
||||||
|
let prompt = String(q.compiledPrompt || '')
|
||||||
|
if (!/<image1>/i.test(prompt))
|
||||||
|
prompt = `Keep the subject in <image1>. ${prompt}`.trim()
|
||||||
|
graph = structuredClone(turbo ? qwen21EditTurboTemplate : qwen21EditTemplate)
|
||||||
|
graph['10'].inputs.image = a
|
||||||
|
if (b) {
|
||||||
|
graph['11'] = {
|
||||||
|
inputs: { image: b },
|
||||||
|
class_type: 'LoadImage',
|
||||||
|
_meta: { title: 'image_2 optional' }
|
||||||
|
}
|
||||||
|
graph['9'].inputs['images.image_2'] = ['11', 0]
|
||||||
|
}
|
||||||
|
graph['9'].inputs.prompt = prompt
|
||||||
|
graph['9'].inputs.negative_prompt = negative
|
||||||
|
// Official edit: sampler latent comes from TextEncode (sized from image_1).
|
||||||
|
graph['9'].inputs.resolution = 1024
|
||||||
|
if (turbo) {
|
||||||
|
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
|
||||||
|
graph['7'].inputs.strength = 1.0
|
||||||
|
graph['31'].inputs.noise_seed = s.seed
|
||||||
|
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
|
||||||
|
graph['36'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 edit latent'
|
||||||
|
r.graphId = 'studio2_qwen21_edit_turbo.json'
|
||||||
|
} else {
|
||||||
|
graph['15'].inputs.seed = s.seed
|
||||||
|
graph['15'].inputs.steps = s.steps || 25
|
||||||
|
graph['15'].inputs.cfg = s.cfg ?? 1
|
||||||
|
graph['15'].inputs.sampler_name = 'euler'
|
||||||
|
graph['15'].inputs.scheduler = 'simple'
|
||||||
|
graph['21'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 edit latent'
|
||||||
|
r.graphId = 'studio2_qwen21_edit.json'
|
||||||
|
}
|
||||||
|
r.sampleDenoise = null
|
||||||
|
r.heroReferenceAttached = false
|
||||||
|
} else {
|
||||||
|
// Bench aspect is law for Generate. PE wh_ratio is advisory only (never sizes the canvas).
|
||||||
|
const size = resolveQwen21Size(s.aspect)
|
||||||
|
s.width = size.width
|
||||||
|
s.height = size.height
|
||||||
|
graph = structuredClone(turbo ? qwen21T2iTurboTemplate : qwen21Template)
|
||||||
|
graph['9'].inputs.prompt = q.compiledPrompt
|
||||||
|
graph['9'].inputs.negative_prompt = negative
|
||||||
|
graph['9'].inputs.resolution = 1024
|
||||||
|
graph['16'].inputs.width = size.width
|
||||||
|
graph['16'].inputs.height = size.height
|
||||||
|
if (turbo) {
|
||||||
|
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
|
||||||
|
graph['7'].inputs.strength = 1.0
|
||||||
|
graph['31'].inputs.noise_seed = s.seed
|
||||||
|
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
|
||||||
|
graph['36'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 empty latent'
|
||||||
|
r.graphId = 'studio2_qwen21_t2i_turbo.json'
|
||||||
|
} else {
|
||||||
|
graph['15'].inputs.seed = s.seed
|
||||||
|
graph['15'].inputs.steps = s.steps || 25
|
||||||
|
graph['15'].inputs.cfg = s.cfg ?? 1
|
||||||
|
graph['15'].inputs.sampler_name = 'euler'
|
||||||
|
graph['15'].inputs.scheduler = 'simple'
|
||||||
|
graph['21'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 empty latent'
|
||||||
|
r.graphId = 'studio2_qwen21_t2i.json'
|
||||||
|
}
|
||||||
|
r.sampleDenoise = null
|
||||||
|
r.heroReferenceAttached = false
|
||||||
|
}
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
||||||
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
||||||
@@ -171,56 +363,31 @@ async function run(r: any, job: Job) {
|
|||||||
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
|
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
|
||||||
update(r, 'waking');
|
update(r, 'waking');
|
||||||
await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
|
await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
|
||||||
|
if (job.status === 'cancelled')
|
||||||
|
throw new Error('Cancelled');
|
||||||
|
await runQwen21PromptEnhance(r, job);
|
||||||
if (job.status === 'cancelled')
|
if (job.status === 'cancelled')
|
||||||
throw new Error('Cancelled');
|
throw new Error('Cancelled');
|
||||||
const graph = await prepareGraph(r);
|
const graph = await prepareGraph(r);
|
||||||
if (job.status === 'cancelled')
|
if (job.status === 'cancelled')
|
||||||
throw new Error('Cancelled');
|
throw new Error('Cancelled');
|
||||||
update(r, 'submitting');
|
update(r, 'submitting');
|
||||||
const queued = await queuePrompt(graph, job.clientId);
|
emitJob(job, { type: 'status', message: 'Generating…' });
|
||||||
|
let queued: { prompt_id: string; number?: number }
|
||||||
|
try {
|
||||||
|
queued = await queuePrompt(graph, job.clientId);
|
||||||
|
} catch (error: any) {
|
||||||
|
const msg = String(error?.statusMessage || error?.message || error)
|
||||||
|
if (r.request?.turbo && /lora|ViggleTurbo|not in list|value_not_in_list/i.test(msg))
|
||||||
|
throw new Error(`Qwen Turbo LoRA missing on host (${QWEN21_TURBO_LORA}). Install the r128 file under models/loras and restart Comfy.`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
job.promptId = queued.prompt_id;
|
job.promptId = queued.prompt_id;
|
||||||
r.promptId = queued.prompt_id;
|
r.promptId = queued.prompt_id;
|
||||||
r.renderStartedAt = Date.now();
|
r.renderStartedAt = Date.now();
|
||||||
update(r, 'rendering');
|
update(r, 'rendering');
|
||||||
}
|
}
|
||||||
let history: any, missingSince = 0;
|
const history = await waitPromptHistory(r, job, r.promptId);
|
||||||
for (;;) {
|
|
||||||
try {
|
|
||||||
history = await fetchHistory(r.promptId);
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
history = null;
|
|
||||||
}
|
|
||||||
const e = history?.[r.promptId];
|
|
||||||
if (job.status === 'cancelled')
|
|
||||||
throw new Error('Cancelled');
|
|
||||||
if (['error', 'interrupted'].includes(e?.status?.status_str))
|
|
||||||
throw new Error('Comfy generation failed or was interrupted; inspect the host error log.');
|
|
||||||
if (!e) {
|
|
||||||
try {
|
|
||||||
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) });
|
|
||||||
if (response.ok) {
|
|
||||||
const q = await response.json() as any;
|
|
||||||
const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === r.promptId);
|
|
||||||
if (present)
|
|
||||||
missingSince = 0;
|
|
||||||
else if (!missingSince)
|
|
||||||
missingSince = Date.now();
|
|
||||||
else if (Date.now() - missingSince > 60000)
|
|
||||||
throw new Error('PROMPT_MISSING');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error: any) {
|
|
||||||
if (error.message === 'PROMPT_MISSING')
|
|
||||||
throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e?.status?.completed || e?.status?.status_str === 'success')
|
|
||||||
break;
|
|
||||||
if (job.status === 'cancelled')
|
|
||||||
throw new Error('Cancelled');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
||||||
}
|
|
||||||
const messages = history[r.promptId]?.status?.messages || [];
|
const messages = history[r.promptId]?.status?.messages || [];
|
||||||
const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp;
|
const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp;
|
||||||
const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp;
|
const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp;
|
||||||
|
|||||||
@@ -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,31 @@ export function validateRequest(raw, xaigen = false) {
|
|||||||
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
||||||
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
||||||
const video = ['video','extend'].includes(mode)
|
const video = ['video','extend'].includes(mode)
|
||||||
if (!(video ? ['minimax','ltx'] : ['flux','krea']).includes(engine)) fail('Engine does not support this task.')
|
if (!(video ? ['minimax','ltx'] : ['flux','krea','qwen21']).includes(engine)) fail('Engine does not support this task.')
|
||||||
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
||||||
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
||||||
|
if (engine === 'qwen21' && !['generate', 'edit'].includes(mode)) fail('Qwen 2.1 supports Generate and Edit.')
|
||||||
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
||||||
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
||||||
if (!raw.folderId) fail('Choose a folder.')
|
if (!raw.folderId) fail('Choose a folder.')
|
||||||
if ((video || ['edit','compose','iterate'].includes(mode)) && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
|
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) fail('Choose the source still.')
|
||||||
|
if ((video || ['edit','compose','iterate'].includes(mode)) && engine !== 'qwen21' && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
|
||||||
if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
|
if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
|
||||||
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
|
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
|
||||||
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) fail('Choose the source still.')
|
|
||||||
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip or Previous clip.')
|
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip or Previous clip.')
|
||||||
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
|
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
|
||||||
const promptSections = video ? allSections : {action:allSections.action}
|
const promptSections = video ? allSections : {action:allSections.action}
|
||||||
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
||||||
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
||||||
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
|
const qwen = engine === 'qwen21'
|
||||||
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
const defaultSteps = video ? 8 : qwen ? 25 : 24
|
||||||
|
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
|
||||||
|
const aspect = QWEN21_ASPECT_IDS.has(raw.settings?.aspect) ? raw.settings.aspect : 'auto'
|
||||||
|
const qwenSize = qwen && mode === 'generate' ? resolveQwen21Size(aspect) : null
|
||||||
|
const defaultWidth = qwenSize ? qwenSize.width : qwen ? 1024 : 1344
|
||||||
|
const defaultHeight = qwenSize ? qwenSize.height : qwen ? 1024 : 768
|
||||||
|
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?defaultSteps:1,0,2147483647))) fail(`Invalid ${key}.`)
|
||||||
|
for (const key of ['width','height']) if (number(key,key==='width'?defaultWidth:defaultHeight,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
||||||
const duration=number('duration',5,1,15)
|
const duration=number('duration',5,1,15)
|
||||||
if (video) {
|
if (video) {
|
||||||
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
||||||
@@ -55,10 +65,16 @@ export function validateRequest(raw, xaigen = false) {
|
|||||||
}
|
}
|
||||||
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
||||||
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
||||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
const enhancePrompt = qwen && !video && !!raw.enhancePrompt
|
||||||
|
const turbo = qwen && !video && !!raw.turbo
|
||||||
|
const width = qwenSize ? qwenSize.width : number('width',defaultWidth,256,4096)
|
||||||
|
const height = qwenSize ? qwenSize.height : number('height',defaultHeight,256,4096)
|
||||||
|
const steps = turbo ? QWEN21_TURBO_STEPS : number('steps',defaultSteps,1,100)
|
||||||
|
const cfg = turbo ? QWEN21_TURBO_CFG : number('cfg',defaultCfg,0,20)
|
||||||
|
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, enhancePrompt, turbo, ...(turbo ? { lora: QWEN21_TURBO_LORA, sigmas: QWEN21_TURBO_SIGMAS } : {}), lockFace:qwen ? false : raw.lockFace !== false, lockOutfit:qwen ? false : raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:qwen ? '' : (raw.identityStillId || ''), imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||||
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
||||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
|
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
|
||||||
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
|
settings: { aspect, fps:number('fps',24,1,60), width, height, steps, cfg, seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:qwen?[]:(Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[]) },
|
||||||
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
|
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
|
||||||
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
|
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import assert from 'node:assert/strict'
|
|||||||
import {readFileSync} from 'node:fs'
|
import {readFileSync} from 'node:fs'
|
||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
|
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
|
||||||
import {useAsHero,useAsSource,restoreAll} from '../shared/studio2/input.mjs'
|
import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs'
|
||||||
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
|
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
|
||||||
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
||||||
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
|
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
|
||||||
|
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
|
||||||
|
|
||||||
test('deleted library files never return from job outputs; selection falls back or clears',()=>{
|
test('deleted library files never return from job outputs; selection falls back or clears',()=>{
|
||||||
const deleted={id:'gone',folderId:'f'},keep={id:'keep',folderId:'f',createdAt:2};const jobs=[{request:{folderId:'f'},outputs:[deleted]}]
|
const deleted={id:'gone',folderId:'f'},keep={id:'keep',folderId:'f',createdAt:2};const jobs=[{request:{folderId:'f'},outputs:[deleted]}]
|
||||||
@@ -28,6 +29,8 @@ test('hidden xAIGen canvas and family render no image or video elements',async()
|
|||||||
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
||||||
assert.match(src,/aria-label="Current family outputs"/)
|
assert.match(src,/aria-label="Current family outputs"/)
|
||||||
assert.match(src,/preview hidden/)
|
assert.match(src,/preview hidden/)
|
||||||
|
assert.match(src,/Reveal all/)
|
||||||
|
assert.match(src,/pickerRevealAll/)
|
||||||
assert.doesNotMatch(src,/Hide input stills/)
|
assert.doesNotMatch(src,/Hide input stills/)
|
||||||
assert.doesNotMatch(src,/Hide output preview/)
|
assert.doesNotMatch(src,/Hide output preview/)
|
||||||
})
|
})
|
||||||
@@ -74,3 +77,63 @@ test('image chips compile positive and negative independently; video chips do no
|
|||||||
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
||||||
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
||||||
})
|
})
|
||||||
|
test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
|
||||||
|
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})
|
||||||
|
assert.equal(q.engine,'qwen21');assert.equal(q.settings.steps,25);assert.equal(q.settings.cfg,1)
|
||||||
|
assert.equal(q.settings.width,1024);assert.equal(q.settings.height,1024)
|
||||||
|
assert.equal(q.lockFace,false);assert.equal(q.lockOutfit,false);assert.deepEqual(q.settings.loraStack,[])
|
||||||
|
assert.equal(q.enhancePrompt,false)
|
||||||
|
assert.equal(q.turbo,false)
|
||||||
|
const wide=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'wide'},settings:{aspect:'16:9'}})
|
||||||
|
assert.equal(wide.settings.width,1536);assert.equal(wide.settings.height,864)
|
||||||
|
const tall=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'tall'},settings:{aspect:'9:16'}})
|
||||||
|
assert.equal(tall.settings.width,864);assert.equal(tall.settings.height,1536)
|
||||||
|
const turbo=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',turbo:true,promptSections:{action:'fast'},settings:{aspect:'1:1',steps:25,cfg:4}})
|
||||||
|
assert.equal(turbo.turbo,true);assert.equal(turbo.settings.steps,6);assert.equal(turbo.settings.cfg,1)
|
||||||
|
assert.match(String(turbo.lora),/viggle-turbo-v0\.2\.1-6step-lora-r128/)
|
||||||
|
assert.match(String(turbo.sigmas),/0\.9375/)
|
||||||
|
const enhanced=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',enhancePrompt:true,promptSections:{action:'corgi'},settings:{aspect:'1:1'}})
|
||||||
|
assert.equal(enhanced.enhancePrompt,true)
|
||||||
|
assert.equal(validateRequest({mode:'generate',engine:'flux',folderId:'f',enhancePrompt:true,turbo:true,promptSections:{action:'x'},settings:{aspect:'1:1'}}).turbo,false)
|
||||||
|
const edit=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'change the sky'},settings:{aspect:'auto'}})
|
||||||
|
assert.equal(edit.mode,'edit');assert.equal(edit.settings.steps,25);assert.equal(edit.settings.cfg,1)
|
||||||
|
assert.equal(edit.lockFace,false);assert.deepEqual(edit.settings.loraStack,[])
|
||||||
|
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',promptSections:{action:'x'}}),/source still/)
|
||||||
|
assert.throws(()=>validateRequest({mode:'compose',engine:'qwen21',folderId:'f',imageAId:'s',imageBId:'b',promptSections:{action:'x'}}),/Generate and Edit/)
|
||||||
|
assert.throws(()=>validateRequest({mode:'iterate',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/Generate and Edit/)
|
||||||
|
})
|
||||||
|
test('hydrateStudio2Job restores LoRA strengths, locked seed, and clears missing stills',()=>{
|
||||||
|
const form={mode:'generate',engine:'flux',identityStillId:'',imageAId:'',promptSections:{action:''},imageStyles:{positive:[],negative:[]},settings:{aspect:'auto',seed:null,seedMode:'random',loraStack:[]},guides:[]}
|
||||||
|
const saved={mode:'edit',engine:'flux',identityStillId:'hero1',imageAId:'gone',lockFace:true,lockOutfit:false,promptSections:{action:'pokemon trainer'},imageStyles:{positive:[],negative:['blurry','custom bad']},settings:{aspect:'16:9',seed:42,steps:24,cfg:4,loraStack:[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}]}}
|
||||||
|
const {form:next,missing}=hydrateStudio2Job(form,saved,{stillIds:new Set(['hero1']),clipIds:new Set()})
|
||||||
|
assert.equal(next.mode,'edit');assert.equal(next.identityStillId,'hero1');assert.equal(next.imageAId,'')
|
||||||
|
assert.equal(next.settings.seed,42);assert.equal(next.settings.seedMode,'locked');assert.equal(next.settings.aspect,'16:9')
|
||||||
|
assert.deepEqual(next.settings.loraStack,[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}])
|
||||||
|
assert.deepEqual(next.imageStyles.negative,['blurry','custom bad'])
|
||||||
|
assert.match(missing,/start missing/)
|
||||||
|
})
|
||||||
|
test('custom negative chips survive normalizeStyles and bench exposes Load actions',()=>{
|
||||||
|
let styles={positive:[],negative:['blurry','my watermark']}
|
||||||
|
styles=toggleStyle(styles,'deformed',true)
|
||||||
|
assert.deepEqual(styles.negative,['blurry','my watermark','deformed'])
|
||||||
|
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
||||||
|
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Edit source/)
|
||||||
|
assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/)
|
||||||
|
assert.match(src,/Enhance prompt/);assert.match(src,/enhancePrompt/)
|
||||||
|
assert.match(src,/\bTurbo\b/);assert.match(src,/form\.turbo/)
|
||||||
|
const peT2i=readFileSync(new URL('../server/assets/studio2_qwen21_pe_t2i.json',import.meta.url),'utf8')
|
||||||
|
const peEdit=readFileSync(new URL('../server/assets/studio2_qwen21_pe_edit.json',import.meta.url),'utf8')
|
||||||
|
assert.match(peT2i,/QwenImage21_T2IPromptRewrite/);assert.match(peT2i,/pe_t2i\.int8_convrot/)
|
||||||
|
assert.match(peEdit,/QwenImage21_EditPromptRewrite/);assert.match(peEdit,/pe_i2i\.int8_convrot/)
|
||||||
|
assert.doesNotMatch(peT2i,/UnetLoader|KSampler|VAELoader/)
|
||||||
|
assert.doesNotMatch(peEdit,/UnetLoader|KSampler|VAELoader/)
|
||||||
|
const t2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i.json',import.meta.url),'utf8')
|
||||||
|
assert.match(t2i,/EmptyLatentImage/);assert.match(t2i,/"latent_image":\s*\["16",\s*0\]/)
|
||||||
|
const turboT2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i_turbo.json',import.meta.url),'utf8')
|
||||||
|
const turboEdit=readFileSync(new URL('../server/assets/studio2_qwen21_edit_turbo.json',import.meta.url),'utf8')
|
||||||
|
assert.match(turboT2i,/ViggleTurboLora/);assert.match(turboT2i,/ViggleTurboSigmas/);assert.match(turboT2i,/SamplerCustomAdvanced/)
|
||||||
|
assert.match(turboT2i,/0\.9375/);assert.match(turboEdit,/ViggleTurboLora/);assert.match(turboEdit,/"latent":\s*\["9",\s*2\]/)
|
||||||
|
assert.deepEqual(resolveQwen21Size('16:9'),{width:1536,height:864})
|
||||||
|
assert.deepEqual(resolveQwen21Size('1:1'),{width:1024,height:1024})
|
||||||
|
assert.deepEqual(resolveQwen21Size('9:16'),{width:864,height:1536})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||