Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,
|
||||
[data-display='classic'],
|
||||
[data-display='studio'] {
|
||||
--display-bg: #09090b;
|
||||
--display-fg: #e4e4e7;
|
||||
--display-muted: #a1a1aa;
|
||||
--display-border: rgba(255, 255, 255, 0.1);
|
||||
--display-amber: #fbbf24;
|
||||
--display-amber-fg: #09090b;
|
||||
--display-radius: 8px;
|
||||
--display-focus: rgba(252, 211, 77, 0.5);
|
||||
--bg: #0b0b0d;
|
||||
--panel: #141416;
|
||||
--panel-raise: #1a1a1d;
|
||||
--line: #2a2a2e;
|
||||
--text: #f3f1ea;
|
||||
--muted: #9a9588;
|
||||
--amber: #f5a524;
|
||||
--amber-on: #1a1204;
|
||||
--chip-bg: #2a2418;
|
||||
--chip-line: #5a4a28;
|
||||
--chip-text: #e8c37a;
|
||||
--success: #7dda6f;
|
||||
--radius: 8px;
|
||||
--page-gap: 12px;
|
||||
--field-gap: 12px;
|
||||
--header-h: 48px;
|
||||
--rail-task-h: 40px;
|
||||
--rail-model-h: 36px;
|
||||
--left-w: 260px;
|
||||
--right-w: 280px;
|
||||
--type: 14px;
|
||||
--type-sm: 12px;
|
||||
--type-label: 11px;
|
||||
}
|
||||
|
||||
[data-display='classic'] {
|
||||
--chrome-gap: 0.5rem;
|
||||
--rail-h: 2.25rem;
|
||||
--card-pad: 0.75rem;
|
||||
--display-type-title: 1.75rem;
|
||||
--display-type-body: 0.875rem;
|
||||
--display-viewer-radius: 0.75rem;
|
||||
--field-gap: 8px;
|
||||
--type: 13px;
|
||||
}
|
||||
|
||||
[data-display='studio'] {
|
||||
--chrome-gap: 1rem;
|
||||
--rail-h: 3rem;
|
||||
--card-pad: 1rem;
|
||||
--display-type-title: 1.875rem;
|
||||
--display-type-body: 0.9375rem;
|
||||
--display-viewer-radius: 8px;
|
||||
[data-display] {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: var(--type);
|
||||
}
|
||||
|
||||
[data-display] .display-title {
|
||||
font-size: var(--display-type-title);
|
||||
/* Skin toggles */
|
||||
[data-display='classic'] .rail-task,
|
||||
[data-display='classic'] .rail-model,
|
||||
[data-display='classic'] .identity-slots,
|
||||
[data-display='classic'] .eye-viewer,
|
||||
[data-display='classic'] .eye-tile {
|
||||
display: none !important;
|
||||
}
|
||||
[data-display='studio'] .classic-task-model,
|
||||
[data-display='studio'] .viewer-lock-bar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-display] .display-body {
|
||||
font-size: var(--display-type-body);
|
||||
}
|
||||
|
||||
[data-display] .display-stack {
|
||||
.bench-root {
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
flex-direction: column;
|
||||
gap: var(--chrome-gap);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-display] .display-card {
|
||||
border-radius: var(--display-radius);
|
||||
border: 1px solid var(--display-border);
|
||||
padding: var(--card-pad);
|
||||
.bench-root[data-mobile="1"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-display] .display-viewer {
|
||||
border-radius: var(--display-viewer-radius);
|
||||
.bench-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
height: var(--header-h);
|
||||
min-height: var(--header-h);
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 12px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
[data-display] .display-rail {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
min-height: var(--rail-h);
|
||||
.bench-brand {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
[data-display] .display-rail__seg {
|
||||
min-height: var(--rail-h);
|
||||
border-radius: var(--display-radius);
|
||||
border: 1px solid var(--display-border);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: var(--display-type-body);
|
||||
color: var(--display-fg);
|
||||
.bench-crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.bench-crumb select {
|
||||
max-width: 14rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-display] .display-rail__seg:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--display-focus);
|
||||
.bench-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
[data-display] .display-rail__seg[aria-pressed='true'],
|
||||
[data-display] .display-rail__seg[data-active='true'] {
|
||||
border-color: var(--display-amber);
|
||||
background: var(--display-amber);
|
||||
color: var(--display-amber-fg);
|
||||
.bench-nav {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: var(--type-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-display] .display-rail__seg:disabled,
|
||||
[data-display] .display-rail__seg[aria-disabled='true'] {
|
||||
opacity: 0.55;
|
||||
.bench-nav a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bench-nav a:hover,
|
||||
.bench-nav a.router-link-active {
|
||||
color: var(--amber);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gpu-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-raise);
|
||||
padding: 8px 12px;
|
||||
font-size: var(--type-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.gpu-chip .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.gpu-chip[data-tone='ready'] .dot { background: var(--success); }
|
||||
.gpu-chip[data-tone='busy'] .dot { background: var(--amber); animation: pulse 1.2s infinite; }
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.rail-task { min-height: var(--rail-task-h); align-items: center; padding-top: 8px; }
|
||||
.rail-model { min-height: var(--rail-model-h); align-items: center; padding-bottom: 8px; }
|
||||
|
||||
.seg {
|
||||
height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
padding: 0 14px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rail-model .seg { height: 32px; font-weight: 500; }
|
||||
|
||||
.seg[aria-pressed='true'] {
|
||||
border-color: var(--amber);
|
||||
background: var(--amber);
|
||||
color: var(--amber-on);
|
||||
}
|
||||
|
||||
.seg:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-display='studio'] .display-rail__seg {
|
||||
.seg:focus-visible {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||
}
|
||||
|
||||
.bench-grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
grid-template-columns: var(--left-w) minmax(0, 1fr) var(--right-w);
|
||||
gap: var(--page-gap);
|
||||
padding: var(--page-gap);
|
||||
}
|
||||
|
||||
.bench-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.bench-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--field-gap);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-label);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-raise);
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field-control:focus-visible,
|
||||
.field-area:focus-visible {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||
}
|
||||
|
||||
.field-area {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-raise);
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.neg-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.neg-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--chip-line);
|
||||
border-radius: 999px;
|
||||
background: var(--chip-bg);
|
||||
padding: 4px 10px;
|
||||
color: var(--chip-text);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.neg-chip button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.seed-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.seed-row .field-control { flex: 1; }
|
||||
|
||||
.dice-btn {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-raise);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewer-lock-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.center-stack {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Stage: fills center column above filmstrip + transport */
|
||||
.viewer-frame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #0b0b0d;
|
||||
}
|
||||
|
||||
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */
|
||||
.viewer-frame > img,
|
||||
.viewer-frame > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.eye-viewer {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #000a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filmstrip {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
height: 80px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 10px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
width: 96px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.tile { width: 120px; height: 80px; }
|
||||
.filmstrip { height: 96px; }
|
||||
}
|
||||
|
||||
.tile[data-active='true'] {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.tile button.pick {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.tile .eye-tile,
|
||||
.tile .tile-trash {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
display: grid;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #000a;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tile .tile-trash { right: 22px; }
|
||||
|
||||
.strip-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 10px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.strip-actions button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 4px 10px;
|
||||
font-size: var(--type-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.strip-actions button:hover {
|
||||
border-color: var(--amber);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.neg-add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.neg-add-row .field-control {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.transport {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.transport .ops {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.transport button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.queue-col {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.queue-head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 12px 8px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.queue-badge {
|
||||
display: inline-grid;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--amber);
|
||||
color: var(--amber-on);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.job-card {
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-raise);
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.job-card[data-active='true'] {
|
||||
border-width: 2px;
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.job-id {
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.job-title {
|
||||
margin-top: 2px;
|
||||
font-weight: 600;
|
||||
padding: 0.625rem 1.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
[data-display='classic'] .display-rail__seg {
|
||||
min-height: 2.25rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-weight: 500;
|
||||
.job-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
[data-display] .display-cta {
|
||||
border-radius: var(--display-radius);
|
||||
background: var(--display-amber);
|
||||
color: var(--display-amber-fg);
|
||||
font-weight: 600;
|
||||
.job-status {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
[data-display] .display-cta:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--display-focus);
|
||||
.job-status .left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-display] .display-control {
|
||||
border-radius: var(--display-radius);
|
||||
.job-status .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.job-status[data-state='queued'] .dot,
|
||||
.job-status[data-state='waiting'] .dot {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.kebab {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.queue-foot {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.generate-cta {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: var(--amber);
|
||||
color: var(--amber-on);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.generate-cta:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.queue-foot .tiny {
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.queue-foot .tiny button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-block { width: 100%; }
|
||||
|
||||
.slot-thumb {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #0a0a0c;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-thumb--hero { height: 148px; }
|
||||
.slot-thumb--slot { height: 96px; }
|
||||
.slot-thumb--empty {
|
||||
height: 72px;
|
||||
border-style: dashed;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.slot-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.slot-caption {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.slot-upload {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
color: var(--amber);
|
||||
font-size: var(--type-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.placeholder-still {
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
@@ -6,22 +6,21 @@
|
||||
<li
|
||||
v-for="(item, index) in model"
|
||||
:key="item.name"
|
||||
class="rounded-xl border bg-zinc-950/80"
|
||||
class="rounded-lg border border-[#2A2A2E] bg-[#1A1A1D] p-2.5"
|
||||
:class="[
|
||||
compact ? 'px-2 py-1.5' : 'px-3 py-2',
|
||||
dragOverIndex === index ? 'border-amber-300/50' : 'border-white/10',
|
||||
dragOverIndex === index ? 'border-amber-300/50' : '',
|
||||
draggingIndex === index ? 'opacity-60' : ''
|
||||
]"
|
||||
@dragover.prevent="onDragOver(index)"
|
||||
@drop.prevent="onDrop(index)"
|
||||
@dragleave="onDragLeave(index)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-start gap-2">
|
||||
<button
|
||||
v-if="model.length > 1"
|
||||
type="button"
|
||||
class="shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
|
||||
class="mt-0.5 shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="disabled"
|
||||
draggable="true"
|
||||
title="Drag to reorder"
|
||||
@@ -31,61 +30,91 @@
|
||||
>
|
||||
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
|
||||
</button>
|
||||
<span class="min-w-0"><span class="block truncate text-zinc-200">{{ labelFor(item.name) }}</span><span v-if="filenameSubtitle" class="block truncate text-[11px] text-zinc-500" :title="item.name">{{ item.name }}</span></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="block truncate font-medium text-[#F3F1EA]" :title="labelFor(item.name)">{{ labelFor(item.name) }}</span>
|
||||
<LoraTriggerTip
|
||||
:text="triggerFor(item.name)"
|
||||
:aria-label="'Activation for ' + labelFor(item.name)"
|
||||
@insert="emit('insert-trigger', $event)"
|
||||
@save="saveTrigger(item.name, $event)"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="filenameSubtitle" class="mt-0.5 block truncate text-[11px] text-[#9A9588]" :title="item.name">{{ stemFor(item.name) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-[11px] text-zinc-500 hover:text-red-300 disabled:opacity-50"
|
||||
class="shrink-0 text-[11px] text-[#9A9588] hover:text-red-300 disabled:opacity-50"
|
||||
:disabled="disabled"
|
||||
@click="removeAt(index)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap gap-3">
|
||||
<Thumbwheel
|
||||
:model-value="item.strengthModel"
|
||||
label="Model"
|
||||
:lock-key="`lora-model:${item.name}`"
|
||||
:min="LORA_STRENGTH_MIN"
|
||||
:max="LORA_STRENGTH_MAX"
|
||||
|
||||
<div class="mt-2 space-y-1.5">
|
||||
<label class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]">
|
||||
<span>{{ clipStrength ? 'Model' : 'Strength' }}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="lora-strength-range min-w-0"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:fine-step="LORA_STRENGTH_STEP"
|
||||
:coarse-step="0.25"
|
||||
:digits="2"
|
||||
compact
|
||||
:disabled="disabled"
|
||||
@update:model-value="setStrength(index, 'strengthModel', $event)"
|
||||
/>
|
||||
<Thumbwheel
|
||||
:model-value="item.strengthClip"
|
||||
label="CLIP"
|
||||
:lock-key="`lora-clip:${item.name}`"
|
||||
:min="LORA_STRENGTH_MIN"
|
||||
:max="LORA_STRENGTH_MAX"
|
||||
:value="displayStrength(item.strengthModel)"
|
||||
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' for ' + labelFor(item.name)"
|
||||
@input="onRange(index, 'strengthModel', $event)"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:fine-step="LORA_STRENGTH_STEP"
|
||||
:coarse-step="0.25"
|
||||
:digits="2"
|
||||
compact
|
||||
:disabled="disabled || !clipStrength"
|
||||
@update:model-value="setStrength(index, 'strengthClip', $event)"
|
||||
/>
|
||||
:disabled="disabled"
|
||||
:value="displayStrength(item.strengthModel).toFixed(2)"
|
||||
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' value'"
|
||||
@change="onNumber(index, 'strengthModel', $event)"
|
||||
>
|
||||
</label>
|
||||
<label
|
||||
v-if="clipStrength"
|
||||
class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]"
|
||||
>
|
||||
<span>CLIP</span>
|
||||
<input
|
||||
type="range"
|
||||
class="lora-strength-range min-w-0"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:disabled="disabled"
|
||||
:value="displayStrength(item.strengthClip)"
|
||||
:aria-label="'CLIP for ' + labelFor(item.name)"
|
||||
@input="onRange(index, 'strengthClip', $event)"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:disabled="disabled"
|
||||
:value="displayStrength(item.strengthClip).toFixed(2)"
|
||||
:aria-label="'CLIP value'"
|
||||
@change="onNumber(index, 'strengthClip', $event)"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!clipStrength && model.length" class="mt-1 text-[11px] text-zinc-500">CLIP unused on this graph — model strength only.</p>
|
||||
<select
|
||||
v-if="model.length < LORA_STACK_MAX"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 outline-none ring-amber-300/40 focus:ring-2 disabled:opacity-50"
|
||||
class="w-full rounded-lg border border-[#2A2A2E] bg-[#0B0B0D] text-[#F3F1EA] outline-none ring-[#F5A524]/40 focus:ring-2 disabled:opacity-50"
|
||||
:class="[
|
||||
compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm',
|
||||
compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
|
||||
model.length ? 'mt-2' : ''
|
||||
]"
|
||||
:disabled="disabled || !availableNames.length"
|
||||
@@ -103,13 +132,55 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lora-strength-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #2a2a2e;
|
||||
outline: none;
|
||||
}
|
||||
.lora-strength-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: #f5a524;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lora-strength-range::-moz-range-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #2a2a2e;
|
||||
}
|
||||
.lora-strength-range::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: #f5a524;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lora-strength-range:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.lora-strength-range::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #2a2a2e;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
LORA_STACK_MAX,
|
||||
LORA_STRENGTH_MAX,
|
||||
LORA_STRENGTH_MIN,
|
||||
LORA_STRENGTH_DEFAULT,
|
||||
LORA_STRENGTH_STEP,
|
||||
clampLoraStrength,
|
||||
loraDisplayName,
|
||||
loraFriendlyName,
|
||||
loraIdentityKey,
|
||||
loraMatchesImageEngine,
|
||||
@@ -120,6 +191,9 @@ import {
|
||||
type LoraStackItem
|
||||
} from '~/utils/loras'
|
||||
|
||||
const UI_MIN = 0
|
||||
const UI_MAX = 2
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
names: string[]
|
||||
filenameSubtitle?: boolean
|
||||
@@ -175,6 +249,17 @@ const selectPlaceholder = computed(() => {
|
||||
return model.value.length ? props.addLabel : props.emptyLabel
|
||||
})
|
||||
|
||||
function clampUi(raw: unknown) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return LORA_STRENGTH_DEFAULT
|
||||
const snapped = Math.round(value / LORA_STRENGTH_STEP) * LORA_STRENGTH_STEP
|
||||
return Math.min(UI_MAX, Math.max(UI_MIN, Math.round(snapped * 100) / 100))
|
||||
}
|
||||
|
||||
function displayStrength(value: unknown) {
|
||||
return clampUi(value ?? LORA_STRENGTH_DEFAULT)
|
||||
}
|
||||
|
||||
function onAdd(event: Event) {
|
||||
const name = (event.target as HTMLSelectElement).value
|
||||
;(event.target as HTMLSelectElement).value = ''
|
||||
@@ -186,7 +271,11 @@ function addLora(name: string) {
|
||||
if (!item || model.value.length >= LORA_STACK_MAX) return
|
||||
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
|
||||
if (taken.has(loraIdentityKey(item.name))) return
|
||||
model.value = [...model.value, item]
|
||||
model.value = [...model.value, {
|
||||
...item,
|
||||
strengthModel: clampUi(item.strengthModel ?? LORA_STRENGTH_DEFAULT),
|
||||
strengthClip: clampUi(item.strengthClip ?? LORA_STRENGTH_DEFAULT)
|
||||
}]
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
@@ -234,8 +323,22 @@ function onDragEnd() {
|
||||
}
|
||||
|
||||
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
|
||||
const next = clampLoraStrength(value)
|
||||
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item)
|
||||
const next = clampUi(value)
|
||||
model.value = model.value.map((item, i) => {
|
||||
if (i !== index) return item
|
||||
if (!props.clipStrength) {
|
||||
return { ...item, strengthModel: next, strengthClip: next }
|
||||
}
|
||||
return { ...item, [key]: next }
|
||||
})
|
||||
}
|
||||
|
||||
function onRange(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
function onNumber(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
function triggerFor(name: string) {
|
||||
@@ -246,6 +349,10 @@ function labelFor(name: string) {
|
||||
return loraFriendlyName(name, props.aliases)
|
||||
}
|
||||
|
||||
function stemFor(name: string) {
|
||||
return loraDisplayName(name)
|
||||
}
|
||||
|
||||
function saveTrigger(name: string, trigger: string) {
|
||||
emit('update-trigger', name, trigger)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
@click="setDisplayMode('classic')"
|
||||
>
|
||||
<span class="block font-display text-lg font-semibold text-zinc-50">Classic</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">Packed header, compact selects, tight gaps.</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">packed header, dropdowns</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -46,7 +46,7 @@
|
||||
@click="setDisplayMode('studio')"
|
||||
>
|
||||
<span class="block font-display text-lg font-semibold text-zinc-50">Studio</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">Task and model rails, locks by slots, eye toggles.</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">task rail, model rail, slots + eyes</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
<template>
|
||||
<div class="w-36 shrink-0 space-y-2"><span class="text-xs text-zinc-400">{{ label }}</span><button class="flex h-24 w-full items-center justify-center overflow-hidden rounded-xl border border-white/15 bg-black" @click="$emit('pick')"><span v-if="asset && hidden" class="p-2 text-xs text-zinc-400">Still loaded · preview hidden</span><img v-else-if="asset" :src="asset.kind==='clip'?`/api/library/clips/${asset.id}/thumb`:`/api/library/stills/${asset.id}`" class="h-full w-full object-contain" :alt="label"><span v-else class="text-xs text-zinc-400">Choose {{ label.toLowerCase() }}</span></button><p v-if="asset" class="truncate text-xs" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p><label v-if="upload" class="block cursor-pointer text-xs text-amber-200">Upload {{ label.toLowerCase() }}<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)"></label></div>
|
||||
<div class="slot-block">
|
||||
<span class="field-label">{{ label }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="slot-thumb"
|
||||
:class="asset ? (size==='hero' ? 'slot-thumb--hero' : 'slot-thumb--slot') : 'slot-thumb--empty'"
|
||||
@click="$emit('pick')"
|
||||
>
|
||||
<span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Still loaded · preview hidden</span>
|
||||
<img v-else-if="asset" :src="asset.kind==='clip' ? `/api/library/clips/${asset.id}/thumb` : `/api/library/stills/${asset.id}`" :alt="label">
|
||||
<span v-else class="inline-flex items-center gap-1">Select <span aria-hidden="true">▾</span></span>
|
||||
</button>
|
||||
<p v-if="asset" class="slot-caption" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p>
|
||||
<label v-if="upload" class="slot-upload">Upload {{ label.toLowerCase() }}
|
||||
<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)">
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {assetLabel} from '~/shared/studio2/viewer.mjs'
|
||||
defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean}>()
|
||||
withDefaults(defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean;size?:'hero'|'slot'}>(),{size:'slot'})
|
||||
defineEmits(['pick','upload'])
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,14 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=
|
||||
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
|
||||
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
|
||||
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
|
||||
{label:'Compiled prompt',value:m.compiledPrompt},{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
|
||||
{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:'Enhance wh_ratio',value:m.enhance?.wh_ratio || null},
|
||||
{label:'Enhance ratio_follow',value:m.enhance?.ratio_follow || null},
|
||||
{label:'Enhance parse_ok',value:m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Yes':'No'},
|
||||
{label:'Enhance thinking',value:m.enhance?.thinking || null},
|
||||
{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
|
||||
{label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult}
|
||||
]})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
<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>
|
||||
<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,46 @@
|
||||
# Qwen Image 2.1 (engine `qwen21`) — host weights + Comfy graph
|
||||
|
||||
## Host paths (this RTX 5080 box)
|
||||
|
||||
| Role | Resolved path |
|
||||
|---|---|
|
||||
| ComfyUI root (Klein / host agent `ComfyUI (1)`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI` |
|
||||
| Models root (Desktop Shared via `shared_model_paths.yaml`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Shared\models` |
|
||||
|
||||
Weights must land under Shared so every Desktop instance sees them:
|
||||
|
||||
- `diffusion_models\qwen-image-2.1-Q8_0.gguf` (~7.59 GiB) — from `abenzerps/Qwen-Image-2.1-Uncensored-GGUF`
|
||||
- `text_encoders\qwen3vl_8b_int8_convrot.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (INT8; BF16 8B VL OOMs on 16 GB)
|
||||
- `vae\qwen_image_2.1_vae_bf16.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (**not** the old Qwen-Image 1.0 VAE)
|
||||
|
||||
Custom node: `custom_nodes\ComfyUI-GGUF` (`UnetLoaderGGUF`). Do not install a second GGUF pack.
|
||||
|
||||
## Setup
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||
```
|
||||
|
||||
DiT download uses the exact host invocation:
|
||||
|
||||
```powershell
|
||||
hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||
```
|
||||
|
||||
That lands in the Hugging Face hub cache; the setup script copies it into Shared `diffusion_models`. The abenzerps GGUF ships with `kv_count=0` (no `general.architecture`) **and** Q8_0-quantized 1D RMSNorm weights (logical 128 → packed 136), which breaks Comfy’s fused `rms_rope`. `scripts/tag-qwen21-gguf.py` rewrites the file with `general.architecture=qwen_image` and promotes small/1D tensors to F32 so city96 `UnetLoaderGGUF` can load it.
|
||||
|
||||
Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false`). Confirm `object_info` lists `UnetLoaderGGUF` and the three filenames.
|
||||
|
||||
## App
|
||||
|
||||
- Engine key: `qwen21` · UI label: **Qwen 2.1**
|
||||
- **Generate** (T2I) and **Edit** (same checkpoint, second graph). Compose / Iterate / Video / Extend / Music stay disabled unless a graph exists.
|
||||
- Edit requires Start still → `images.image_1`; prompt slots use `<image1>` / `<image2>`. If the user omits `<image1>`, the runner prepends `Keep the subject in <image1>.`
|
||||
- Face / outfit locks stay Klein semantics — they do not drive Qwen slots.
|
||||
- Sampler defaults: euler / simple / cfg **1** / steps **25**. Edit uses `QwenImage21Cache` (device auto, dtype int8). T2I uses `ModelSamplingAuraFlow` shift **3.1**.
|
||||
- Default canvas **1024×1024** (aspect 16:9 / 9:16 / 1:1 → long-edge ~1024 via `TextEncodeQwenImage21` resolution; multiples of 32). Edit with aspect `auto` follows `image_1` aspect at ~1024.
|
||||
- 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`.
|
||||
- Custom node: `ComfyUI-Qwen-Image-2.1-Prompt-Enhancer` (`QwenImage21_T2IPromptRewrite` / `QwenImage21_EditPromptRewrite`).
|
||||
|
||||
Graphs: `server/assets/studio2_qwen21_t2i.json`, `server/assets/studio2_qwen21_edit.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' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasHiddenThumbs"
|
||||
v-if="showPrivacyToggles && entries.length"
|
||||
type="button"
|
||||
class="rounded-full border px-2.5 py-1 text-[11px]"
|
||||
:class="revealAll ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||||
:title="revealAll ? 'Hide flagged thumbnails' : 'Reveal all hidden thumbnails'"
|
||||
:title="revealAll ? 'Hide all thumbnails' : 'Reveal all thumbnails'"
|
||||
@click="toggleRevealAll"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
@@ -324,7 +324,7 @@
|
||||
@click="focusPart(node.clip.id)"
|
||||
>
|
||||
<span class="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-zinc-900">
|
||||
<span v-if="node.clip.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
||||
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block h-full w-full" />
|
||||
<img
|
||||
v-else
|
||||
:src="partThumb(node.clip)"
|
||||
@@ -354,7 +354,7 @@
|
||||
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
|
||||
@click="focusPart(part.id)"
|
||||
>
|
||||
<span v-if="('hideThumbnail' in part && part.hideThumbnail) || ('hideInput' in part && part.hideInput)" class="sensitive-glow block aspect-square w-full" />
|
||||
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block aspect-square w-full" />
|
||||
<img
|
||||
v-else
|
||||
:src="partThumb(part)"
|
||||
@@ -828,24 +828,18 @@ function cardClass(entry: LibraryEntry) {
|
||||
return selectMode.value ? 'border-amber-300/25' : 'border-white/10'
|
||||
}
|
||||
|
||||
function itemHidden(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
|
||||
if (entry.kind === 'track') return false
|
||||
return Boolean(entry.stillGroup?.parts[cardImageIndex(entry)]?.hideInput)
|
||||
}
|
||||
|
||||
function thumbHidden(entry: LibraryEntry) {
|
||||
if (!showPrivacyToggles.value || entry.kind === 'track') return false
|
||||
const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.parts[cardImageIndex(entry)]?.id
|
||||
if (id && forceHiddenIds.value.includes(id)) return true
|
||||
return itemHidden(entry) && !revealAll.value
|
||||
// Session cover for the whole grid — not limited to individually flagged items.
|
||||
return !revealAll.value
|
||||
}
|
||||
|
||||
const hasHiddenThumbs = computed(() => entries.value.some(entry => itemHidden(entry)))
|
||||
|
||||
function toggleRevealAll() {
|
||||
revealAll.value = !revealAll.value
|
||||
forceHiddenIds.value = []
|
||||
if (focused.value && itemHidden(focused.value)) {
|
||||
if (focused.value && focused.value.kind !== 'track') {
|
||||
detailsHidden.value = !revealAll.value
|
||||
}
|
||||
}
|
||||
@@ -996,7 +990,7 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) {
|
||||
: entry.kind === 'track'
|
||||
? (entry.track?.id || '')
|
||||
: (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '')
|
||||
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value
|
||||
if (!sameCard) detailsHidden.value = thumbHidden(entry)
|
||||
cancelRename()
|
||||
}
|
||||
|
||||
|
||||
@@ -961,8 +961,8 @@ async function loadLog(entry: GenerationLogEntry) {
|
||||
kind: entry.kind,
|
||||
payload: entry.payload
|
||||
}))
|
||||
if (entry.kind === 'music') {
|
||||
await navigateTo('/music')
|
||||
if (entry.payload?.studio2Id || entry.payload?.studio2Request || entry.kind === 'music') {
|
||||
await navigateTo(entry.kind === 'music' ? '/studio-2?task=music' : '/studio-2')
|
||||
} else {
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
@@ -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 { saveRecord, readRecord } from '../../utils/studio2/store';
|
||||
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue';
|
||||
import { isXaigenStudio } from '~/utils/videoModels';
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event), raw = await readBody(event);
|
||||
const xaigen = isXaigenStudio();
|
||||
let request: any;
|
||||
try {
|
||||
request = validateRequest(raw, useRuntimeConfig().public.studio === 'xaigen');
|
||||
request = validateRequest(raw, xaigen);
|
||||
}
|
||||
catch (e: any) {
|
||||
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
|
||||
}
|
||||
const video = ['video', 'extend'].includes(request.mode);
|
||||
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
|
||||
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen');
|
||||
if (!video)
|
||||
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, xaigen);
|
||||
if (!video && request.engine !== 'qwen21')
|
||||
assertImageV2LoraStack(request.settings.loraStack, request.engine);
|
||||
assertFolderAccess(event, request.folderId);
|
||||
for (const id of [request.identityStillId, request.imageAId, request.imageBId, request.maskId, request.endStillId, ...request.guides.map((g: any) => g.stillId)].filter(Boolean))
|
||||
@@ -26,7 +28,7 @@ export default defineEventHandler(async (event) => {
|
||||
await resolveRequestSize(owner,request);
|
||||
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
|
||||
saveRecord(record);
|
||||
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
|
||||
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, studio2Request: structuredClone(request), prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
|
||||
saveRecord({ ...readRecord(owner, id), queueId: row.id });
|
||||
kickStudioQueue();
|
||||
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
|
||||
|
||||
@@ -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,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,75 @@
|
||||
{
|
||||
"4": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||
},
|
||||
"class_type": "UnetLoaderGGUF",
|
||||
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"shift": 3.1,
|
||||
"model": ["4", 0]
|
||||
},
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"_meta": { "title": "Qwen 2.1 shift" }
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"clip": ["5", 0],
|
||||
"prompt": "",
|
||||
"negative_prompt": "",
|
||||
"resolution": 1024
|
||||
},
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"_meta": { "title": "Text Encode Qwen Image 2.1" }
|
||||
},
|
||||
"15": {
|
||||
"inputs": {
|
||||
"seed": 1,
|
||||
"steps": 25,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": ["3", 0],
|
||||
"positive": ["9", 0],
|
||||
"negative": ["9", 1],
|
||||
"latent_image": ["9", 2]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"20": {
|
||||
"inputs": {
|
||||
"samples": ["15", 0],
|
||||
"vae": ["6", 0]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"21": {
|
||||
"inputs": {
|
||||
"filename_prefix": "studio2-qwen21",
|
||||
"images": ["20", 0]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": { "title": "Save Image" }
|
||||
}
|
||||
}
|
||||
@@ -139,11 +139,44 @@ export async function updateGenerationLogByStudioJob(
|
||||
export async function requeueGenerationLog(owner: string, id: string) {
|
||||
const entry = getGenerationLogEntry(owner, id)
|
||||
const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue')
|
||||
let payload = structuredClone(entry.payload)
|
||||
if (payload?.studio2Id || payload?.studio2Request) {
|
||||
const { saveRecord, readRecord } = await import('~/server/utils/studio2/store')
|
||||
let request = payload.studio2Request
|
||||
if (!request && payload.studio2Id) {
|
||||
try { request = structuredClone(readRecord(owner, payload.studio2Id).request) }
|
||||
catch { request = null }
|
||||
}
|
||||
if (!request) throw createError({ statusCode: 400, statusMessage: 'That Studio 2 job has no saved request to re-queue.' })
|
||||
const studio2Id = crypto.randomUUID()
|
||||
saveRecord({
|
||||
id: studio2Id,
|
||||
owner,
|
||||
request: structuredClone(request),
|
||||
state: 'waiting',
|
||||
queuedAt: Date.now(),
|
||||
familyId: crypto.randomUUID(),
|
||||
outputs: [],
|
||||
purgeResult: 'Not yet saved'
|
||||
})
|
||||
payload = {
|
||||
...payload,
|
||||
...structuredClone(request.settings || {}),
|
||||
studio2Id,
|
||||
studio2Request: structuredClone(request),
|
||||
prompt: request.compiledPrompt || payload.prompt,
|
||||
folderId: request.folderId || payload.folderId,
|
||||
extensions: [],
|
||||
referenceStillIds: [],
|
||||
useIdentityRefs: false,
|
||||
queueAutoRun: true
|
||||
}
|
||||
}
|
||||
const studio = await addStudioJob({
|
||||
ownerKey: owner,
|
||||
familyId: crypto.randomUUID(),
|
||||
kind: entry.kind,
|
||||
payload: structuredClone(entry.payload)
|
||||
payload
|
||||
})
|
||||
await kickStudioQueue()
|
||||
return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) }
|
||||
|
||||
@@ -8,13 +8,18 @@ import { queueSeeds } from '~/shared/studio2/seed.mjs';
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import template from '../../assets/studio2_minimax_native.json';
|
||||
import qwen21Template from '../../assets/studio2_qwen21_t2i.json'
|
||||
import qwen21EditTemplate from '../../assets/studio2_qwen21_edit.json'
|
||||
import qwen21PeT2iTemplate from '../../assets/studio2_qwen21_pe_t2i.json'
|
||||
import qwen21PeEditTemplate from '../../assets/studio2_qwen21_pe_edit.json';
|
||||
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
||||
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
||||
import { resolveQwen21Size } from '~/shared/studio2/input.mjs';
|
||||
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
||||
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
||||
import { readRecord, saveRecord, studio2Root, records } from './store';
|
||||
import { ensureComfyReady } from '../comfyLifecycle';
|
||||
import { comfyFetch, queuePrompt, fetchHistory, extractVideo } from '../comfy';
|
||||
import { comfyFetch, queuePrompt, fetchHistory, extractVideo, freeComfyVram } from '../comfy';
|
||||
import { extractEditedImage } from '../imageComfy';
|
||||
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
|
||||
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
|
||||
@@ -34,10 +39,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
|
||||
// extracted last frame). Fitting these every hop is what destroys identity across chained extends.
|
||||
const NEVER_FIT = new Set(['hero', 'start']);
|
||||
|
||||
function previewAnyText(outputs: Record<string, any> | undefined, nodeId: string): string {
|
||||
const node = outputs?.[nodeId]
|
||||
if (!node || typeof node !== 'object') return ''
|
||||
const raw = node.text ?? node.STRING ?? node.source
|
||||
if (Array.isArray(raw)) return String(raw[0] ?? '')
|
||||
if (raw == null) return ''
|
||||
return String(raw)
|
||||
}
|
||||
|
||||
function harvestQwen21Pe(history: Record<string, any> | null | undefined, promptId: string, edit: boolean) {
|
||||
const outputs = history?.[promptId]?.outputs as Record<string, any> | undefined
|
||||
const positive_prompt = previewAnyText(outputs, '30').trim()
|
||||
const wh_ratio = previewAnyText(outputs, '31').trim()
|
||||
const ratio_follow = edit ? previewAnyText(outputs, '32').trim() : ''
|
||||
const thinking = previewAnyText(outputs, edit ? '33' : '32').trim()
|
||||
const parseRaw = previewAnyText(outputs, edit ? '34' : '33').trim()
|
||||
const parse_ok = /^(true|1|yes)$/i.test(parseRaw)
|
||||
return { positive_prompt, wh_ratio, ratio_follow, thinking, parse_ok }
|
||||
}
|
||||
|
||||
async function waitPromptHistory(r: any, job: Job, promptId: string) {
|
||||
let history: any = null, missingSince = 0
|
||||
for (;;) {
|
||||
try { history = await fetchHistory(promptId) }
|
||||
catch { history = null }
|
||||
const e = history?.[promptId]
|
||||
if (job.status === 'cancelled') throw new Error('Cancelled')
|
||||
if (['error', 'interrupted'].includes(e?.status?.status_str))
|
||||
throw new Error('Comfy generation failed or was interrupted; inspect the host error log.')
|
||||
if (!e) {
|
||||
try {
|
||||
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) })
|
||||
if (response.ok) {
|
||||
const q = await response.json() as any
|
||||
const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === promptId)
|
||||
if (present) missingSince = 0
|
||||
else if (!missingSince) missingSince = Date.now()
|
||||
else if (Date.now() - missingSince > 60000) throw new Error('PROMPT_MISSING')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.message === 'PROMPT_MISSING')
|
||||
throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.')
|
||||
}
|
||||
}
|
||||
if (e?.status?.completed || e?.status?.status_str === 'success') break
|
||||
if (job.status === 'cancelled') throw new Error('Cancelled')
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
}
|
||||
return history
|
||||
}
|
||||
|
||||
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> {
|
||||
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
|
||||
let fitted = false;
|
||||
if (!NEVER_FIT.has(name) && needsFit(data, r.request.settings.width, r.request.settings.height)) {
|
||||
if (!NEVER_FIT.has(name) && r.request?.engine !== 'qwen21' && needsFit(data, r.request.settings.width, r.request.settings.height)) {
|
||||
data = await fitStill(data, r.request.settings.width, r.request.settings.height);
|
||||
fitted = true;
|
||||
}
|
||||
@@ -126,6 +232,66 @@ async function prepareGraph(r: any) {
|
||||
r.graphId = 'studio2_minimax_native.json';
|
||||
}
|
||||
}
|
||||
else if (q.engine === 'qwen21') {
|
||||
if (!['generate', 'edit'].includes(q.mode))
|
||||
throw new Error('Qwen 2.1 supports Generate and Edit.')
|
||||
if (q.mode === 'edit') {
|
||||
if (!a) throw new Error('Choose the source still.')
|
||||
let prompt = String(q.compiledPrompt || '')
|
||||
if (!/<image1>/i.test(prompt))
|
||||
prompt = `Keep the subject in <image1>. ${prompt}`.trim()
|
||||
graph = structuredClone(qwen21EditTemplate)
|
||||
graph['10'].inputs.image = a
|
||||
if (b) {
|
||||
graph['11'] = {
|
||||
inputs: { image: b },
|
||||
class_type: 'LoadImage',
|
||||
_meta: { title: 'image_2 optional' }
|
||||
}
|
||||
graph['9'].inputs['images.image_2'] = ['11', 0]
|
||||
}
|
||||
graph['9'].inputs.prompt = prompt
|
||||
graph['9'].inputs.negative_prompt = stylePrompt(q.imageStyles, true)
|
||||
graph['9'].inputs.resolution = 1024
|
||||
if (s.aspect && s.aspect !== 'auto') {
|
||||
graph['16'] = {
|
||||
inputs: { width: s.width, height: s.height, batch_size: 1 },
|
||||
class_type: 'EmptyLatentImage',
|
||||
_meta: { title: 'Qwen edit canvas' }
|
||||
}
|
||||
graph['15'].inputs.latent_image = ['16', 0]
|
||||
}
|
||||
graph['15'].inputs.seed = s.seed
|
||||
graph['15'].inputs.steps = s.steps || 25
|
||||
graph['15'].inputs.cfg = s.cfg ?? 1
|
||||
graph['15'].inputs.sampler_name = 'euler'
|
||||
graph['15'].inputs.scheduler = 'simple'
|
||||
graph['21'].inputs.filename_prefix = prefix + '/image'
|
||||
r.sampleLatent = s.aspect && s.aspect !== 'auto' ? 'qwen21 empty latent' : 'qwen21 edit latent'
|
||||
r.sampleDenoise = null
|
||||
r.heroReferenceAttached = false
|
||||
r.graphId = 'studio2_qwen21_edit.json'
|
||||
} else {
|
||||
const size = resolveQwen21Size(s.aspect)
|
||||
s.width = size.width
|
||||
s.height = size.height
|
||||
graph = structuredClone(qwen21Template)
|
||||
graph['9'].inputs.prompt = q.compiledPrompt
|
||||
graph['9'].inputs.negative_prompt = stylePrompt(q.imageStyles, true)
|
||||
// TextEncodeQwenImage21 builds the 64-ch empty latent from resolution (square T2I).
|
||||
graph['9'].inputs.resolution = Math.max(size.width, size.height)
|
||||
graph['15'].inputs.seed = s.seed
|
||||
graph['15'].inputs.steps = s.steps || 25
|
||||
graph['15'].inputs.cfg = s.cfg ?? 1
|
||||
graph['15'].inputs.sampler_name = 'euler'
|
||||
graph['15'].inputs.scheduler = 'simple'
|
||||
graph['21'].inputs.filename_prefix = prefix + '/image'
|
||||
r.sampleLatent = 'qwen21 textencode latent'
|
||||
r.sampleDenoise = null
|
||||
r.heroReferenceAttached = false
|
||||
r.graphId = 'studio2_qwen21_t2i.json'
|
||||
}
|
||||
}
|
||||
else {
|
||||
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
||||
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
||||
@@ -171,56 +337,23 @@ async function run(r: any, job: Job) {
|
||||
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
|
||||
update(r, 'waking');
|
||||
await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
|
||||
if (job.status === 'cancelled')
|
||||
throw new Error('Cancelled');
|
||||
await runQwen21PromptEnhance(r, job);
|
||||
if (job.status === 'cancelled')
|
||||
throw new Error('Cancelled');
|
||||
const graph = await prepareGraph(r);
|
||||
if (job.status === 'cancelled')
|
||||
throw new Error('Cancelled');
|
||||
update(r, 'submitting');
|
||||
emitJob(job, { type: 'status', message: 'Generating…' });
|
||||
const queued = await queuePrompt(graph, job.clientId);
|
||||
job.promptId = queued.prompt_id;
|
||||
r.promptId = queued.prompt_id;
|
||||
r.renderStartedAt = Date.now();
|
||||
update(r, 'rendering');
|
||||
}
|
||||
let history: any, missingSince = 0;
|
||||
for (;;) {
|
||||
try {
|
||||
history = await fetchHistory(r.promptId);
|
||||
}
|
||||
catch {
|
||||
history = null;
|
||||
}
|
||||
const e = history?.[r.promptId];
|
||||
if (job.status === 'cancelled')
|
||||
throw new Error('Cancelled');
|
||||
if (['error', 'interrupted'].includes(e?.status?.status_str))
|
||||
throw new Error('Comfy generation failed or was interrupted; inspect the host error log.');
|
||||
if (!e) {
|
||||
try {
|
||||
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) });
|
||||
if (response.ok) {
|
||||
const q = await response.json() as any;
|
||||
const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === r.promptId);
|
||||
if (present)
|
||||
missingSince = 0;
|
||||
else if (!missingSince)
|
||||
missingSince = Date.now();
|
||||
else if (Date.now() - missingSince > 60000)
|
||||
throw new Error('PROMPT_MISSING');
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
if (error.message === 'PROMPT_MISSING')
|
||||
throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.');
|
||||
}
|
||||
}
|
||||
if (e?.status?.completed || e?.status?.status_str === 'success')
|
||||
break;
|
||||
if (job.status === 'cancelled')
|
||||
throw new Error('Cancelled');
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
}
|
||||
const history = await waitPromptHistory(r, job, r.promptId);
|
||||
const messages = history[r.promptId]?.status?.messages || [];
|
||||
const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp;
|
||||
const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp;
|
||||
|
||||
@@ -21,5 +21,5 @@ export async function resolveRequestSize(owner: string, request: any) {
|
||||
}
|
||||
}
|
||||
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
|
||||
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions))
|
||||
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions,undefined,request.engine))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export type StudioJobKind = 'video' | 'edit' | 'music'
|
||||
|
||||
export interface StudioJobPayload {
|
||||
studio2Id?: string
|
||||
/** Full Studio 2 validateRequest snapshot for Load / Re-queue. */
|
||||
studio2Request?: Record<string, any>
|
||||
upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string }
|
||||
prompt: string
|
||||
promptMid?: string
|
||||
|
||||
@@ -13,7 +13,7 @@ export function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
|
||||
export function compilePrompt(value, video = false, locks = {}) {
|
||||
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
|
||||
const hasImage=!!(locks.imageAId || locks.identityStillId)
|
||||
const attached=hasImage && locks.engine !== 'krea'
|
||||
const attached=hasImage && locks.engine !== 'krea' && locks.engine !== 'qwen21'
|
||||
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
|
||||
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
|
||||
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
|
||||
@@ -31,23 +31,29 @@ export function validateRequest(raw, xaigen = false) {
|
||||
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
||||
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
||||
const video = ['video','extend'].includes(mode)
|
||||
if (!(video ? ['minimax','ltx'] : ['flux','krea']).includes(engine)) fail('Engine does not support this task.')
|
||||
if (!(video ? ['minimax','ltx'] : ['flux','krea','qwen21']).includes(engine)) fail('Engine does not support this task.')
|
||||
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
||||
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
||||
if (engine === 'qwen21' && !['generate', 'edit'].includes(mode)) fail('Qwen 2.1 supports Generate and Edit.')
|
||||
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
||||
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
||||
if (!raw.folderId) fail('Choose a folder.')
|
||||
if ((video || ['edit','compose','iterate'].includes(mode)) && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
|
||||
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) fail('Choose the source still.')
|
||||
if ((video || ['edit','compose','iterate'].includes(mode)) && engine !== 'qwen21' && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
|
||||
if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
|
||||
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
|
||||
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) fail('Choose the source still.')
|
||||
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip or Previous clip.')
|
||||
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
|
||||
const promptSections = video ? allSections : {action:allSections.action}
|
||||
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
||||
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
||||
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
|
||||
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
||||
const qwen = engine === 'qwen21'
|
||||
const defaultSteps = video ? 8 : qwen ? 25 : 24
|
||||
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
|
||||
const defaultWidth = qwen ? 1024 : 1344
|
||||
const defaultHeight = qwen ? 1024 : 768
|
||||
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?defaultSteps:1,0,2147483647))) fail(`Invalid ${key}.`)
|
||||
for (const key of ['width','height']) if (number(key,key==='width'?defaultWidth:defaultHeight,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
||||
const duration=number('duration',5,1,15)
|
||||
if (video) {
|
||||
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
||||
@@ -55,10 +61,11 @@ export function validateRequest(raw, xaigen = false) {
|
||||
}
|
||||
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
||||
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||
const enhancePrompt = qwen && !video && !!raw.enhancePrompt
|
||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, enhancePrompt, lockFace:qwen ? false : raw.lockFace !== false, lockOutfit:qwen ? false : raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:qwen ? '' : (raw.identityStillId || ''), imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
|
||||
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
|
||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
|
||||
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',defaultWidth,256,4096),height:number('height',defaultHeight,256,4096),steps:number('steps',defaultSteps,1,100),cfg:number('cfg',defaultCfg,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:qwen?[]:(Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[]) },
|
||||
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
|
||||
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
||||
import { restoreInput } from './contracts.mjs'
|
||||
import { restoreInput, sectionsFrom } from './contracts.mjs'
|
||||
import { normalizeStyles } from './styles.mjs'
|
||||
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
|
||||
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) {
|
||||
export function resolveQwen21Size(aspect, source) {
|
||||
const presets = {'16:9':[1024,576],'9:16':[576,1024],'1:1':[1024,1024]}
|
||||
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
||||
if (source?.width && source?.height) {
|
||||
const ratio = source.width / source.height
|
||||
const width = Math.max(32, Math.round(Math.sqrt(1024 * 1024 * ratio) / 32) * 32)
|
||||
const height = Math.max(32, Math.round(Math.sqrt(1024 * 1024 / ratio) / 32) * 32)
|
||||
return {width, height}
|
||||
}
|
||||
return {width:1024,height:1024}
|
||||
}
|
||||
export function resolveSize(aspect, source, fallback = {width:1344,height:768}, engine = '') {
|
||||
if (engine === 'qwen21') return resolveQwen21Size(aspect, source)
|
||||
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
||||
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
||||
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
|
||||
@@ -35,7 +48,7 @@ export function restoreAsset(form, item, xaigen = false, memory) {
|
||||
const restored = restoreInput(item)
|
||||
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
|
||||
next.settings.seedMode='random'
|
||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
||||
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
|
||||
next.imageAId=item.id
|
||||
next.startClipId=''
|
||||
@@ -58,11 +71,76 @@ export function changeSource(form) {
|
||||
|
||||
export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
|
||||
export function useAsSource(form, item) { return {...form,imageAId:item.id} }
|
||||
|
||||
/** Hydrate the live bench from a saved Studio 2 request (Load). Does not enqueue. */
|
||||
export function hydrateStudio2Job(form, saved, { stillIds = new Set(), clipIds = new Set(), xaigen = false, memory } = {}) {
|
||||
const src = structuredClone(saved?.studio2 || saved || {})
|
||||
const missing = []
|
||||
const keepStill = (id, label) => {
|
||||
const value = id || ''
|
||||
if (!value) return ''
|
||||
if (stillIds.has(value)) return value
|
||||
missing.push(`${label} missing from library`)
|
||||
return ''
|
||||
}
|
||||
const keepClip = (id, label) => {
|
||||
const value = id || ''
|
||||
if (!value) return ''
|
||||
if (clipIds.has(value)) return value
|
||||
missing.push(`${label} missing from library`)
|
||||
return ''
|
||||
}
|
||||
const modeRaw = src.mode === 'iterate' ? 'edit' : src.mode
|
||||
const mode = ['generate','edit','compose','refine','video','extend','music'].includes(modeRaw) ? modeRaw : 'edit'
|
||||
let engine = src.engine === 'qwen21' ? 'qwen21' : src.engine === 'krea' ? 'krea' : src.engine === 'ltx' ? 'ltx' : src.engine === 'minimax' ? 'minimax' : 'flux'
|
||||
if (['video','extend'].includes(mode)) engine = xaigen && engine === 'ltx' ? 'ltx' : 'minimax'
|
||||
else if (engine === 'minimax' || engine === 'ltx') engine = 'flux'
|
||||
const settings = structuredClone(src.settings || {})
|
||||
const aspect = ['auto','16:9','9:16','1:1'].includes(settings.aspect) ? settings.aspect : 'auto'
|
||||
const loraStack = Array.isArray(settings.loraStack) ? settings.loraStack.map(item => ({
|
||||
name: item.name,
|
||||
strengthModel: Number(item.strengthModel ?? item.strength ?? 1),
|
||||
strengthClip: Number(item.strengthClip ?? item.strengthModel ?? item.strength ?? 1)
|
||||
})) : []
|
||||
const guides = (Array.isArray(src.guides) ? src.guides : []).map(g => {
|
||||
const stillId = keepStill(g.stillId, 'guide still')
|
||||
return stillId ? { stillId, frame: g.frame } : null
|
||||
}).filter(Boolean)
|
||||
const next = {
|
||||
...form,
|
||||
mode,
|
||||
engine,
|
||||
enhancePrompt: engine === 'qwen21' && !!src.enhancePrompt,
|
||||
lockFace: src.lockFace !== false,
|
||||
lockOutfit: src.lockOutfit !== false,
|
||||
hideInput: !!src.hideInput,
|
||||
folderId: src.folderId || form.folderId,
|
||||
identityStillId: keepStill(src.identityStillId, 'hero'),
|
||||
imageAId: keepStill(src.imageAId, 'start'),
|
||||
imageBId: keepStill(src.imageBId, 'second still'),
|
||||
maskId: keepStill(src.maskId, 'mask'),
|
||||
startClipId: keepClip(src.startClipId, 'start clip'),
|
||||
endStillId: keepStill(src.endStillId, 'end still'),
|
||||
guides,
|
||||
imageStyles: normalizeStyles(src.imageStyles || { positive: [], negative: [] }),
|
||||
promptSections: sectionsFrom(src.promptSections || { action: src.compiledPrompt || src.prompt || '' }),
|
||||
settings: {
|
||||
...form.settings,
|
||||
...settings,
|
||||
aspect,
|
||||
seed: settings.seed ?? form.settings?.seed ?? null,
|
||||
seedMode: 'locked',
|
||||
loraStack
|
||||
}
|
||||
}
|
||||
return { form: restoreLoras(next, loraStack, xaigen, memory), missing: missing[0] || '', warnings: missing }
|
||||
}
|
||||
|
||||
export function restoreAll(form,item,xaigen=false,memory) {
|
||||
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
|
||||
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
|
||||
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
|
||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
||||
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||
next.imageAId=item.id
|
||||
next.startClipId=''
|
||||
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
|
||||
|
||||
@@ -2,7 +2,9 @@ import {filterLoraStackForUse} from '../../utils/loras.ts'
|
||||
|
||||
export function loraUse(form) {
|
||||
const video=['video','extend'].includes(form.mode)
|
||||
return video ? (form.engine==='ltx'?'ltx':'minimax') : (form.engine==='krea'?'krea':'flux')
|
||||
if (video) return form.engine==='ltx'?'ltx':'minimax'
|
||||
if (form.engine==='qwen21') return 'qwen21'
|
||||
return form.engine==='krea'?'krea':'flux'
|
||||
}
|
||||
|
||||
// One controller per bench. Storage is optional (SSR/private browsing still works).
|
||||
|
||||
@@ -8,8 +8,8 @@ export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
|
||||
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'}
|
||||
const job=jobs.find(j=>['waking','submitting','rendering','saving'].includes(j.state))
|
||||
if(job)return {label:{waking:'Waking up',submitting:'Submitting',rendering:'Generating',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
|
||||
const job=jobs.find(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state))
|
||||
if(job)return {label:{waking:'Waking up',enhancing:'Enhancing prompt…',submitting:'Submitting',rendering:'Generating…',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
|
||||
if(health.comfy?.busy || health.imageComfy?.busy)return {label:'Busy',tone:'busy',detail:'GPU is working on another job'}
|
||||
if(health.comfy?.ok || health.imageComfy?.ok)return {label:'Ready',tone:'ready',detail:'No job running'}
|
||||
if(health.comfy?.asleep)return {label:'Sleeping',tone:'sleeping',detail:'Wakes when a job starts'}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
export const styleGroups={Render:['Photorealistic','Pixar','3D Disney','Comic','Watercolor','Oil painting'],Quality:['Realistic skin','Skin pores','Fine detail','Natural texture'],Lighting:['Golden hour','Soft studio','Window light','Overcast','Cinematic lighting']}
|
||||
export function normalizeStyles(value={}) { const known=Object.values(styleGroups).flat();const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))];return {positive,negative:[...new Set((value.negative||[]).filter(s=>known.includes(s)&&!positive.includes(s)))]} }
|
||||
export const defaultNegatives=['blurry','deformed','low quality','text','watermark']
|
||||
export function normalizeStyles(value={}) {
|
||||
const known=[...Object.values(styleGroups).flat(),...defaultNegatives]
|
||||
const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))]
|
||||
// Freeform negatives are allowed (sticky custom chips); positives stay on the known style list.
|
||||
const negative=[...new Set((value.negative||[]).map(s=>String(s||'').trim()).filter(s=>s&&!positive.includes(s)))]
|
||||
return {positive,negative}
|
||||
}
|
||||
export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next }
|
||||
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
|
||||
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
||||
import {readFileSync} from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
|
||||
import {useAsHero,useAsSource,restoreAll} from '../shared/studio2/input.mjs'
|
||||
import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs'
|
||||
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
|
||||
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
||||
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
|
||||
@@ -28,6 +28,8 @@ test('hidden xAIGen canvas and family render no image or video elements',async()
|
||||
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
||||
assert.match(src,/aria-label="Current family outputs"/)
|
||||
assert.match(src,/preview hidden/)
|
||||
assert.match(src,/Reveal all/)
|
||||
assert.match(src,/pickerRevealAll/)
|
||||
assert.doesNotMatch(src,/Hide input stills/)
|
||||
assert.doesNotMatch(src,/Hide output preview/)
|
||||
})
|
||||
@@ -74,3 +76,44 @@ test('image chips compile positive and negative independently; video chips do no
|
||||
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
||||
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
||||
})
|
||||
test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
|
||||
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})
|
||||
assert.equal(q.engine,'qwen21');assert.equal(q.settings.steps,25);assert.equal(q.settings.cfg,1)
|
||||
assert.equal(q.settings.width,1024);assert.equal(q.settings.height,1024)
|
||||
assert.equal(q.lockFace,false);assert.equal(q.lockOutfit,false);assert.deepEqual(q.settings.loraStack,[])
|
||||
assert.equal(q.enhancePrompt,false)
|
||||
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,promptSections:{action:'x'},settings:{aspect:'1:1'}}).enhancePrompt,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/)
|
||||
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/)
|
||||
})
|
||||
|
||||
@@ -283,7 +283,7 @@ export function kleinLoraOnKreaMessage(name: string) {
|
||||
return imageLoraEngineMismatchMessage(name, 'krea')
|
||||
}
|
||||
|
||||
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea'
|
||||
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea' | 'qwen21'
|
||||
|
||||
export function isSystemGraphLora(name: string) {
|
||||
const id = loraIdentityKey(name)
|
||||
@@ -301,16 +301,19 @@ export function inferLoraUse(name: string): LoraUse[] | null {
|
||||
if (/ltx/.test(value)) uses.push('ltx')
|
||||
if (/klein|flux/.test(value)) uses.push('flux')
|
||||
if (/krea/.test(value)) uses.push('krea')
|
||||
if (/qwen/.test(value)) uses.push('qwen21')
|
||||
return uses.length ? uses : null
|
||||
}
|
||||
|
||||
export function loraMatchesUse(name: string, use: LoraUse) {
|
||||
if (use === 'qwen21') return false
|
||||
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
|
||||
}
|
||||
|
||||
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
|
||||
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
|
||||
if (use === 'ltx' && !xaigen) return []
|
||||
if (use === 'qwen21') return []
|
||||
return filterLoraStackForStudio(stack, xaigen).filter(item => {
|
||||
const uses = inferLoraUse(item.name)
|
||||
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
|
||||
@@ -318,6 +321,7 @@ export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: bool
|
||||
}
|
||||
|
||||
export function filterLorasForUse(names: string[], use: LoraUse) {
|
||||
if (use === 'qwen21') return []
|
||||
return names.filter(name => loraMatchesUse(name, use))
|
||||
}
|
||||
|
||||
|
||||