Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b311d6529 | ||
|
|
1f10082087 | ||
|
|
4e61ee17b6 | ||
|
|
b95e8b2a87 | ||
|
|
277b49403c | ||
|
|
4831b32d20 |
@@ -1,97 +0,0 @@
|
||||
# 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.
|
||||
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
@@ -1,149 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 446 B |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 308 B |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 302 B |
@@ -1,56 +0,0 @@
|
||||
/* 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,693 +1,118 @@
|
||||
/* Pixel grid tokens — Classic | Studio. Match TARGET-classic-layout.png */
|
||||
/* Studio 2 display skins — Classic | Studio. Layout tokens only; palette matches main.css. */
|
||||
|
||||
:root,
|
||||
[data-display='classic'],
|
||||
[data-display='studio'] {
|
||||
--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;
|
||||
--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);
|
||||
}
|
||||
|
||||
[data-display='classic'] {
|
||||
--field-gap: 8px;
|
||||
--type: 13px;
|
||||
--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;
|
||||
}
|
||||
|
||||
[data-display] {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: var(--type);
|
||||
[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;
|
||||
}
|
||||
|
||||
/* 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-title {
|
||||
font-size: var(--display-type-title);
|
||||
}
|
||||
|
||||
.bench-root {
|
||||
[data-display] .display-body {
|
||||
font-size: var(--display-type-body);
|
||||
}
|
||||
|
||||
[data-display] .display-stack {
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
gap: var(--chrome-gap);
|
||||
}
|
||||
|
||||
.bench-root[data-mobile="1"] {
|
||||
position: relative;
|
||||
[data-display] .display-card {
|
||||
border-radius: var(--display-radius);
|
||||
border: 1px solid var(--display-border);
|
||||
padding: var(--card-pad);
|
||||
}
|
||||
|
||||
.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-viewer {
|
||||
border-radius: var(--display-viewer-radius);
|
||||
}
|
||||
|
||||
.bench-brand {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.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;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bench-header-right {
|
||||
[data-display] .display-rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bench-nav {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: var(--type-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 0.5rem;
|
||||
min-height: var(--rail-h);
|
||||
}
|
||||
|
||||
.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;
|
||||
[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);
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.rail-model .seg { height: 32px; font-weight: 500; }
|
||||
|
||||
.seg[aria-pressed='true'] {
|
||||
border-color: var(--amber);
|
||||
background: var(--amber);
|
||||
color: var(--amber-on);
|
||||
[data-display] .display-rail__seg:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--display-focus);
|
||||
}
|
||||
|
||||
.seg:disabled {
|
||||
opacity: 0.45;
|
||||
[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);
|
||||
}
|
||||
|
||||
[data-display] .display-rail__seg:disabled,
|
||||
[data-display] .display-rail__seg[aria-disabled='true'] {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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;
|
||||
[data-display='studio'] .display-rail__seg {
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
padding: 0.625rem 1.25rem;
|
||||
}
|
||||
|
||||
.job-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
[data-display='classic'] .display-rail__seg {
|
||||
min-height: 2.25rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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 {
|
||||
border-radius: var(--display-radius);
|
||||
background: var(--display-amber);
|
||||
color: var(--display-amber-fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.job-status .left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
[data-display] .display-cta:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--display-focus);
|
||||
}
|
||||
|
||||
.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);
|
||||
[data-display] .display-control {
|
||||
border-radius: var(--display-radius);
|
||||
}
|
||||
|
||||
@@ -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, dropdowns</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">Packed header, compact selects, tight gaps.</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 rail, model rail, slots + eyes</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">Task and model rails, locks by slots, eye toggles.</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {assetLabel} from '~/shared/studio2/viewer.mjs'
|
||||
withDefaults(defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean;size?:'hero'|'slot'}>(),{size:'slot'})
|
||||
defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean}>()
|
||||
defineEmits(['pick','upload'])
|
||||
</script>
|
||||
|
||||
@@ -19,17 +19,7 @@ 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:'Prompt (raw)',value:m.promptRaw || null},
|
||||
{label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'},
|
||||
{label:'Turbo',value:m.turbo==null?null:m.turbo?'On':'Off'},
|
||||
{label:'Turbo LoRA',value:m.lora || null},
|
||||
{label:'Turbo sigmas',value:m.sigmas || null},
|
||||
{label:'Enhance wh_ratio',value:m.enhance?.wh_ratio || null},
|
||||
{label:'Enhance ratio_follow',value:m.enhance?.ratio_follow || null},
|
||||
{label:'Enhance parse_ok',value:m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Yes':'No'},
|
||||
{label:'Enhance thinking',value:m.enhance?.thinking || null},
|
||||
{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
|
||||
{label:'Compiled prompt',value:m.compiledPrompt},{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>
|
||||
|
||||
@@ -1,585 +0,0 @@
|
||||
<template>
|
||||
<div class="m-shell" data-mobile="1">
|
||||
<header class="m-header">
|
||||
<span class="m-brand">{{ ctx.instanceName }}</span>
|
||||
<label class="m-crumb">
|
||||
<select v-model="ctx.form.folderId" aria-label="Folder">
|
||||
<option v-for="f in ctx.library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="m-grow" />
|
||||
<span class="m-gpu" :data-tone="ctx.gpu.tone" :title="ctx.gpu.detail">● {{ ctx.gpuChipLabel }}</span>
|
||||
<button type="button" class="m-chip" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
|
||||
<details class="m-more">
|
||||
<summary aria-label="More">⋯</summary>
|
||||
<div class="m-more-menu">
|
||||
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
|
||||
<NuxtLink to="/queue" @click="closeMenus">Queue</NuxtLink>
|
||||
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
|
||||
<div class="m-chips" role="tablist" aria-label="Tasks">
|
||||
<button
|
||||
v-for="task in ctx.tasks"
|
||||
:key="task"
|
||||
type="button"
|
||||
class="m-chip capitalize"
|
||||
:class="{ on: ctx.form.mode===task }"
|
||||
:aria-pressed="ctx.form.mode===task"
|
||||
@click="ctx.chooseTask(task)"
|
||||
>{{ task }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ctx.form.mode!=='music'" class="m-chips" role="group" aria-label="Model">
|
||||
<button
|
||||
v-for="opt in ctx.engineOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="m-chip"
|
||||
:class="{ on: ctx.form.engine===opt.value }"
|
||||
:disabled="opt.disabled"
|
||||
:title="opt.hint || undefined"
|
||||
:aria-pressed="ctx.form.engine===opt.value"
|
||||
@click="!opt.disabled && (ctx.form.engine=opt.value)"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
<div v-else class="m-chips"><span class="m-chip on">YuE2</span></div>
|
||||
|
||||
<div class="m-stage">
|
||||
<button
|
||||
v-if="ctx.selected && ctx.xaigen"
|
||||
type="button"
|
||||
class="m-eye"
|
||||
:aria-label="ctx.isHidden(ctx.selected) ? 'Show preview' : 'Hide preview'"
|
||||
@click.stop="ctx.toggleReveal(ctx.selected.id)"
|
||||
>
|
||||
<img :src="ctx.isHidden(ctx.selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
|
||||
</button>
|
||||
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
|
||||
<p class="m-muted">{{ ctx.selected.kind==='track' ? 'Track loaded · preview hidden' : 'Preview hidden' }}</p>
|
||||
</template>
|
||||
<template v-else-if="ctx.selected?.kind==='track'">
|
||||
<div class="m-audio">
|
||||
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
|
||||
</div>
|
||||
</template>
|
||||
<video
|
||||
v-else-if="ctx.selected?.kind==='clip'"
|
||||
:key="ctx.selected.id"
|
||||
:src="`/api/library/clips/${ctx.selected.id}/video`"
|
||||
controls
|
||||
playsinline
|
||||
/>
|
||||
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still">
|
||||
<p v-else class="m-muted">Select a still or clip</p>
|
||||
</div>
|
||||
|
||||
<div class="m-strip" aria-label="Family outputs">
|
||||
<div
|
||||
v-for="o in ctx.family"
|
||||
:key="o.id"
|
||||
class="m-tile"
|
||||
:class="{ on: ctx.selected?.id===o.id }"
|
||||
>
|
||||
<button type="button" class="m-tile-pick" @click="ctx.selectOutput(o)">
|
||||
<span v-if="ctx.isHidden(o)" class="m-tile-hid">Hidden</span>
|
||||
<span v-else-if="o.kind==='track'" class="m-tile-hid">Audio</span>
|
||||
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : ctx.stillUrl(o.id)" alt="">
|
||||
</button>
|
||||
<button
|
||||
v-if="ctx.xaigen"
|
||||
type="button"
|
||||
class="m-tile-eye"
|
||||
:aria-label="ctx.isHidden(o) ? 'Show tile' : 'Hide tile'"
|
||||
@click.stop="ctx.toggleReveal(o.id)"
|
||||
>
|
||||
<img :src="ctx.isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
|
||||
</button>
|
||||
<button
|
||||
v-if="ctx.selected?.id===o.id"
|
||||
type="button"
|
||||
class="m-tile-trash"
|
||||
aria-label="Delete selected"
|
||||
@click.stop="ctx.deleteOutput"
|
||||
>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ctx.selected && ctx.selected.kind!=='track'" class="m-strip-actions">
|
||||
<button type="button" @click="ctx.applyUseAsSource">Use as start</button>
|
||||
<button type="button" @click="ctx.applyUseAsHero">Use as hero</button>
|
||||
<button type="button" @click="ctx.copySelectedPrompt">Copy prompt</button>
|
||||
<button type="button" @click="ctx.loadSelectedJob">Load</button>
|
||||
<button type="button" @click="ctx.deleteOutput">Delete</button>
|
||||
</div>
|
||||
|
||||
<div class="m-dock">
|
||||
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Slots</button>
|
||||
<button type="button" :class="{ on: sheet==='prompt' }" @click="toggleSheet('prompt')">Prompt</button>
|
||||
<button type="button" :class="{ on: sheet==='look' }" @click="toggleSheet('look')">Look</button>
|
||||
<button type="button" :class="{ on: sheet==='gen' }" @click="toggleSheet('gen')">Gen</button>
|
||||
<button type="button" class="m-cta" :disabled="ctx.submitting" @click="ctx.queue">{{ ctx.submitting ? '…' : 'Generate' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Sheets -->
|
||||
<div v-if="sheet" class="m-sheet-backdrop" @click="sheet=''" />
|
||||
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Slots">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Slots</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<template v-if="ctx.form.mode==='music'">
|
||||
<p class="m-muted">Music uses tags + lyrics in Prompt / Gen — no hero slot.</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Studio2AssetSlot size="hero" :label="ctx.heroSlotLabel" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.identityStillId)" :hidden="ctx.slotHidden(ctx.form.identityStillId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker('identityStillId')" @upload="ctx.upload($event,'hero')" />
|
||||
<Studio2AssetSlot :label="ctx.sourceSlotLabel" :asset="ctx.sourceSlot" :hidden="ctx.slotHidden(ctx.form.mode==='extend'?ctx.form.startClipId:ctx.form.imageAId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker(ctx.form.mode==='extend'?'startClipId':'imageAId')" @upload="ctx.upload($event,'source')" />
|
||||
<p v-if="ctx.editSourceRequired && !ctx.form.imageAId" class="m-hint">Required for Edit</p>
|
||||
<p v-if="ctx.slotPairHint" class="m-hint">{{ ctx.slotPairHint }}</p>
|
||||
<Studio2AssetSlot v-if="ctx.form.mode==='compose'" label="Second still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.imageBId)" :hidden="ctx.slotHidden(ctx.form.imageBId)" @pick="ctx.setPicker('imageBId')" />
|
||||
<Studio2AssetSlot v-if="ctx.form.mode==='refine'" label="Mask" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.maskId)" :hidden="ctx.slotHidden(ctx.form.maskId)" @pick="ctx.setPicker('maskId')" />
|
||||
<Studio2AssetSlot v-if="ctx.video" label="End still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.endStillId)" :hidden="ctx.slotHidden(ctx.form.endStillId)" @pick="ctx.setPicker('endStillId')" />
|
||||
<label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label>
|
||||
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label>
|
||||
<p class="m-hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
||||
<p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-if="sheet==='prompt'" class="m-sheet" role="dialog" aria-label="Prompt">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Prompt</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<template v-if="ctx.form.mode==='music'">
|
||||
<label class="m-label">Genre / style tags<textarea v-model="ctx.music.tags" rows="3" class="m-area" /></label>
|
||||
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label>
|
||||
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label>
|
||||
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label>
|
||||
<p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
|
||||
<div>
|
||||
<span class="m-label">Negative prompt</span>
|
||||
<div class="m-neg">
|
||||
<span v-for="chip in ctx.negativeChips" :key="chip" class="m-neg-chip">
|
||||
{{ chip }}
|
||||
<button type="button" :aria-label="'Remove '+chip" @click="ctx.removeNegative(chip)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="m-neg-add">
|
||||
<input
|
||||
:value="ctx.negativeDraft"
|
||||
type="text"
|
||||
class="m-input"
|
||||
placeholder="Add negative chip"
|
||||
@input="ctx.setNegativeDraft(($event.target as HTMLInputElement).value)"
|
||||
@keydown.enter.prevent="ctx.addNegative"
|
||||
>
|
||||
<button type="button" @click="ctx.addNegative">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<Studio2StyleChips v-if="!ctx.video" v-model="ctx.form.imageStyles" />
|
||||
</template>
|
||||
<p v-if="ctx.error" class="m-err">{{ ctx.error }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-if="sheet==='look'" class="m-sheet" role="dialog" aria-label="Look">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Look</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<template v-if="ctx.form.mode==='music'">
|
||||
<label class="m-label">Seed<input v-model="ctx.music.seed" class="m-input" placeholder="random"></label>
|
||||
<label class="m-label">Track name<input v-model="ctx.music.name" class="m-input" placeholder="Optional"></label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<span class="m-label">LoRA</span>
|
||||
<LoraSelect v-model="ctx.form.settings.loraStack" compact :names="ctx.pickerLoras" :use="ctx.currentLoraUse" :clip-strength="!ctx.video" filename-subtitle :aliases="ctx.loraAliases" :triggers="ctx.loraTriggers" missing-label="None" @insert-trigger="ctx.form.promptSections.action += ' '+$event" />
|
||||
</div>
|
||||
<label class="m-label">Seed
|
||||
<div class="m-seed">
|
||||
<input
|
||||
:value="ctx.form.settings.seed ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="2147483647"
|
||||
class="m-input"
|
||||
@input="ctx.form.settings.seed=Number(($event.target as HTMLInputElement).value);ctx.form.settings.seedMode='locked'"
|
||||
>
|
||||
<button type="button" class="m-dice" aria-label="Random seed" @click="ctx.randomizeSeed">🎲</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="m-label">Aspect
|
||||
<select v-model="ctx.form.settings.aspect" class="m-input">
|
||||
<option v-for="opt in ctx.aspectOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="m-label">Steps<input v-model.number="ctx.form.settings.steps" type="number" class="m-input"></label>
|
||||
<label class="m-label">CFG<input v-model.number="ctx.form.settings.cfg" type="number" class="m-input"></label>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-if="sheet==='gen'" class="m-sheet" role="dialog" aria-label="Gen extras">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Gen</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<template v-if="ctx.form.mode==='music'">
|
||||
<label class="m-label">Target length (sec)<input v-model.number="ctx.music.duration" type="number" min="15" max="150" class="m-input"></label>
|
||||
<p class="m-hint">Caps semantic tokens (~25/s). Actual length follows the model end token.</p>
|
||||
</template>
|
||||
<template v-else-if="ctx.video">
|
||||
<label class="m-label">Duration (sec)<input v-model.number="ctx.form.settings.duration" type="number" class="m-input"></label>
|
||||
<label class="m-label">FPS<input v-model.number="ctx.form.settings.fps" type="number" class="m-input"></label>
|
||||
<label class="m-check"><input v-model="ctx.form.settings.turbo" type="checkbox"> Turbo</label>
|
||||
<Studio2PromptControls v-model="ctx.form.promptSections" />
|
||||
<details class="m-details">
|
||||
<summary>Queue more shots</summary>
|
||||
<textarea :value="ctx.batchText" rows="4" class="m-area" placeholder="First additional prompt Next" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
|
||||
</details>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="m-muted">Image extras live in Look (steps / CFG / seed / aspect).</p>
|
||||
<details v-if="ctx.form.mode==='edit'" class="m-details">
|
||||
<summary>Queue extra edit passes</summary>
|
||||
<textarea :value="ctx.batchText" rows="4" class="m-area" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-if="sheet==='queue'" class="m-sheet" role="dialog" aria-label="Queue">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Queue</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<div
|
||||
v-for="card in ctx.queueCards"
|
||||
:key="card.id"
|
||||
class="m-job"
|
||||
:class="{ on: ctx.focusedJobId===card.id }"
|
||||
@click="ctx.focusQueueCard(card)"
|
||||
>
|
||||
<div class="m-job-id">{{ card.code }}</div>
|
||||
<div class="m-job-title">{{ card.title }}</div>
|
||||
<div class="m-muted">{{ card.meta }}</div>
|
||||
<div class="m-job-status">
|
||||
<span>● {{ card.statusLabel }}</span>
|
||||
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
|
||||
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export type Studio2MobileCtx = Record<string, any>
|
||||
|
||||
const props = defineProps<{ ctx: Studio2MobileCtx }>()
|
||||
const sheet = ref('')
|
||||
|
||||
function toggleSheet(id: string) {
|
||||
sheet.value = sheet.value === id ? '' : id
|
||||
}
|
||||
function closeMenus() {
|
||||
sheet.value = ''
|
||||
}
|
||||
watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.m-shell {
|
||||
--bg: #0b0b0d;
|
||||
--panel: #141416;
|
||||
--line: #2a2a2e;
|
||||
--amber: #f5a524;
|
||||
--amber-on: #1a1204;
|
||||
--text: #f3f1ea;
|
||||
--muted: #9a9588;
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.m-header {
|
||||
display: flex;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 12px;
|
||||
}
|
||||
.m-brand { font-weight: 700; }
|
||||
.m-crumb select {
|
||||
max-width: 7rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-grow { flex: 1; }
|
||||
.m-gpu {
|
||||
border: 1px solid #2d4a2a;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
color: #b8e0a8;
|
||||
font-size: 11px;
|
||||
}
|
||||
.m-gpu[data-tone='busy'] { border-color: color-mix(in srgb, var(--amber) 40%, transparent); color: var(--amber); }
|
||||
.m-chips {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.m-chip {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #1a1a1d;
|
||||
padding: 6px 10px;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
}
|
||||
.m-chip.on, .m-dock button.on {
|
||||
border-color: var(--amber);
|
||||
background: var(--amber);
|
||||
color: var(--amber-on);
|
||||
font-weight: 700;
|
||||
}
|
||||
.m-chip:disabled { opacity: 0.45; }
|
||||
.m-more { position: relative; }
|
||||
.m-more summary { list-style: none; cursor: pointer; padding: 4px 8px; color: var(--muted); }
|
||||
.m-more-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
min-width: 8rem;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
padding: 8px;
|
||||
}
|
||||
.m-more-menu a { color: var(--text); text-decoration: none; padding: 6px 8px; }
|
||||
.m-stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #080808;
|
||||
}
|
||||
.m-stage > img,
|
||||
.m-stage > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
|
||||
.m-eye {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #000a;
|
||||
}
|
||||
.m-strip {
|
||||
display: flex;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.m-tile {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #222;
|
||||
}
|
||||
.m-tile.on { border-color: var(--amber); }
|
||||
.m-tile-pick { display: block; width: 100%; height: 100%; border: 0; padding: 0; background: transparent; }
|
||||
.m-tile img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.m-tile-hid { display: grid; height: 100%; place-items: center; color: var(--muted); font-size: 10px; }
|
||||
.m-tile-eye, .m-tile-trash {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
display: grid;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: #000a;
|
||||
font-size: 10px;
|
||||
}
|
||||
.m-tile-trash { right: 20px; }
|
||||
.m-dock {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr) 120px;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.m-dock button {
|
||||
height: 48px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #1a1a1d;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
.m-cta {
|
||||
background: var(--amber) !important;
|
||||
color: var(--amber-on) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
.m-cta:disabled { opacity: 0.5; }
|
||||
.m-sheet-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
background: #0008;
|
||||
}
|
||||
.m-sheet {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 31;
|
||||
display: flex;
|
||||
max-height: min(85vh, 100%);
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: var(--panel);
|
||||
padding: 10px 14px 20px;
|
||||
}
|
||||
.m-handle {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
margin: 4px auto 12px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #444;
|
||||
}
|
||||
.m-sheet-head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.m-sheet-head button { border: 0; background: transparent; color: var(--amber); }
|
||||
.m-sheet-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.m-label { display: block; font-size: 11px; color: var(--muted); }
|
||||
.m-area, .m-input {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #1a1a1d;
|
||||
padding: 8px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
.m-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
|
||||
.m-hint, .m-muted { color: var(--muted); font-size: 12px; }
|
||||
.m-amber { color: var(--amber); font-size: 12px; }
|
||||
.m-err { color: #fca5a5; font-size: 12px; }
|
||||
.m-neg { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||
.m-neg-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid #5a4a28;
|
||||
border-radius: 999px;
|
||||
background: #2a2418;
|
||||
padding: 4px 10px;
|
||||
color: #e8c37a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-neg-chip button { border: 0; background: transparent; color: inherit; }
|
||||
.m-neg-add { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.m-neg-add .m-input { flex: 1; margin-top: 0; }
|
||||
.m-strip-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.m-strip-actions button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-seed { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.m-seed .m-input { margin-top: 0; flex: 1; }
|
||||
.m-dice {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #1a1a1d;
|
||||
}
|
||||
.m-details { border: 1px solid var(--line); border-radius: 8px; padding: 8px; }
|
||||
.m-job {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m-job.on { border-color: var(--amber); border-width: 2px; }
|
||||
.m-job-id { color: var(--muted); font-size: 12px; }
|
||||
.m-job-title { font-weight: 600; }
|
||||
.m-job-status { display: flex; justify-content: space-between; margin-top: 6px; font-size: 12px; }
|
||||
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
|
||||
</style>
|
||||
@@ -1,29 +0,0 @@
|
||||
/** 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,40 @@
|
||||
# Studio 2 image→text caption (Qwen2.5-VL NSFW Caption V4 GGUF)
|
||||
|
||||
Caption jobs run on the Windows GPU host via **llama-server** (llama.cpp vision / mmproj). Not Comfy, not Klein, not JoyCaption.
|
||||
|
||||
## Weights (~7GB — two files only)
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts\setup-caption.ps1
|
||||
```
|
||||
|
||||
Installs into the Shared models tree:
|
||||
|
||||
`%LOCALAPPDATA%\Comfy-Desktop\ComfyUI-Shared\models\caption\qwen25vl-7b-nsfw-v4\`
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf` | Language model |
|
||||
| `Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf` | Vision projector |
|
||||
|
||||
Do **not** download the full 16.6GB safetensors repo.
|
||||
|
||||
Runtime needs ~8–10GB VRAM. Exclusive GPU: host stops Comfy (and waits if YuE2/upscale is busy), loads the GGUF for one shot, then kills llama-server (`keep_alive 0`).
|
||||
|
||||
## Host agent
|
||||
|
||||
Requires `llama-server` on PATH (`winget install ggml.llamacpp`) or `CAPTION_LLAMA_SERVER`.
|
||||
|
||||
Restart the Comfy host agent after setup. Confirm:
|
||||
|
||||
```text
|
||||
GET http://127.0.0.1:8199/caption/status → { configured, busy, backend: "llama.cpp" }
|
||||
POST /caption { imagePath, style } → load → { text } → unload
|
||||
POST /caption/jobs + PUT …/input → async job used by Studio 2
|
||||
```
|
||||
|
||||
Styles: `descriptive` | `klein_prompt` | `delta` | `tags`.
|
||||
|
||||
## App
|
||||
|
||||
`POST /api/studio-2/caption` with `{ folderId, stillId|sourcePath, captionStyle }` queues a Studio job. Bench **Describe** button and the **caption** task pill both enqueue. Result stores `resultText` on the studio2 job and optionally writes `{still}.txt` beside the library file.
|
||||
@@ -34,15 +34,9 @@ Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false
|
||||
## App
|
||||
|
||||
- Engine key: `qwen21` · UI label: **Qwen 2.1**
|
||||
- **Generate** (T2I) and **Edit** (same checkpoint, second graph). Compose / Iterate / Video / Extend / Music stay disabled unless a graph exists.
|
||||
- Edit requires Start still → `images.image_1`; prompt slots use `<image1>` / `<image2>`. If the user omits `<image1>`, the runner prepends `Keep the subject in <image1>.`
|
||||
- Face / outfit locks stay Klein semantics — they do not drive Qwen slots.
|
||||
- Sampler defaults: euler / simple / cfg **1** / steps **25**. Edit uses `QwenImage21Cache` (device auto, dtype int8). T2I uses `ModelSamplingAuraFlow` shift **3.1**.
|
||||
- Default Generate canvas follows the bench Aspect control on the Qwen-safe ~1 MP grid (`EmptyLatentImage`): 1:1 → 1024×1024, 16:9 → 1536×864, 9:16 → 864×1536 (and the other table rows). PE `wh_ratio` is advisory only and never sizes the canvas. Do not use native 2K bins on 16 GB.
|
||||
- Edit follows `image_1` via the encode node’s latent (resolution long-edge ~1024). Do not inject a picker EmptyLatentImage onto the edit sampler.
|
||||
- Optional **Enhance prompt** (off by default): runs a separate PE-only Comfy graph (`CLIPLoader` + rewrite node), then frees VRAM and queues the existing T2I/Edit graph with the rewritten prompt. Never loads PE CLIP + DiT together on 16 GB. Fail closed if `parse_ok` is false or the rewrite is empty.
|
||||
- PE weights (int8 only): `text_encoders\qwen3.5_9b_qwen_image_2.1_pe_{t2i,i2i}.int8_convrot.safetensors` — do not replace the image TE `qwen3vl_8b_int8_convrot.safetensors`.
|
||||
- Optional **Turbo** (off by default): Viggle DMD LoRA `Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors` on the same Q8 GGUF via `ViggleTurboLora` + `ViggleTurboSigmas` (`1.0, 0.9375, 0.875, 0.75, 0.5, 0.25`), 6 steps, CFG 1, empty negative. Not a new engine. Enhance prompt stays compatible and is recommended with Turbo.
|
||||
- Custom node: `ComfyUI-Viggle-Turbo` (`viggle_turbo.py`). Do not merge the LoRA with stock `LoraLoaderModelOnly` (lossy on int8/bf16).
|
||||
- Generate (T2I) only in this build. Edit / Compose / Iterate / Video disable with: “Qwen 2.1 is T2I in this build”.
|
||||
- Sampler defaults: euler / simple / cfg **1** / steps **25** · `ModelSamplingAuraFlow` shift **3.1**
|
||||
- Default canvas **1024×1024** (aspect 16:9 / 9:16 / 1:1 → long-edge square via `TextEncodeQwenImage21` resolution; multiples of 32). Drop to 768 if VRAM errors.
|
||||
- No hero / locks / Klein LoRA stack on this engine.
|
||||
|
||||
Graphs: `server/assets/studio2_qwen21_t2i.json`, `server/assets/studio2_qwen21_edit.json`, `server/assets/studio2_qwen21_t2i_turbo.json`, `server/assets/studio2_qwen21_edit_turbo.json`, `server/assets/studio2_qwen21_pe_t2i.json`, `server/assets/studio2_qwen21_pe_edit.json`.
|
||||
Graph: `server/assets/studio2_qwen21_t2i.json`.
|
||||
|
||||
@@ -91,11 +91,11 @@
|
||||
{{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showPrivacyToggles && entries.length"
|
||||
v-if="hasHiddenThumbs"
|
||||
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 all thumbnails' : 'Reveal all thumbnails'"
|
||||
:title="revealAll ? 'Hide flagged thumbnails' : 'Reveal all hidden 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="showPrivacyToggles && !revealAll" class="sensitive-glow block h-full w-full" />
|
||||
<span v-if="node.clip.hideThumbnail" 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="showPrivacyToggles && !revealAll" class="sensitive-glow block aspect-square w-full" />
|
||||
<span v-if="('hideThumbnail' in part && part.hideThumbnail) || ('hideInput' in part && part.hideInput)" class="sensitive-glow block aspect-square w-full" />
|
||||
<img
|
||||
v-else
|
||||
:src="partThumb(part)"
|
||||
@@ -828,18 +828,24 @@ 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
|
||||
// Session cover for the whole grid — not limited to individually flagged items.
|
||||
return !revealAll.value
|
||||
return itemHidden(entry) && !revealAll.value
|
||||
}
|
||||
|
||||
const hasHiddenThumbs = computed(() => entries.value.some(entry => itemHidden(entry)))
|
||||
|
||||
function toggleRevealAll() {
|
||||
revealAll.value = !revealAll.value
|
||||
forceHiddenIds.value = []
|
||||
if (focused.value && focused.value.kind !== 'track') {
|
||||
if (focused.value && itemHidden(focused.value)) {
|
||||
detailsHidden.value = !revealAll.value
|
||||
}
|
||||
}
|
||||
@@ -990,7 +996,7 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) {
|
||||
: entry.kind === 'track'
|
||||
? (entry.track?.id || '')
|
||||
: (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '')
|
||||
if (!sameCard) detailsHidden.value = thumbHidden(entry)
|
||||
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value
|
||||
cancelRename()
|
||||
}
|
||||
|
||||
|
||||
@@ -961,8 +961,8 @@ async function loadLog(entry: GenerationLogEntry) {
|
||||
kind: entry.kind,
|
||||
payload: entry.payload
|
||||
}))
|
||||
if (entry.payload?.studio2Id || entry.payload?.studio2Request || entry.kind === 'music') {
|
||||
await navigateTo(entry.kind === 'music' ? '/studio-2?task=music' : '/studio-2')
|
||||
if (entry.kind === 'music') {
|
||||
await navigateTo('/music')
|
||||
} else {
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 441 B |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 303 B |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 302 B |
@@ -0,0 +1,331 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, readdirSync, copyFileSync } from 'node:fs'
|
||||
import { join, resolve, extname } from 'node:path'
|
||||
import { captionStylePrompt, CAPTION_STYLES } from '../shared/studio2/caption.mjs'
|
||||
|
||||
const MODEL_NAME = 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'
|
||||
const MMPROJ_NAME = 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
|
||||
|
||||
function defaultModelsDir() {
|
||||
const shared = process.env.COMFY_MODELS_ROOT
|
||||
|| join(process.env.LOCALAPPDATA || '', 'Comfy-Desktop', 'ComfyUI-Shared', 'models')
|
||||
return resolve(process.env.CAPTION_MODELS || join(shared, 'caption', 'qwen25vl-7b-nsfw-v4'))
|
||||
}
|
||||
|
||||
function defaultLlamaServer() {
|
||||
if (process.env.CAPTION_LLAMA_SERVER) return resolve(process.env.CAPTION_LLAMA_SERVER)
|
||||
return 'llama-server'
|
||||
}
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolvePort, reject) => {
|
||||
const server = createServer()
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
server.close(error => error ? reject(error) : resolvePort(port))
|
||||
})
|
||||
server.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForServer(port, signal, timeoutMs = 120_000, fetchImpl = fetch) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (signal?.aborted) throw new Error('Caption cancelled while loading the model.')
|
||||
try {
|
||||
const response = await fetchImpl(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2000) })
|
||||
if (response.ok) return
|
||||
} catch { /* still booting */ }
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error('llama-server did not become ready in time.')
|
||||
}
|
||||
|
||||
function mimeFor(path) {
|
||||
const ext = extname(path).toLowerCase()
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
if (ext === '.webp') return 'image/webp'
|
||||
if (ext === '.gif') return 'image/gif'
|
||||
return 'image/png'
|
||||
}
|
||||
|
||||
function killTree(child) {
|
||||
return new Promise(resolveKill => {
|
||||
if (!child?.pid) return resolveKill()
|
||||
let finished = false
|
||||
const done = () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
resolveKill()
|
||||
}
|
||||
child.once('close', done)
|
||||
try {
|
||||
if (typeof child.kill === 'function') child.kill()
|
||||
} catch { /* ignore */ }
|
||||
if (process.platform === 'win32' && child.pid > 0) {
|
||||
try {
|
||||
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
||||
killer.once('close', done)
|
||||
killer.once('error', done)
|
||||
} catch {
|
||||
done()
|
||||
}
|
||||
}
|
||||
setTimeout(done, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
export function validateCaptionHostRequest(body) {
|
||||
if (!/^[a-zA-Z0-9-]{12,80}$/.test(body?.id || '')) throw new Error('Invalid caption job ID.')
|
||||
const style = String(body.style || body.captionStyle || 'descriptive')
|
||||
if (!CAPTION_STYLES.includes(style)) throw new Error('Unknown caption style.')
|
||||
return { id: body.id, style, imagePath: body.imagePath ? String(body.imagePath) : '' }
|
||||
}
|
||||
|
||||
/** One llama-server process per caption. Process exit is the VRAM unload boundary (keep_alive 0). */
|
||||
export function createCaptionHost({
|
||||
prepare,
|
||||
leaseValid,
|
||||
spawnProcess = spawn,
|
||||
modelsDir,
|
||||
llamaServer,
|
||||
dataDir,
|
||||
fetchImpl = fetch
|
||||
} = {}) {
|
||||
const models = resolve(modelsDir || defaultModelsDir())
|
||||
const executable = llamaServer || defaultLlamaServer()
|
||||
const data = resolve(dataDir || process.env.CAPTION_JOBS_DIR || join(models, 'aigen-jobs'))
|
||||
let active = null
|
||||
let holdUntil = 0
|
||||
if (existsSync(data)) {
|
||||
for (const id of readdirSync(data)) {
|
||||
try {
|
||||
const path = join(data, id, 'status.json')
|
||||
if (!existsSync(path)) continue
|
||||
const state = JSON.parse(readFileSync(path, 'utf8'))
|
||||
if (['running', 'starting', 'cancelling'].includes(state.status)) {
|
||||
holdUntil = Date.now() + 10000
|
||||
state.status = 'error'
|
||||
state.error = 'Caption host restarted. GPU model unloaded.'
|
||||
writeFileSync(path + '.tmp', JSON.stringify(state))
|
||||
renameSync(path + '.tmp', path)
|
||||
}
|
||||
} catch { /* ignore bad records */ }
|
||||
}
|
||||
}
|
||||
const dir = id => {
|
||||
if (!/^[a-zA-Z0-9-]{12,80}$/.test(id || '')) throw new Error('Invalid caption job ID.')
|
||||
return join(data, id)
|
||||
}
|
||||
const persist = job => {
|
||||
const target = join(dir(job.id), 'status.json')
|
||||
writeFileSync(target + '.tmp', JSON.stringify(job))
|
||||
renameSync(target + '.tmp', target)
|
||||
}
|
||||
const read = id => {
|
||||
if (active?.job.id === id) return { ...active.job, text: active.job.text }
|
||||
const path = join(dir(id), 'status.json')
|
||||
if (!existsSync(path)) return null
|
||||
return JSON.parse(readFileSync(path, 'utf8'))
|
||||
}
|
||||
const modelPath = () => join(models, MODEL_NAME)
|
||||
const mmprojPath = () => join(models, MMPROJ_NAME)
|
||||
const configured = () => {
|
||||
try {
|
||||
if (!existsSync(modelPath()) || !existsSync(mmprojPath())) return false
|
||||
if (executable.includes('\\') || executable.includes('/')) return existsSync(executable)
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
return {
|
||||
busy: () => Boolean(active) || Date.now() < holdUntil,
|
||||
configured,
|
||||
modelsDir: () => models,
|
||||
read,
|
||||
async upload(id, stream) {
|
||||
if (read(id)?.status === 'complete') throw new Error('This caption job has already finished.')
|
||||
const jobDir = dir(id)
|
||||
mkdirSync(jobDir, { recursive: true })
|
||||
const dest = join(jobDir, 'input.upload')
|
||||
const { createWriteStream } = await import('node:fs')
|
||||
const { pipeline } = await import('node:stream/promises')
|
||||
const { Transform } = await import('node:stream')
|
||||
let bytes = 0
|
||||
await pipeline(stream, new Transform({
|
||||
transform(chunk, _, callback) {
|
||||
bytes += chunk.length
|
||||
callback(bytes > 40 * 1024 * 1024 ? new Error('Image exceeds the 40 MB upload limit.') : null, chunk)
|
||||
}
|
||||
}), createWriteStream(dest, { flags: 'w' }))
|
||||
if (bytes < 32) throw new Error('Image file is empty.')
|
||||
renameSync(dest, join(jobDir, 'input.png'))
|
||||
},
|
||||
async start(body, lease) {
|
||||
const request = validateCaptionHostRequest(body)
|
||||
const previous = read(request.id)
|
||||
if (previous) return previous
|
||||
if (active || Date.now() < holdUntil) throw Object.assign(new Error('Caption is already running or releasing VRAM.'), { statusCode: 409 })
|
||||
if (!configured()) throw new Error(`Caption model is not installed. Expected ${MODEL_NAME} and ${MMPROJ_NAME} under ${models}. Run scripts/setup-caption.ps1.`)
|
||||
if (!leaseValid(lease)) throw new Error('GPU reservation expired.')
|
||||
|
||||
let imagePath = request.imagePath
|
||||
if (imagePath) {
|
||||
if (!existsSync(imagePath)) throw new Error('imagePath does not exist on the GPU host.')
|
||||
mkdirSync(dir(request.id), { recursive: true })
|
||||
const dest = join(dir(request.id), `input${extname(imagePath) || '.png'}`)
|
||||
copyFileSync(imagePath, dest)
|
||||
imagePath = dest
|
||||
} else {
|
||||
imagePath = join(dir(request.id), 'input.png')
|
||||
if (!existsSync(imagePath)) {
|
||||
const alt = readdirSync(dir(request.id)).find(name => /^input\./i.test(name))
|
||||
if (!alt) throw new Error('Upload an image first or pass imagePath.')
|
||||
imagePath = join(dir(request.id), alt)
|
||||
}
|
||||
}
|
||||
|
||||
const job = {
|
||||
id: request.id,
|
||||
status: 'starting',
|
||||
message: 'Preparing GPU for caption',
|
||||
progress: 0,
|
||||
style: request.style,
|
||||
startedAt: Date.now(),
|
||||
checkedAt: Date.now()
|
||||
}
|
||||
active = { job, child: null, cancelled: false, abort: new AbortController() }
|
||||
const run = active
|
||||
mkdirSync(dir(job.id), { recursive: true })
|
||||
writeFileSync(join(dir(job.id), 'request.json'), JSON.stringify({ ...request, imagePath, prompt: captionStylePrompt(request.style) }))
|
||||
persist(job)
|
||||
|
||||
try {
|
||||
if (prepare) await prepare()
|
||||
if (run.cancelled || !leaseValid(lease)) throw new Error('Caption start cancelled or GPU reservation expired.')
|
||||
|
||||
job.status = 'running'
|
||||
job.message = 'Loading Qwen2.5-VL caption model'
|
||||
job.progress = 5
|
||||
persist(job)
|
||||
|
||||
const port = await freePort()
|
||||
const args = [
|
||||
'-m', modelPath(),
|
||||
'--mmproj', mmprojPath(),
|
||||
'--host', '127.0.0.1',
|
||||
'--port', String(port),
|
||||
'-ngl', String(process.env.CAPTION_NGL || '99'),
|
||||
'-c', String(process.env.CAPTION_CTX || '4096'),
|
||||
'--jinja'
|
||||
]
|
||||
const child = spawnProcess(executable, args, {
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env }
|
||||
})
|
||||
run.child = child
|
||||
const log = chunk => appendFileSync(join(dir(job.id), 'worker.log'), chunk)
|
||||
child.stdout.on('data', log)
|
||||
child.stderr.on('data', log)
|
||||
|
||||
const watchdog = setInterval(() => {
|
||||
if (!leaseValid(lease)) {
|
||||
job.error = 'GPU reservation expired; caption stopped.'
|
||||
run.abort.abort()
|
||||
void killTree(child)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
try {
|
||||
await waitForServer(port, run.abort.signal, 120_000, fetchImpl)
|
||||
if (run.cancelled) throw new Error('Cancelled')
|
||||
job.message = 'Captioning'
|
||||
job.progress = 40
|
||||
persist(job)
|
||||
|
||||
const bytes = readFileSync(imagePath)
|
||||
const dataUrl = `data:${mimeFor(imagePath)};base64,${bytes.toString('base64')}`
|
||||
const timeout = AbortSignal.timeout(Number(process.env.CAPTION_TIMEOUT_MS || 180_000))
|
||||
const onAbort = () => { /* combined via race below */ }
|
||||
run.abort.signal.addEventListener('abort', onAbort, { once: true })
|
||||
let response
|
||||
try {
|
||||
response = await fetchImpl(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: run.abort.signal.aborted ? run.abort.signal : timeout,
|
||||
body: JSON.stringify({
|
||||
temperature: 0.2,
|
||||
max_tokens: 512,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: captionStylePrompt(request.style) },
|
||||
{ type: 'image_url', image_url: { url: dataUrl } }
|
||||
]
|
||||
}]
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
run.abort.signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
if (run.abort.signal.aborted) throw new Error('Cancelled')
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`llama-server caption failed (${response.status}): ${detail.slice(0, 400)}`)
|
||||
}
|
||||
const payload = await response.json()
|
||||
const text = String(payload?.choices?.[0]?.message?.content || '').trim()
|
||||
if (!text) throw new Error('Caption model returned empty text.')
|
||||
job.text = text
|
||||
job.status = run.cancelled ? 'cancelled' : 'complete'
|
||||
job.message = job.status === 'complete' ? 'Caption ready' : 'Cancelled'
|
||||
job.progress = 100
|
||||
job.checkedAt = Date.now()
|
||||
persist(job)
|
||||
return { ...job }
|
||||
} finally {
|
||||
clearInterval(watchdog)
|
||||
await killTree(child)
|
||||
// keep_alive 0: process exit unloads GGUF+mmproj from VRAM
|
||||
holdUntil = Date.now() + 2000
|
||||
if (active === run) active = null
|
||||
}
|
||||
} catch (error) {
|
||||
job.status = run.cancelled ? 'cancelled' : 'error'
|
||||
job.error = error.message || String(error)
|
||||
job.message = job.error
|
||||
job.checkedAt = Date.now()
|
||||
persist(job)
|
||||
if (run.child) await killTree(run.child)
|
||||
if (active === run) active = null
|
||||
holdUntil = Date.now() + 2000
|
||||
throw error
|
||||
}
|
||||
},
|
||||
async cancel(id) {
|
||||
if (active?.job.id !== id) return read(id)
|
||||
const run = active
|
||||
run.cancelled = true
|
||||
run.job.status = 'cancelling'
|
||||
run.job.message = 'Cancelling'
|
||||
persist(run.job)
|
||||
run.abort.abort()
|
||||
if (run.child) await killTree(run.child)
|
||||
return read(id)
|
||||
},
|
||||
/** Sync one-shot used by POST /caption { imagePath, style }. */
|
||||
async captionOnce(body, lease) {
|
||||
const id = body.id || `caption-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
const result = await this.start({ ...body, id }, lease)
|
||||
if (result.status === 'complete') return { text: result.text, id: result.id, style: result.style }
|
||||
throw new Error(result.error || 'Caption failed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const captionModelFiles = { model: MODEL_NAME, mmproj: MMPROJ_NAME }
|
||||
@@ -4,6 +4,7 @@ import { stableMemoryArgs } from './comfy-memory-policy.mjs'
|
||||
import { createGpuReservation } from './gpu-reservation.mjs'
|
||||
import { createGpuProxy } from './gpu-proxy.mjs'
|
||||
import { createYue2Host } from './yue2-host.mjs'
|
||||
import { createCaptionHost } from './caption-host.mjs'
|
||||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
@@ -147,7 +148,7 @@ let proxyTarget = 0
|
||||
|
||||
function ensureProxyListening() {
|
||||
if (proxyServer) return
|
||||
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() })
|
||||
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() || caption.busy() })
|
||||
proxyServer.on('error', (error) => {
|
||||
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || error) }))
|
||||
})
|
||||
@@ -819,7 +820,7 @@ function purgeDesktopFiles(body) {
|
||||
}
|
||||
|
||||
const gpuReservation = createGpuReservation({ idle: async () => {
|
||||
if (yue2.busy() || upscale.busy()) return false
|
||||
if (yue2.busy() || upscale.busy() || caption.busy()) return false
|
||||
if ((await trainingLock()).busy) return false
|
||||
const healthy = await syncProxy()
|
||||
if (healthy) {
|
||||
@@ -830,14 +831,15 @@ const gpuReservation = createGpuReservation({ idle: async () => {
|
||||
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
||||
} })
|
||||
|
||||
const yue2 = createYue2Host({
|
||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||
prepare: async () => {
|
||||
async function prepareExclusiveGpu(label) {
|
||||
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
||||
if (label !== 'YuE2' && yue2.busy()) throw new Error('YuE2 is using the GPU.')
|
||||
if (label !== 'caption' && caption.busy()) throw new Error('Caption is using the GPU.')
|
||||
if (upscale.busy()) throw new Error('Local upscale is using the GPU.')
|
||||
const healthy = await syncProxy()
|
||||
if (healthy) {
|
||||
const queue = await fetchLocalQueue(healthy)
|
||||
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuE2 cannot start.')
|
||||
if (!queue.ok || queue.running || queue.pending) throw new Error(`Comfy is busy; ${label} cannot start.`)
|
||||
}
|
||||
// Stop Comfy and refuse to launch while its Python still owns VRAM.
|
||||
if (healthy || await processUp() || await pythonMainUp().catch(() => false)) {
|
||||
@@ -846,13 +848,50 @@ const yue2 = createYue2Host({
|
||||
}
|
||||
if (await pythonMainUp()) throw new Error('Comfy has not stopped; retry after the GPU is free.')
|
||||
}
|
||||
})
|
||||
|
||||
const upscale = createUpscaleHost({ leaseValid: token => gpuReservation.isOwner(token) })
|
||||
|
||||
const yue2 = createYue2Host({
|
||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||
prepare: async () => prepareExclusiveGpu('YuE2')
|
||||
})
|
||||
|
||||
const caption = createCaptionHost({
|
||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||
prepare: async () => prepareExclusiveGpu('caption')
|
||||
})
|
||||
|
||||
async function handleControl(req, res) {
|
||||
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
|
||||
const url = new URL(req.url || '/', 'http://localhost')
|
||||
if (url.pathname.startsWith('/caption')) {
|
||||
const match = url.pathname.match(/^\/caption\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/cancel)?$/)
|
||||
if (req.method === 'GET' && url.pathname === '/caption/status') {
|
||||
return json(res, 200, { configured: caption.configured(), busy: caption.busy(), backend: 'llama.cpp', modelsDir: caption.modelsDir() })
|
||||
}
|
||||
// Sync one-shot: POST /caption { imagePath, style } → load → caption → unload → { text }
|
||||
if (req.method === 'POST' && url.pathname === '/caption') {
|
||||
try {
|
||||
const body = await readJson(req)
|
||||
return json(res, 200, await caption.captionOnce(body, String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||
} catch (error) {
|
||||
return json(res, error.statusCode || 400, { error: error.message || 'Caption failed' })
|
||||
}
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/caption/jobs') {
|
||||
return json(res, 200, await caption.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||
}
|
||||
if (match && req.method === 'PUT' && match[2] === '/input') {
|
||||
await caption.upload(match[1], req)
|
||||
return json(res, 200, { uploaded: true })
|
||||
}
|
||||
if (match && req.method === 'POST' && match[2] === '/cancel') return json(res, 200, await caption.cancel(match[1]))
|
||||
if (match && req.method === 'GET' && !match[2]) {
|
||||
const job = caption.read(match[1])
|
||||
return json(res, job ? 200 : 404, job || { error: 'Caption job not found' })
|
||||
}
|
||||
return json(res, 404, { error: 'Unknown caption endpoint' })
|
||||
}
|
||||
if (url.pathname.startsWith('/upscale/')) {
|
||||
const match = url.pathname.match(/^\/upscale\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/video|\/cancel)?$/)
|
||||
if (req.method === 'POST' && url.pathname === '/upscale/jobs') return json(res, 200, upscale.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||
@@ -909,12 +948,14 @@ async function handleControl(req, res) {
|
||||
gpu: gpuReservation.availability(),
|
||||
training: { busy: lastTraining.busy },
|
||||
yue2: { busy: yue2.busy(), configured: yue2.configured() },
|
||||
caption: { busy: caption.busy(), configured: caption.configured() },
|
||||
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
|
||||
})
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/start') {
|
||||
if (upscale.busy()) return json(res, 409, { message: 'Local upscale is using the GPU.' })
|
||||
if (yue2.busy()) return json(res, 409, { message: 'YuE2 is using the GPU.' })
|
||||
if (caption.busy()) return json(res, 409, { message: 'Caption is using the GPU.' })
|
||||
const training = await trainingLock()
|
||||
if (training.busy) {
|
||||
return json(res, 409, {
|
||||
@@ -1016,7 +1057,7 @@ const server = http.createServer(async (req, res) => {
|
||||
} else await handleControl(req, res)
|
||||
} catch (error) {
|
||||
req.resume()
|
||||
if ((String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Music host request failed' })
|
||||
if ((String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/') || String(req.url || '').startsWith('/caption')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Host request failed' })
|
||||
if (!res.headersSent) json(res, error.statusCode || 400, { ok: false, message: error.statusCode === 409 ? 'GPU is in use. Waiting for availability.' : 'GPU coordination request failed.' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Download Qwen2.5-VL NSFW Caption V4 GGUF (Q5_K_M + mmproj only) for Studio 2 caption jobs.
|
||||
# Does NOT pull the full safetensors repo (~16.6GB).
|
||||
#
|
||||
# Example:
|
||||
# powershell -ExecutionPolicy Bypass -File scripts\setup-caption.ps1
|
||||
# powershell -File scripts\setup-caption.ps1 -SkipDownload
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ModelsRoot = '',
|
||||
[string]$LlamaServer = '',
|
||||
[switch]$SkipDownload,
|
||||
[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-ModelsRoot([string]$Hint) {
|
||||
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
|
||||
$candidates = @(
|
||||
$Hint,
|
||||
$env:COMFY_MODELS_ROOT,
|
||||
$shared
|
||||
) | 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 @{ Name = $name; Source = $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
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step 'Resolve models root'
|
||||
$modelsRoot = Resolve-ModelsRoot $ModelsRoot
|
||||
$captionDir = Join-Path $modelsRoot 'caption\qwen25vl-7b-nsfw-v4'
|
||||
Ensure-Dir $captionDir
|
||||
Write-Ok "Models root: $modelsRoot"
|
||||
Write-Ok "Caption dir: $captionDir"
|
||||
|
||||
$model = 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'
|
||||
$mmproj = 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
|
||||
$modelPath = Join-Path $captionDir $model
|
||||
$mmprojPath = Join-Path $captionDir $mmproj
|
||||
|
||||
if (-not $SkipDownload) {
|
||||
Write-Step 'Download GGUF + mmproj only (~7GB)'
|
||||
$needModel = $Force -or -not (Test-Path $modelPath)
|
||||
$needMmproj = $Force -or -not (Test-Path $mmprojPath)
|
||||
if (-not $needModel -and -not $needMmproj) {
|
||||
Write-Ok 'Both weight files already present.'
|
||||
} else {
|
||||
$hf = Find-HfCli
|
||||
if ($hf.Name -eq 'hf') {
|
||||
& $hf.Source download mradermacher/Qwen2.5-VL-7B-NSFW-Caption-V4-GGUF `
|
||||
--include $model `
|
||||
--include $mmproj `
|
||||
--local-dir $captionDir
|
||||
} else {
|
||||
& $hf.Source download mradermacher/Qwen2.5-VL-7B-NSFW-Caption-V4-GGUF `
|
||||
--include $model `
|
||||
--include $mmproj `
|
||||
--local-dir $captionDir
|
||||
}
|
||||
if (-not (Test-Path $modelPath)) { throw "Missing $model after download." }
|
||||
if (-not (Test-Path $mmprojPath)) { throw "Missing $mmproj after download." }
|
||||
Write-Ok 'Download complete.'
|
||||
}
|
||||
} else {
|
||||
Write-Warn 'SkipDownload set — not fetching weights.'
|
||||
}
|
||||
|
||||
Write-Step 'Locate llama-server'
|
||||
$server = $LlamaServer
|
||||
if (-not $server) { $server = $env:CAPTION_LLAMA_SERVER }
|
||||
if (-not $server) {
|
||||
$cmd = Get-Command llama-server -ErrorAction SilentlyContinue
|
||||
if ($cmd) { $server = $cmd.Source }
|
||||
}
|
||||
if ($server) {
|
||||
Write-Ok "llama-server: $server"
|
||||
} else {
|
||||
Write-Warn 'llama-server not on PATH. Install with: winget install ggml.llamacpp'
|
||||
Write-Warn 'Or set CAPTION_LLAMA_SERVER to a CUDA build of llama-server.exe'
|
||||
}
|
||||
|
||||
$ready = @{
|
||||
model = $modelPath
|
||||
mmproj = $mmprojPath
|
||||
modelsDir = $captionDir
|
||||
llamaServer = $server
|
||||
backend = 'llama.cpp'
|
||||
updatedAt = (Get-Date).ToString('o')
|
||||
}
|
||||
$readyPath = Join-Path $captionDir 'aigen-ready.json'
|
||||
$ready | ConvertTo-Json | Set-Content -Path $readyPath -Encoding utf8
|
||||
Write-Ok "Wrote $readyPath"
|
||||
Write-Host ""
|
||||
Write-Host "Caption host looks for:" -ForegroundColor Cyan
|
||||
Write-Host " $modelPath"
|
||||
Write-Host " $mmprojPath"
|
||||
Write-Host "Restart the Comfy host agent after install so /caption/status reports configured=true."
|
||||
@@ -48,7 +48,7 @@ export default defineEventHandler(async () => {
|
||||
host: comfy.host || '',
|
||||
queueRunning: queue.running,
|
||||
queuePending: queue.pending,
|
||||
asleep: Boolean(control?.asleep) && !comfyReachable && !control?.yue2?.busy,
|
||||
asleep: Boolean(control?.asleep) && !comfyReachable && !control?.yue2?.busy && !control?.caption?.busy,
|
||||
processUp: Boolean(control?.process ?? control?.processUp),
|
||||
lastActivityAt: control?.lastActivityAt || null,
|
||||
stats: comfy.ok ? comfy.stats : undefined
|
||||
@@ -58,6 +58,11 @@ export default defineEventHandler(async () => {
|
||||
busy: Boolean(control?.yue2?.busy),
|
||||
backend: control?.yue2?.backend || 'yue2'
|
||||
},
|
||||
caption: {
|
||||
configured: Boolean(control?.caption?.configured),
|
||||
busy: Boolean(control?.caption?.busy),
|
||||
backend: control?.caption?.backend || 'llama.cpp'
|
||||
},
|
||||
imageComfy: {
|
||||
configured: imageConfigured,
|
||||
ok: imageOk,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { validateCaptionRequest } from '~/shared/studio2/caption.mjs'
|
||||
import { saveRecord, readRecord } from '../../utils/studio2/store'
|
||||
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue'
|
||||
import { getStill } from '../../utils/library'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const raw = await readBody(event)
|
||||
let request: ReturnType<typeof validateCaptionRequest>
|
||||
try {
|
||||
request = validateCaptionRequest(raw)
|
||||
} catch (e: any) {
|
||||
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message })
|
||||
}
|
||||
assertFolderAccess(event, request.folderId)
|
||||
let stillId = request.stillId
|
||||
if (!stillId && request.sourcePath) {
|
||||
// sourcePath may be a still id when the client already resolved a library asset.
|
||||
stillId = String(request.sourcePath)
|
||||
}
|
||||
const still = getStill(owner, stillId)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
request.stillId = still.id
|
||||
request.imageAId = still.id
|
||||
request.sourcePath = still.id
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const record = {
|
||||
id,
|
||||
owner,
|
||||
request,
|
||||
state: 'waiting',
|
||||
queuedAt: Date.now(),
|
||||
familyId: crypto.randomUUID(),
|
||||
outputs: [],
|
||||
resultText: '',
|
||||
purgeResult: 'Not yet saved'
|
||||
}
|
||||
saveRecord(record)
|
||||
const row = await addStudioJob({
|
||||
ownerKey: owner,
|
||||
kind: 'caption',
|
||||
familyId: record.familyId,
|
||||
payload: {
|
||||
studio2Id: id,
|
||||
prompt: request.compiledPrompt,
|
||||
name: `Describe · ${request.captionStyle}`,
|
||||
folderId: request.folderId,
|
||||
stillId: still.id,
|
||||
captionStyle: request.captionStyle,
|
||||
aspect: 'auto',
|
||||
width: 0,
|
||||
height: 0,
|
||||
steps: 1,
|
||||
turbo: true,
|
||||
seed: 0,
|
||||
cfg: 0,
|
||||
fps: 24,
|
||||
samplerName: 'euler',
|
||||
scheduler: 'simple',
|
||||
duration: 0,
|
||||
sound: false,
|
||||
workflow: 'minimax',
|
||||
useIdentityRefs: false,
|
||||
hideThumbnail: false,
|
||||
referenceStillIds: [],
|
||||
extensions: [],
|
||||
queueAutoRun: true
|
||||
} as StudioJobPayload
|
||||
})
|
||||
saveRecord({ ...readRecord(owner, id), queueId: row.id })
|
||||
kickStudioQueue()
|
||||
return { id, captionStyle: request.captionStyle, stillId: still.id }
|
||||
})
|
||||
@@ -4,21 +4,19 @@ 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, xaigen);
|
||||
request = validateRequest(raw, useRuntimeConfig().public.studio === '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, xaigen);
|
||||
if (!video && request.engine !== 'qwen21')
|
||||
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen');
|
||||
if (!video)
|
||||
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))
|
||||
@@ -28,7 +26,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, studio2Request: structuredClone(request), 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, 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 };
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
{
|
||||
"4": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||
},
|
||||
"class_type": "UnetLoaderGGUF",
|
||||
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"lora_name": "Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors",
|
||||
"strength": 1.0
|
||||
},
|
||||
"class_type": "ViggleTurboLora",
|
||||
"_meta": { "title": "Viggle Turbo LoRA (unmerged)" }
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"device": "auto",
|
||||
"dtype": "int8",
|
||||
"model": ["7", 0]
|
||||
},
|
||||
"class_type": "QwenImage21Cache",
|
||||
"_meta": { "title": "Qwen 2.1 Cache" }
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||
},
|
||||
"10": {
|
||||
"inputs": {
|
||||
"image": "qwen21_edit_source.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": { "title": "image_1 source" }
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"clip": ["5", 0],
|
||||
"vae": ["6", 0],
|
||||
"prompt": "",
|
||||
"negative_prompt": "",
|
||||
"resolution": 1024,
|
||||
"images.image_1": ["10", 0]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"_meta": { "title": "Text Encode Qwen Image 2.1 Edit" }
|
||||
},
|
||||
"30": {
|
||||
"inputs": {
|
||||
"model": ["8", 0],
|
||||
"conditioning": ["9", 0]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": { "title": "CFG off (Turbo)" }
|
||||
},
|
||||
"31": {
|
||||
"inputs": {
|
||||
"noise_seed": 1
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": { "title": "Noise" }
|
||||
},
|
||||
"32": {
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": { "title": "euler" }
|
||||
},
|
||||
"33": {
|
||||
"inputs": {
|
||||
"latent": ["9", 2],
|
||||
"nodes": "1.0, 0.9375, 0.875, 0.75, 0.5, 0.25"
|
||||
},
|
||||
"class_type": "ViggleTurboSigmas",
|
||||
"_meta": { "title": "Viggle Turbo Sigmas" }
|
||||
},
|
||||
"34": {
|
||||
"inputs": {
|
||||
"noise": ["31", 0],
|
||||
"guider": ["30", 0],
|
||||
"sampler": ["32", 0],
|
||||
"sigmas": ["33", 0],
|
||||
"latent_image": ["9", 2]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": { "title": "Turbo Sampler" }
|
||||
},
|
||||
"35": {
|
||||
"inputs": {
|
||||
"samples": ["34", 0],
|
||||
"vae": ["6", 0]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"36": {
|
||||
"inputs": {
|
||||
"filename_prefix": "studio2-qwen21-edit-turbo",
|
||||
"images": ["35", 0]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": { "title": "Save Image" }
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
@@ -40,15 +40,6 @@
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"_meta": { "title": "Text Encode Qwen Image 2.1" }
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": { "title": "Qwen 2.1 canvas" }
|
||||
},
|
||||
"15": {
|
||||
"inputs": {
|
||||
"seed": 1,
|
||||
@@ -60,7 +51,7 @@
|
||||
"model": ["3", 0],
|
||||
"positive": ["9", 0],
|
||||
"negative": ["9", 1],
|
||||
"latent_image": ["16", 0]
|
||||
"latent_image": ["9", 2]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": { "title": "KSampler" }
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
{
|
||||
"4": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||
},
|
||||
"class_type": "UnetLoaderGGUF",
|
||||
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"lora_name": "Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors",
|
||||
"strength": 1.0
|
||||
},
|
||||
"class_type": "ViggleTurboLora",
|
||||
"_meta": { "title": "Viggle Turbo LoRA (unmerged)" }
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"clip": ["5", 0],
|
||||
"prompt": "",
|
||||
"negative_prompt": "",
|
||||
"resolution": 1024
|
||||
},
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"_meta": { "title": "Text Encode Qwen Image 2.1" }
|
||||
},
|
||||
"16": {
|
||||
"inputs": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": { "title": "Qwen 2.1 canvas" }
|
||||
},
|
||||
"30": {
|
||||
"inputs": {
|
||||
"model": ["7", 0],
|
||||
"conditioning": ["9", 0]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": { "title": "CFG off (Turbo)" }
|
||||
},
|
||||
"31": {
|
||||
"inputs": {
|
||||
"noise_seed": 1
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": { "title": "Noise" }
|
||||
},
|
||||
"32": {
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": { "title": "euler" }
|
||||
},
|
||||
"33": {
|
||||
"inputs": {
|
||||
"latent": ["16", 0],
|
||||
"nodes": "1.0, 0.9375, 0.875, 0.75, 0.5, 0.25"
|
||||
},
|
||||
"class_type": "ViggleTurboSigmas",
|
||||
"_meta": { "title": "Viggle Turbo Sigmas" }
|
||||
},
|
||||
"34": {
|
||||
"inputs": {
|
||||
"noise": ["31", 0],
|
||||
"guider": ["30", 0],
|
||||
"sampler": ["32", 0],
|
||||
"sigmas": ["33", 0],
|
||||
"latent_image": ["16", 0]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": { "title": "Turbo Sampler" }
|
||||
},
|
||||
"35": {
|
||||
"inputs": {
|
||||
"samples": ["34", 0],
|
||||
"vae": ["6", 0]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"36": {
|
||||
"inputs": {
|
||||
"filename_prefix": "studio2-qwen21-turbo",
|
||||
"images": ["35", 0]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": { "title": "Save Image" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { resumeCaptionJobs } from '~/server/utils/caption'
|
||||
|
||||
export default defineNitroPlugin(() => { resumeCaptionJobs() })
|
||||
@@ -0,0 +1,268 @@
|
||||
import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createJob, emitJob, getJob, restoreJob, type Job } from './jobs'
|
||||
import { getStill, stillPath } from './library'
|
||||
import { sharedGpuHeaders } from './sharedGpu'
|
||||
import { CAPTION_STYLES } from '~/shared/studio2/caption.mjs'
|
||||
|
||||
function settings() {
|
||||
const config = useRuntimeConfig()
|
||||
return {
|
||||
url: String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, ''),
|
||||
token: String(config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '')
|
||||
}
|
||||
}
|
||||
|
||||
export function captionConfigured() { return Boolean(settings().url) }
|
||||
|
||||
function pendingRoot() {
|
||||
return join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'caption-pending')
|
||||
}
|
||||
|
||||
export function captionPendingAlive(jobId: string) {
|
||||
if (!jobId) return false
|
||||
return existsSync(join(pendingRoot(), `${jobId}.json`))
|
||||
}
|
||||
|
||||
async function request(path: string, method = 'GET', body?: unknown) {
|
||||
const { url, token } = settings()
|
||||
if (!url) throw new Error('Caption host is not configured. Set COMFY_CONTROL_URL.')
|
||||
const response = await fetch(`${url}/caption/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(method === 'GET' ? {} : sharedGpuHeaders()),
|
||||
...(method === 'POST' ? { 'Content-Type': 'application/json' } : {})
|
||||
},
|
||||
body: method === 'POST' ? JSON.stringify(body) : body as BodyInit | undefined,
|
||||
...(method === 'PUT' ? { duplex: 'half' as const } : {}),
|
||||
signal: AbortSignal.timeout(method === 'PUT' ? 120_000 : 60_000)
|
||||
} as RequestInit)
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({})) as { message?: string; error?: string }
|
||||
throw Object.assign(new Error(detail.message || detail.error || `Caption host returned ${response.status}`), { statusCode: response.status })
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
function persist(record: Record<string, unknown>) {
|
||||
mkdirSync(pendingRoot(), { recursive: true })
|
||||
const path = join(pendingRoot(), `${record.id}.json`)
|
||||
writeFileSync(path + '.tmp', JSON.stringify(record))
|
||||
renameSync(path + '.tmp', path)
|
||||
}
|
||||
|
||||
function clearPending(id: string) {
|
||||
const path = join(pendingRoot(), `${id}.json`)
|
||||
if (existsSync(path)) unlinkSync(path)
|
||||
}
|
||||
|
||||
function writeSidecar(owner: string, stillId: string, text: string) {
|
||||
try {
|
||||
const image = stillPath(owner, stillId)
|
||||
if (!existsSync(image)) return
|
||||
writeFileSync(`${image}.txt`, text, 'utf8')
|
||||
} catch { /* optional sidecar */ }
|
||||
}
|
||||
|
||||
export async function cancelCaptionJob(job: Job) {
|
||||
await request(`jobs/${job.id}/cancel`, 'POST', {})
|
||||
job.status = 'cancelled'
|
||||
emitJob(job, { type: 'error', error: 'Cancelled', message: 'Cancelled' })
|
||||
}
|
||||
|
||||
async function settle(job: Job) {
|
||||
const { onLiveVideoSettled } = await import('./studioQueue')
|
||||
await onLiveVideoSettled(job)
|
||||
clearPending(job.id)
|
||||
}
|
||||
|
||||
async function watch(job: Job, record: {
|
||||
id: string
|
||||
owner: string
|
||||
stillId: string
|
||||
folderId: string
|
||||
captionStyle: string
|
||||
studio2Id?: string
|
||||
liveId: string
|
||||
}) {
|
||||
let failures = 0
|
||||
while (job.status !== 'cancelled') {
|
||||
try {
|
||||
const state = await (await request(`jobs/${job.id}`)).json() as {
|
||||
status: string
|
||||
message?: string
|
||||
error?: string
|
||||
progress?: number
|
||||
text?: string
|
||||
}
|
||||
if (job.status === 'cancelled') { await settle(job); return }
|
||||
if (state.status === 'error' || state.status === 'cancelled') {
|
||||
job.status = state.status === 'cancelled' ? 'cancelled' : 'error'
|
||||
job.error = state.error || state.message || 'Caption failed'
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
if (record.studio2Id) {
|
||||
const { readRecord, saveRecord } = await import('./studio2/store')
|
||||
try {
|
||||
const r = readRecord(record.owner, record.studio2Id)
|
||||
r.state = job.status === 'cancelled' ? 'cancelled' : 'failed'
|
||||
r.error = job.error
|
||||
r.finishedAt = Date.now()
|
||||
saveRecord(r)
|
||||
} catch { /* record may already be gone */ }
|
||||
}
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
if (state.status === 'complete') {
|
||||
const text = String(state.text || '').trim()
|
||||
job.message = 'Caption ready'
|
||||
job.progress = 100
|
||||
job.status = 'complete'
|
||||
;(job as Job & { resultText?: string }).resultText = text
|
||||
if (record.studio2Id) {
|
||||
const { readRecord, saveRecord } = await import('./studio2/store')
|
||||
const r = readRecord(record.owner, record.studio2Id)
|
||||
r.state = 'complete'
|
||||
r.resultText = text
|
||||
r.finishedAt = Date.now()
|
||||
r.savedAt = Date.now()
|
||||
saveRecord(r)
|
||||
}
|
||||
writeSidecar(record.owner, record.stillId, text)
|
||||
emitJob(job, { type: 'complete', message: 'Caption ready', progress: 100 })
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
failures = 0
|
||||
job.status = 'running'
|
||||
emitJob(job, {
|
||||
type: 'progress',
|
||||
message: state.message || 'Captioning',
|
||||
progress: Math.min(95, Math.max(1, Number(state.progress || 10)))
|
||||
})
|
||||
} catch (error) {
|
||||
failures++
|
||||
emitJob(job, { type: 'status', message: `Caption host check failed; retrying: ${error instanceof Error ? error.message : String(error)}` })
|
||||
if (failures >= 10) {
|
||||
job.status = 'error'
|
||||
job.error = 'Caption host unreachable.'
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
}
|
||||
await settle(job)
|
||||
}
|
||||
|
||||
export async function startCaptionJob(params: {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
stillId: string
|
||||
captionStyle: string
|
||||
studio2Id?: string
|
||||
name?: string
|
||||
}) {
|
||||
if (!CAPTION_STYLES.includes(params.captionStyle)) throw new Error('Unknown caption style.')
|
||||
getStill(params.ownerKey, params.stillId)
|
||||
const job = createJob('caption')
|
||||
job.caption = true
|
||||
job.library = {
|
||||
ownerKey: params.ownerKey,
|
||||
folderId: params.folderId,
|
||||
hideThumbnail: false,
|
||||
name: params.name || `Describe · ${params.captionStyle}`,
|
||||
prompt: params.captionStyle,
|
||||
aspect: 'image',
|
||||
width: 0,
|
||||
height: 0,
|
||||
steps: 1,
|
||||
turbo: true,
|
||||
seed: 0,
|
||||
stillId: params.stillId,
|
||||
engine: 'caption'
|
||||
}
|
||||
const record = {
|
||||
id: job.id,
|
||||
liveId: job.id,
|
||||
owner: params.ownerKey,
|
||||
stillId: params.stillId,
|
||||
folderId: params.folderId,
|
||||
captionStyle: params.captionStyle,
|
||||
studio2Id: params.studio2Id,
|
||||
createdAt: Date.now(),
|
||||
status: 'running'
|
||||
}
|
||||
persist(record)
|
||||
void (async () => {
|
||||
try {
|
||||
if (job.status === 'cancelled') { await settle(job); return }
|
||||
await request(`jobs/${job.id}/input`, 'PUT', createReadStream(stillPath(params.ownerKey, params.stillId)))
|
||||
if (job.status === 'cancelled') { await settle(job); return }
|
||||
try {
|
||||
await request('jobs', 'POST', { id: job.id, style: params.captionStyle })
|
||||
} catch {
|
||||
/* Confirm by ID; never caption twice on an HTTP timeout. */
|
||||
}
|
||||
await watch(job, record)
|
||||
} catch (error) {
|
||||
const statusCode = (error as { statusCode?: number })?.statusCode
|
||||
if (statusCode && statusCode >= 400 && statusCode < 500) {
|
||||
job.status = 'error'
|
||||
job.error = error instanceof Error ? error.message : String(error)
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
if (params.studio2Id) {
|
||||
try {
|
||||
const { readRecord, saveRecord } = await import('./studio2/store')
|
||||
const r = readRecord(params.ownerKey, params.studio2Id)
|
||||
r.state = 'failed'
|
||||
r.error = job.error
|
||||
r.finishedAt = Date.now()
|
||||
saveRecord(r)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
emitJob(job, { type: 'status', message: `Checking caption submission: ${error instanceof Error ? error.message : String(error)}` })
|
||||
await watch(job, record)
|
||||
}
|
||||
})()
|
||||
return job
|
||||
}
|
||||
|
||||
export function resumeCaptionJobs() {
|
||||
if (!existsSync(pendingRoot())) return
|
||||
for (const file of readdirSync(pendingRoot()).filter(file => /^[a-zA-Z0-9-]+\.json$/.test(file))) {
|
||||
try {
|
||||
const record = JSON.parse(readFileSync(join(pendingRoot(), file), 'utf8'))
|
||||
if (getJob(record.id)) continue
|
||||
const job = restoreJob({
|
||||
id: record.liveId || record.id,
|
||||
clientId: record.clientId || crypto.randomUUID(),
|
||||
promptId: '',
|
||||
startedAt: record.createdAt || Date.now(),
|
||||
library: {
|
||||
ownerKey: record.owner,
|
||||
folderId: record.folderId,
|
||||
hideThumbnail: false,
|
||||
prompt: record.captionStyle || 'descriptive',
|
||||
aspect: 'image',
|
||||
width: 0,
|
||||
height: 0,
|
||||
steps: 1,
|
||||
turbo: true,
|
||||
seed: 0,
|
||||
stillId: record.stillId,
|
||||
engine: 'caption'
|
||||
}
|
||||
})
|
||||
job.caption = true
|
||||
job.kind = 'caption'
|
||||
job.message = 'Reconnecting to caption host'
|
||||
void watch(job, record)
|
||||
} catch { /* Preserve invalid records for diagnosis. */ }
|
||||
}
|
||||
}
|
||||
@@ -139,44 +139,11 @@ 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
|
||||
payload: structuredClone(entry.payload)
|
||||
})
|
||||
await kickStudioQueue()
|
||||
return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) }
|
||||
|
||||
@@ -36,9 +36,11 @@ export interface Job {
|
||||
upscale?: boolean
|
||||
yueGp?: boolean
|
||||
yue2?: boolean
|
||||
caption?: boolean
|
||||
resultText?: string
|
||||
musicActivity?: { checkedAt: number; running: boolean }
|
||||
id: string
|
||||
kind?: 'video' | 'edit' | 'music'
|
||||
kind?: 'video' | 'edit' | 'music' | 'caption'
|
||||
promptId?: string
|
||||
clientId: string
|
||||
status: JobStatus
|
||||
|
||||
@@ -8,21 +8,15 @@ 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 qwen21T2iTurboTemplate from '../../assets/studio2_qwen21_t2i_turbo.json'
|
||||
import qwen21EditTurboTemplate from '../../assets/studio2_qwen21_edit_turbo.json'
|
||||
import qwen21PeT2iTemplate from '../../assets/studio2_qwen21_pe_t2i.json'
|
||||
import qwen21PeEditTemplate from '../../assets/studio2_qwen21_pe_edit.json';
|
||||
import qwen21Template from '../../assets/studio2_qwen21_t2i.json';
|
||||
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
||||
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
||||
import { resolveQwen21Size } from '~/shared/studio2/qwen21-size.mjs';
|
||||
import { QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from '~/shared/studio2/qwen21-turbo.mjs';
|
||||
import { 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, freeComfyVram } from '../comfy';
|
||||
import { comfyFetch, queuePrompt, fetchHistory, extractVideo } from '../comfy';
|
||||
import { extractEditedImage } from '../imageComfy';
|
||||
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
|
||||
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
|
||||
@@ -42,111 +36,10 @@ 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) && r.request?.engine !== 'qwen21' && needsFit(data, r.request.settings.width, r.request.settings.height)) {
|
||||
if (!NEVER_FIT.has(name) && needsFit(data, r.request.settings.width, r.request.settings.height)) {
|
||||
data = await fitStill(data, r.request.settings.width, r.request.settings.height);
|
||||
fitted = true;
|
||||
}
|
||||
@@ -236,87 +129,26 @@ async function prepareGraph(r: any) {
|
||||
}
|
||||
}
|
||||
else if (q.engine === 'qwen21') {
|
||||
if (!['generate', 'edit'].includes(q.mode))
|
||||
throw new Error('Qwen 2.1 supports Generate and Edit.')
|
||||
const turbo = !!q.turbo
|
||||
if (turbo) {
|
||||
q.lora = QWEN21_TURBO_LORA
|
||||
q.sigmas = QWEN21_TURBO_SIGMAS
|
||||
s.steps = QWEN21_TURBO_STEPS
|
||||
s.cfg = 1
|
||||
}
|
||||
const negative = turbo ? '' : stylePrompt(q.imageStyles, true)
|
||||
if (q.mode === 'edit') {
|
||||
if (!a) throw new Error('Choose the source still.')
|
||||
let prompt = String(q.compiledPrompt || '')
|
||||
if (!/<image1>/i.test(prompt))
|
||||
prompt = `Keep the subject in <image1>. ${prompt}`.trim()
|
||||
graph = structuredClone(turbo ? qwen21EditTurboTemplate : qwen21EditTemplate)
|
||||
graph['10'].inputs.image = a
|
||||
if (b) {
|
||||
graph['11'] = {
|
||||
inputs: { image: b },
|
||||
class_type: 'LoadImage',
|
||||
_meta: { title: 'image_2 optional' }
|
||||
}
|
||||
graph['9'].inputs['images.image_2'] = ['11', 0]
|
||||
}
|
||||
graph['9'].inputs.prompt = prompt
|
||||
graph['9'].inputs.negative_prompt = negative
|
||||
// Official edit: sampler latent comes from TextEncode (sized from image_1).
|
||||
graph['9'].inputs.resolution = 1024
|
||||
if (turbo) {
|
||||
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
|
||||
graph['7'].inputs.strength = 1.0
|
||||
graph['31'].inputs.noise_seed = s.seed
|
||||
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
|
||||
graph['36'].inputs.filename_prefix = prefix + '/image'
|
||||
r.sampleLatent = 'qwen21 edit latent'
|
||||
r.graphId = 'studio2_qwen21_edit_turbo.json'
|
||||
} else {
|
||||
graph['15'].inputs.seed = s.seed
|
||||
graph['15'].inputs.steps = s.steps || 25
|
||||
graph['15'].inputs.cfg = s.cfg ?? 1
|
||||
graph['15'].inputs.sampler_name = 'euler'
|
||||
graph['15'].inputs.scheduler = 'simple'
|
||||
graph['21'].inputs.filename_prefix = prefix + '/image'
|
||||
r.sampleLatent = 'qwen21 edit latent'
|
||||
r.graphId = 'studio2_qwen21_edit.json'
|
||||
}
|
||||
r.sampleDenoise = null
|
||||
r.heroReferenceAttached = false
|
||||
} else {
|
||||
// Bench aspect is law for Generate. PE wh_ratio is advisory only (never sizes the canvas).
|
||||
const size = resolveQwen21Size(s.aspect)
|
||||
s.width = size.width
|
||||
s.height = size.height
|
||||
graph = structuredClone(turbo ? qwen21T2iTurboTemplate : qwen21Template)
|
||||
graph['9'].inputs.prompt = q.compiledPrompt
|
||||
graph['9'].inputs.negative_prompt = negative
|
||||
graph['9'].inputs.resolution = 1024
|
||||
graph['16'].inputs.width = size.width
|
||||
graph['16'].inputs.height = size.height
|
||||
if (turbo) {
|
||||
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
|
||||
graph['7'].inputs.strength = 1.0
|
||||
graph['31'].inputs.noise_seed = s.seed
|
||||
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
|
||||
graph['36'].inputs.filename_prefix = prefix + '/image'
|
||||
r.sampleLatent = 'qwen21 empty latent'
|
||||
r.graphId = 'studio2_qwen21_t2i_turbo.json'
|
||||
} else {
|
||||
graph['15'].inputs.seed = s.seed
|
||||
graph['15'].inputs.steps = s.steps || 25
|
||||
graph['15'].inputs.cfg = s.cfg ?? 1
|
||||
graph['15'].inputs.sampler_name = 'euler'
|
||||
graph['15'].inputs.scheduler = 'simple'
|
||||
graph['21'].inputs.filename_prefix = prefix + '/image'
|
||||
r.sampleLatent = 'qwen21 empty latent'
|
||||
r.graphId = 'studio2_qwen21_t2i.json'
|
||||
}
|
||||
r.sampleDenoise = null
|
||||
r.heroReferenceAttached = false
|
||||
}
|
||||
if (q.mode !== 'generate')
|
||||
throw new Error('Qwen 2.1 is T2I in this build.');
|
||||
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);
|
||||
@@ -363,31 +195,56 @@ 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…' });
|
||||
let queued: { prompt_id: string; number?: number }
|
||||
try {
|
||||
queued = await queuePrompt(graph, job.clientId);
|
||||
} catch (error: any) {
|
||||
const msg = String(error?.statusMessage || error?.message || error)
|
||||
if (r.request?.turbo && /lora|ViggleTurbo|not in list|value_not_in_list/i.test(msg))
|
||||
throw new Error(`Qwen Turbo LoRA missing on host (${QWEN21_TURBO_LORA}). Install the r128 file under models/loras and restart Comfy.`)
|
||||
throw error
|
||||
}
|
||||
const queued = await queuePrompt(graph, job.clientId);
|
||||
job.promptId = queued.prompt_id;
|
||||
r.promptId = queued.prompt_id;
|
||||
r.renderStartedAt = Date.now();
|
||||
update(r, 'rendering');
|
||||
}
|
||||
const history = await waitPromptHistory(r, job, r.promptId);
|
||||
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 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;
|
||||
|
||||
@@ -13,12 +13,10 @@ import { imageV2StackSpecials } from '~/utils/imageV2'
|
||||
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||
|
||||
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
||||
export type StudioJobKind = 'video' | 'edit' | 'music'
|
||||
export type StudioJobKind = 'video' | 'edit' | 'music' | 'caption'
|
||||
|
||||
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
|
||||
@@ -42,6 +40,7 @@ export interface StudioJobPayload {
|
||||
useIdentityRefs: boolean
|
||||
stillId?: string
|
||||
stillFilename?: string
|
||||
captionStyle?: string
|
||||
hideThumbnail: boolean
|
||||
hideInput?: boolean
|
||||
folderLocked?: boolean
|
||||
@@ -226,6 +225,7 @@ export function listStudioJobs(owner: string) {
|
||||
export function studioJobKind(job: Pick<StudioJob, 'kind'> | { kind?: string }) {
|
||||
if (job.kind === 'edit') return 'edit'
|
||||
if (job.kind === 'music') return 'music'
|
||||
if (job.kind === 'caption') return 'caption'
|
||||
return 'video'
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ function failZombieLiveJob(job: Job, error: string) {
|
||||
function sweepStaleLiveJobs() {
|
||||
const now = Date.now()
|
||||
for (const job of listJobs()) {
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) continue
|
||||
if (job.saving) continue
|
||||
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
|
||||
failZombieLiveJob(job, 'Job never started')
|
||||
@@ -310,12 +310,12 @@ async function reapZombieLiveJobs() {
|
||||
const { fetchHistory } = await import('~/server/utils/comfy')
|
||||
for (const job of listJobs()) {
|
||||
// Never interrupt download/stitch/library save — Comfy is idle then by design.
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) continue
|
||||
if (job.saving) continue
|
||||
if (job.library?.chainContinuing) continue
|
||||
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
|
||||
if (jobIsLocallySubmitting(job)) continue
|
||||
const zombieMs = (job.kind === 'edit' || job.kind === 'music') ? 45_000 : ZOMBIE_EMPTY_COMFY_MS
|
||||
const zombieMs = (job.kind === 'edit' || job.kind === 'music' || job.kind === 'caption') ? 45_000 : ZOMBIE_EMPTY_COMFY_MS
|
||||
if (jobAgeMs(job) < zombieMs) continue
|
||||
if (!job.promptId) {
|
||||
if (jobAgeMs(job) >= QUEUED_GRACE_MS) failZombieLiveJob(job, 'Job never started on ComfyUI')
|
||||
@@ -355,7 +355,7 @@ async function reapZombieLiveJobs() {
|
||||
}
|
||||
|
||||
function liveJobOwnsGpu(job: Job) {
|
||||
if ((job.studio2 || job.yueGp || job.yue2 || job.upscale) && ['running', 'queued', 'uploading'].includes(job.status)) return true
|
||||
if ((job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) && ['running', 'queued', 'uploading'].includes(job.status)) return true
|
||||
if (job.library?.stopAfterCurrent) return false
|
||||
if (job.library?.chainContinuing) return true
|
||||
if (job.saving) return true
|
||||
@@ -375,7 +375,7 @@ function liveJobOwnsGpu(job: Job) {
|
||||
*/
|
||||
function clearDeadGpuClaimsForForceStart() {
|
||||
for (const live of listJobs()) {
|
||||
if (live.studio2 || live.yueGp || live.yue2 || live.upscale) continue
|
||||
if (live.studio2 || live.yueGp || live.yue2 || live.upscale || live.caption) continue
|
||||
if (live.saving) continue
|
||||
if (jobIsLocallySubmitting(live)) continue
|
||||
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
|
||||
@@ -469,8 +469,11 @@ export async function addStudioJob(params: {
|
||||
kind?: StudioJobKind
|
||||
}) {
|
||||
const now = Date.now()
|
||||
const kind = params.kind === 'edit' ? 'edit' : params.kind === 'music' ? 'music' : 'video'
|
||||
const shotCount = kind === 'music'
|
||||
const kind = params.kind === 'edit' ? 'edit'
|
||||
: params.kind === 'music' ? 'music'
|
||||
: params.kind === 'caption' ? 'caption'
|
||||
: 'video'
|
||||
const shotCount = kind === 'music' || kind === 'caption'
|
||||
? 1
|
||||
: kind === 'edit'
|
||||
? 1 + (params.payload.passes?.length || 0)
|
||||
@@ -606,6 +609,10 @@ export async function clearStuckStudioWork(owner: string) {
|
||||
const { cancelYue2Job } = await import('./yue2')
|
||||
await cancelYue2Job(job)
|
||||
}
|
||||
if (job.caption) {
|
||||
const { cancelCaptionJob } = await import('./caption')
|
||||
await cancelCaptionJob(job)
|
||||
}
|
||||
job.status = 'cancelled'
|
||||
job.error = 'Cleared by force reset'
|
||||
if (job.library) {
|
||||
@@ -678,6 +685,11 @@ async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
|
||||
await cancelYue2Job(live)
|
||||
return
|
||||
}
|
||||
if (live?.caption) {
|
||||
const { cancelCaptionJob } = await import('./caption')
|
||||
await cancelCaptionJob(live)
|
||||
return
|
||||
}
|
||||
if (live) {
|
||||
live.status = 'cancelled'
|
||||
if (live.library) {
|
||||
@@ -889,6 +901,10 @@ function pendingAlive(job: StudioJob) {
|
||||
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yue2-pending', `${job.liveJobId}.json`)
|
||||
if (existsSync(root)) return true
|
||||
}
|
||||
if (job.kind === 'caption') {
|
||||
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'caption-pending', `${job.liveJobId}.json`)
|
||||
if (existsSync(root)) return true
|
||||
}
|
||||
}
|
||||
if (!job.shotQueueId) return false
|
||||
return listPendingJobs().some(pending => (
|
||||
@@ -1397,6 +1413,42 @@ async function startStudioExtendJob(item: StudioJob) {
|
||||
}
|
||||
}
|
||||
|
||||
async function startStudioCaptionJob(item: StudioJob) {
|
||||
let live: import('~/server/utils/jobs').Job | undefined
|
||||
try {
|
||||
const { startCaptionJob } = await import('./caption')
|
||||
const style = String(item.payload.captionStyle || 'descriptive')
|
||||
const stillId = String(item.payload.stillId || '')
|
||||
if (!stillId) throw new Error('Caption requires a still.')
|
||||
live = await startCaptionJob({
|
||||
ownerKey: item.ownerKey,
|
||||
folderId: item.payload.folderId,
|
||||
stillId,
|
||||
captionStyle: style,
|
||||
studio2Id: item.payload.studio2Id,
|
||||
name: item.payload.name
|
||||
})
|
||||
await markStudioLive(item.ownerKey, item.id, live.id)
|
||||
if (item.payload.studio2Id) {
|
||||
const { readRecord, saveRecord } = await import('./studio2/store')
|
||||
const record = readRecord(item.ownerKey, item.payload.studio2Id)
|
||||
record.state = 'rendering'
|
||||
record.liveId = live.id
|
||||
record.startedAt = Date.now()
|
||||
record.updatedAt = Date.now()
|
||||
saveRecord(record)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')) {
|
||||
live.status = 'error'
|
||||
live.error = message
|
||||
}
|
||||
await parkStudioOnStartFailure(item.ownerKey, item.id, message)
|
||||
kickStudioQueue()
|
||||
}
|
||||
}
|
||||
|
||||
async function startStudioMusicJob(item: StudioJob) {
|
||||
let live: import('~/server/utils/jobs').Job | undefined
|
||||
try {
|
||||
@@ -1439,6 +1491,10 @@ export async function startStudioJob(item: StudioJob) {
|
||||
}
|
||||
|
||||
async function startStudioJobReserved(item: StudioJob) {
|
||||
if (studioJobKind(item) === 'caption') {
|
||||
await startStudioCaptionJob(item)
|
||||
return
|
||||
}
|
||||
if (item.payload.studio2Id) {
|
||||
try {
|
||||
const { startStudio2Job } = await import('./studio2/runner')
|
||||
@@ -1703,7 +1759,7 @@ export async function onLiveVideoSettled(job: Job) {
|
||||
return
|
||||
}
|
||||
const remaining = remainingStudioShots(job)
|
||||
const wakeFail = !job.studio2 && !job.yueGp && !job.yue2 && !job.upscale && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
|
||||
const wakeFail = !job.studio2 && !job.yueGp && !job.yue2 && !job.upscale && !job.caption && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
|
||||
const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail
|
||||
|
||||
await mutateStore(owner, (store) => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
export const CAPTION_STYLES = ['descriptive', 'klein_prompt', 'delta', 'tags']
|
||||
|
||||
export const DELTA_LOCKED_LOOK =
|
||||
'short light-brown bob, pink tank, light blue shorts, open green Slytherin robe with red-gold trim, barefoot'
|
||||
|
||||
export function captionStylePrompt(style) {
|
||||
switch (style) {
|
||||
case 'klein_prompt':
|
||||
return 'Write one self-contained image generation prompt for Flux Klein. Include subject, pose, clothing, setting, lighting, and camera. No preamble or quotes.'
|
||||
case 'delta':
|
||||
return `The character's locked baseline look is: ${DELTA_LOCKED_LOOK}. Describe ONLY what changed from that baseline in this image. Do not restate the locked look.`
|
||||
case 'tags':
|
||||
return 'List comma-separated descriptive tags for this image. Tags only, no sentences.'
|
||||
case 'descriptive':
|
||||
default:
|
||||
return 'Describe this image in clear, detailed prose. Cover subject, appearance, clothing, pose, setting, and lighting.'
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCaptionRequest(raw) {
|
||||
const fail = message => { throw Object.assign(new Error(message), { statusCode: 400 }) }
|
||||
const body = raw || {}
|
||||
const style = String(body.captionStyle || body.style || 'descriptive')
|
||||
if (!CAPTION_STYLES.includes(style)) fail('Unknown caption style.')
|
||||
const folderId = String(body.folderId || '')
|
||||
if (!folderId) fail('Choose a folder.')
|
||||
const stillId = String(body.stillId || body.imageAId || '')
|
||||
const sourcePath = String(body.sourcePath || '')
|
||||
if (!stillId && !sourcePath) fail('Choose a still to describe.')
|
||||
return {
|
||||
mode: 'caption',
|
||||
folderId,
|
||||
stillId,
|
||||
sourcePath,
|
||||
captionStyle: style,
|
||||
promptSections: { action: `Describe · ${style}` },
|
||||
compiledPrompt: captionStylePrompt(style),
|
||||
engine: 'caption',
|
||||
imageStyles: { positive: [], negative: [] },
|
||||
hideOutput: false,
|
||||
lockFace: false,
|
||||
lockOutfit: false,
|
||||
hideInput: false,
|
||||
identityStillId: '',
|
||||
imageAId: stillId,
|
||||
imageBId: '',
|
||||
maskId: '',
|
||||
startClipId: '',
|
||||
endStillId: '',
|
||||
guides: [],
|
||||
batch: [],
|
||||
settings: {
|
||||
aspect: 'auto',
|
||||
fps: 24,
|
||||
width: 0,
|
||||
height: 0,
|
||||
steps: 1,
|
||||
cfg: 0,
|
||||
seedMode: 'random',
|
||||
seed: 0,
|
||||
duration: 0,
|
||||
turbo: true,
|
||||
loraStack: []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import {normalizeStyles,stylePrompt} from './styles.mjs'
|
||||
import { queueSeeds } from './seed.mjs'
|
||||
import { sectionsFrom, compileSections } from './prompt.mjs'
|
||||
import { QWEN21_ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
|
||||
import { QWEN21_TURBO_CFG, QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from './qwen21-turbo.mjs'
|
||||
export { sectionsFrom } from './prompt.mjs'
|
||||
export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right']
|
||||
export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.'
|
||||
@@ -36,14 +34,14 @@ export function validateRequest(raw, xaigen = false) {
|
||||
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 (engine === 'qwen21' && mode !== 'generate') fail('Qwen 2.1 is T2I in this build.')
|
||||
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 (['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 ((video || ['edit','compose','iterate'].includes(mode)) && (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}
|
||||
@@ -52,10 +50,8 @@ export function validateRequest(raw, xaigen = false) {
|
||||
const qwen = engine === 'qwen21'
|
||||
const defaultSteps = video ? 8 : qwen ? 25 : 24
|
||||
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
|
||||
const aspect = QWEN21_ASPECT_IDS.has(raw.settings?.aspect) ? raw.settings.aspect : 'auto'
|
||||
const qwenSize = qwen && mode === 'generate' ? resolveQwen21Size(aspect) : null
|
||||
const defaultWidth = qwenSize ? qwenSize.width : qwen ? 1024 : 1344
|
||||
const defaultHeight = qwenSize ? qwenSize.height : qwen ? 1024 : 768
|
||||
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)
|
||||
@@ -65,16 +61,10 @@ export function validateRequest(raw, xaigen = false) {
|
||||
}
|
||||
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
||||
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
||||
const enhancePrompt = qwen && !video && !!raw.enhancePrompt
|
||||
const turbo = qwen && !video && !!raw.turbo
|
||||
const width = qwenSize ? qwenSize.width : number('width',defaultWidth,256,4096)
|
||||
const height = qwenSize ? qwenSize.height : number('height',defaultHeight,256,4096)
|
||||
const steps = turbo ? QWEN21_TURBO_STEPS : number('steps',defaultSteps,1,100)
|
||||
const cfg = turbo ? QWEN21_TURBO_CFG : number('cfg',defaultCfg,0,20)
|
||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, enhancePrompt, turbo, ...(turbo ? { lora: QWEN21_TURBO_LORA, sigmas: QWEN21_TURBO_SIGMAS } : {}), lockFace:qwen ? false : raw.lockFace !== false, lockOutfit:qwen ? false : raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:qwen ? '' : (raw.identityStillId || ''), imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:qwen ? false : raw.lockFace !== false, lockOutfit:qwen ? false : raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:qwen ? '' : (raw.identityStillId || ''), imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
|
||||
settings: { aspect, fps:number('fps',24,1,60), width, height, steps, cfg, seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:qwen?[]:(Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[]) },
|
||||
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,39 +1,25 @@
|
||||
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
||||
import { restoreInput, sectionsFrom } from './contracts.mjs'
|
||||
import { normalizeStyles } from './styles.mjs'
|
||||
import { QWEN21_ASPECT_IDS as ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
|
||||
export { resolveQwen21Size, QWEN21_SIZE_TABLE } from './qwen21-size.mjs'
|
||||
|
||||
export const aspectOptions = [
|
||||
{id:'auto',label:'Automatic',hint:'Match start or hero still'},
|
||||
{id:'16:9',label:'16:9',hint:'Widescreen'},
|
||||
{id:'9:16',label:'9:16',hint:'Tall'},
|
||||
{id:'1:1',label:'1:1',hint:'Square'},
|
||||
{id:'4:3',label:'4:3',hint:'Standard'},
|
||||
{id:'3:4',label:'3:4',hint:'Portrait standard'},
|
||||
{id:'3:2',label:'3:2',hint:'Photo'},
|
||||
{id:'2:3',label:'2:3',hint:'Portrait photo'},
|
||||
{id:'21:9',label:'21:9',hint:'Ultrawide'},
|
||||
{id:'9:21',label:'9:21',hint:'Ultra-tall'}
|
||||
]
|
||||
|
||||
import { restoreInput } from './contracts.mjs'
|
||||
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
|
||||
export function resolveQwen21Size(aspect) {
|
||||
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]}
|
||||
return {width:1024,height:1024}
|
||||
}
|
||||
export function resolveSize(aspect, source, fallback = {width:1344,height:768}, engine = '') {
|
||||
if (engine === 'qwen21') return resolveQwen21Size(aspect, source)
|
||||
if (engine === 'qwen21') return resolveQwen21Size(aspect)
|
||||
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}
|
||||
const factor = Math.min(1344/Math.max(source.width,source.height),768/Math.min(source.width,source.height))
|
||||
return {width:Math.max(256,Math.round(source.width*factor/32)*32),height:Math.max(256,Math.round(source.height*factor/32)*32)}
|
||||
}
|
||||
|
||||
export { ASPECT_IDS }
|
||||
|
||||
export function bindClip(form, clip, xaigen = false, memory) {
|
||||
if (!clip?.id) return form
|
||||
const saved = clip.studio2 || clip.job || {}
|
||||
const settings = saved.settings || clip.settings || {}
|
||||
const next = {...form, startClipId:clip.id, mode:'extend', settings:{...form.settings}}
|
||||
next.settings.aspect=ASPECT_IDS.has(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto')
|
||||
next.settings.aspect=['auto','16:9','9:16','1:1'].includes(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto')
|
||||
for (const key of ['duration','width','height','fps','loraStack','turbo','steps','cfg']) {
|
||||
if (settings[key] == null) continue
|
||||
try { next.settings[key] = typeof structuredClone === 'function' ? structuredClone(settings[key]) : JSON.parse(JSON.stringify(settings[key])) }
|
||||
@@ -78,72 +64,6 @@ 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 = ASPECT_IDS.has(settings.aspect) ? settings.aspect : 'auto'
|
||||
const loraStack = Array.isArray(settings.loraStack) ? settings.loraStack.map(item => ({
|
||||
name: item.name,
|
||||
strengthModel: Number(item.strengthModel ?? item.strength ?? 1),
|
||||
strengthClip: Number(item.strengthClip ?? item.strengthModel ?? item.strength ?? 1)
|
||||
})) : []
|
||||
const guides = (Array.isArray(src.guides) ? src.guides : []).map(g => {
|
||||
const stillId = keepStill(g.stillId, 'guide still')
|
||||
return stillId ? { stillId, frame: g.frame } : null
|
||||
}).filter(Boolean)
|
||||
const next = {
|
||||
...form,
|
||||
mode,
|
||||
engine,
|
||||
enhancePrompt: engine === 'qwen21' && !!src.enhancePrompt,
|
||||
turbo: engine === 'qwen21' && !!src.turbo,
|
||||
lockFace: src.lockFace !== false,
|
||||
lockOutfit: src.lockOutfit !== false,
|
||||
hideInput: !!src.hideInput,
|
||||
folderId: src.folderId || form.folderId,
|
||||
identityStillId: keepStill(src.identityStillId, 'hero'),
|
||||
imageAId: keepStill(src.imageAId, 'start'),
|
||||
imageBId: keepStill(src.imageBId, 'second still'),
|
||||
maskId: keepStill(src.maskId, 'mask'),
|
||||
startClipId: keepClip(src.startClipId, 'start clip'),
|
||||
endStillId: keepStill(src.endStillId, 'end still'),
|
||||
guides,
|
||||
imageStyles: normalizeStyles(src.imageStyles || { positive: [], negative: [] }),
|
||||
promptSections: sectionsFrom(src.promptSections || { action: src.compiledPrompt || src.prompt || '' }),
|
||||
settings: {
|
||||
...form.settings,
|
||||
...settings,
|
||||
aspect,
|
||||
seed: settings.seed ?? form.settings?.seed ?? null,
|
||||
seedMode: 'locked',
|
||||
loraStack
|
||||
}
|
||||
}
|
||||
return { form: restoreLoras(next, loraStack, xaigen, memory), missing: missing[0] || '', warnings: missing }
|
||||
}
|
||||
|
||||
export function restoreAll(form,item,xaigen=false,memory) {
|
||||
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'}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/** Qwen-safe ~1 MP grid (÷32). Do not use native 2K bins on 16 GB. */
|
||||
export const QWEN21_SIZE_TABLE = {
|
||||
'1:1': [1024, 1024],
|
||||
'16:9': [1536, 864],
|
||||
'9:16': [864, 1536],
|
||||
'4:3': [1152, 864],
|
||||
'3:4': [864, 1152],
|
||||
'3:2': [1248, 832],
|
||||
'2:3': [832, 1248],
|
||||
'21:9': [1536, 672],
|
||||
'9:21': [672, 1536]
|
||||
}
|
||||
|
||||
export const QWEN21_ASPECT_IDS = new Set(['auto', ...Object.keys(QWEN21_SIZE_TABLE)])
|
||||
|
||||
function parseAspectRatio(aspect) {
|
||||
const m = String(aspect || '').trim().match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/)
|
||||
if (!m) return null
|
||||
const w = Number(m[1]), h = Number(m[2])
|
||||
if (!(w > 0 && h > 0)) return null
|
||||
return w / h
|
||||
}
|
||||
|
||||
function nearestQwen21Aspect(aspect) {
|
||||
const target = parseAspectRatio(aspect)
|
||||
if (target == null) return null
|
||||
let best = '1:1', bestDiff = Infinity
|
||||
for (const [key, [w, h]] of Object.entries(QWEN21_SIZE_TABLE)) {
|
||||
const diff = Math.abs(w / h - target)
|
||||
if (diff < bestDiff) { bestDiff = diff; best = key }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function resolveQwen21Size(aspect, source) {
|
||||
const key = String(aspect || 'auto').trim()
|
||||
if (QWEN21_SIZE_TABLE[key]) {
|
||||
const [width, height] = QWEN21_SIZE_TABLE[key]
|
||||
return { width, height }
|
||||
}
|
||||
if (key === 'auto') {
|
||||
if (source?.width && source?.height) {
|
||||
const nearest = nearestQwen21Aspect(`${source.width}:${source.height}`)
|
||||
if (nearest) {
|
||||
const [width, height] = QWEN21_SIZE_TABLE[nearest]
|
||||
return { width, height }
|
||||
}
|
||||
}
|
||||
return { width: 1024, height: 1024 }
|
||||
}
|
||||
const nearest = nearestQwen21Aspect(key)
|
||||
if (nearest) {
|
||||
const [width, height] = QWEN21_SIZE_TABLE[nearest]
|
||||
return { width, height }
|
||||
}
|
||||
console.warn(`[qwen21] unknown aspect "${key}"; using 1024×1024`)
|
||||
return { width: 1024, height: 1024 }
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/** Viggle DMD turbo LoRA on the existing Qwen 2.1 GGUF (not a separate engine). */
|
||||
export const QWEN21_TURBO_LORA = 'Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors'
|
||||
export const QWEN21_TURBO_SIGMAS = '1.0, 0.9375, 0.875, 0.75, 0.5, 0.25'
|
||||
export const QWEN21_TURBO_STEPS = 6
|
||||
export const QWEN21_TURBO_CFG = 1
|
||||
@@ -1,4 +1,4 @@
|
||||
export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
|
||||
export function gpuStatus(jobs, health, upscales=[], musicJobs=[], captionJobs=[]) {
|
||||
if (!health) return {label:'Status unavailable',tone:'unknown',detail:'Checking GPU connection…'}
|
||||
const upscale=upscales.find(j=>j.status==='running')
|
||||
if(upscale)return {label:'Upscaling',tone:'busy',detail:upscale.name || 'Video upscale'}
|
||||
@@ -7,9 +7,12 @@ export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
|
||||
const stage=music.live?.message || music.live?.stage || music.waitReason || 'YuE2'
|
||||
return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)}
|
||||
}
|
||||
const caption=captionJobs.find(j=>['waiting','waking','submitting','rendering','saving'].includes(j.state) || j.state==='queued' || j.state==='held')
|
||||
if(caption)return {label:caption.state==='waiting'?'Queued':'Describing',tone:'busy',detail:caption.request?.captionStyle || 'Caption'}
|
||||
if(health.caption?.busy)return {label:'Describing',tone:'busy',detail:'Caption model is using the GPU'}
|
||||
if(health.yue2?.busy)return {label:'Generating',tone:'busy',detail:'YuE2 is using the GPU'}
|
||||
const job=jobs.find(j=>['waking','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'}
|
||||
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'}
|
||||
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,13 +1,5 @@
|
||||
export const styleGroups={Render:['Photorealistic','Pixar','3D Disney','Comic','Watercolor','Oil painting'],Quality:['Realistic skin','Skin pores','Fine detail','Natural texture'],Lighting:['Golden hour','Soft studio','Window light','Overcast','Cinematic lighting']}
|
||||
export const 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 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 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)}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createCaptionHost, validateCaptionHostRequest, captionModelFiles } from '../scripts/caption-host.mjs'
|
||||
import { validateCaptionRequest, captionStylePrompt, CAPTION_STYLES, DELTA_LOCKED_LOOK } from '../shared/studio2/caption.mjs'
|
||||
|
||||
test('caption styles and delta lock text', () => {
|
||||
assert.deepEqual(CAPTION_STYLES, ['descriptive', 'klein_prompt', 'delta', 'tags'])
|
||||
assert.match(captionStylePrompt('delta'), new RegExp(DELTA_LOCKED_LOOK))
|
||||
assert.match(captionStylePrompt('klein_prompt'), /Flux Klein/)
|
||||
assert.match(captionStylePrompt('tags'), /comma-separated/)
|
||||
})
|
||||
|
||||
test('validateCaptionRequest requires folder + still', () => {
|
||||
assert.throws(() => validateCaptionRequest({}), /folder/)
|
||||
assert.throws(() => validateCaptionRequest({ folderId: 'f1' }), /still/)
|
||||
assert.throws(() => validateCaptionRequest({ folderId: 'f1', stillId: 's1', captionStyle: 'nope' }), /style/)
|
||||
const ok = validateCaptionRequest({ folderId: 'f1', stillId: 's1', captionStyle: 'descriptive' })
|
||||
assert.equal(ok.mode, 'caption')
|
||||
assert.equal(ok.captionStyle, 'descriptive')
|
||||
assert.equal(ok.lockFace, false)
|
||||
assert.equal(ok.lockOutfit, false)
|
||||
})
|
||||
|
||||
test('host validates job body', () => {
|
||||
assert.throws(() => validateCaptionHostRequest({ id: 'bad', style: 'descriptive' }))
|
||||
assert.deepEqual(
|
||||
validateCaptionHostRequest({ id: 'caption-job-123456', style: 'tags' }),
|
||||
{ id: 'caption-job-123456', style: 'tags', imagePath: '' }
|
||||
)
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'aigen-caption-'))
|
||||
const models = join(root, 'models')
|
||||
mkdirSync(models)
|
||||
writeFileSync(join(models, captionModelFiles.model), 'model')
|
||||
writeFileSync(join(models, captionModelFiles.mmproj), 'mmproj')
|
||||
const llama = join(root, 'llama-server.exe')
|
||||
writeFileSync(llama, '')
|
||||
const dataDir = join(root, 'jobs')
|
||||
const child = new EventEmitter()
|
||||
child.pid = 4242
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = () => { setImmediate(() => child.emit('close', 0)); return true }
|
||||
child.exitCode = null
|
||||
const calls = []
|
||||
let fetchCount = 0
|
||||
const fetchImpl = async (url, init) => {
|
||||
fetchCount++
|
||||
if (String(url).endsWith('/health')) return { ok: true }
|
||||
if (String(url).includes('/v1/chat/completions')) {
|
||||
assert.match(JSON.stringify(JSON.parse(init.body)), /Describe this image|Flux Klein|locked baseline|comma-separated/)
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ choices: [{ message: { content: 'a warm caption' } }] })
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`)
|
||||
}
|
||||
const host = createCaptionHost({
|
||||
modelsDir: models,
|
||||
llamaServer: llama,
|
||||
dataDir,
|
||||
prepare: async () => {},
|
||||
leaseValid: () => true,
|
||||
spawnProcess: (...args) => { calls.push(args); setImmediate(() => {}); return child },
|
||||
fetchImpl
|
||||
})
|
||||
return { host, child, calls, root, dataDir, fetchCount: () => fetchCount }
|
||||
}
|
||||
|
||||
test('host loads llama-server, captions once, and unloads', async () => {
|
||||
const f = fixture()
|
||||
const jobDir = join(f.dataDir, 'caption-job-123456')
|
||||
mkdirSync(jobDir, { recursive: true })
|
||||
writeFileSync(join(jobDir, 'input.png'), Buffer.alloc(64, 1))
|
||||
const started = f.host.start({ id: 'caption-job-123456', style: 'descriptive' }, 'lease')
|
||||
// Let the host reach waitForServer / chat completion.
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
f.child.emit('spawn')
|
||||
const result = await started
|
||||
assert.equal(result.status, 'complete')
|
||||
assert.equal(result.text, 'a warm caption')
|
||||
assert.equal(f.calls.length, 1)
|
||||
const [, args] = f.calls[0]
|
||||
assert.ok(args.includes('--mmproj'))
|
||||
assert.ok(args.includes('-m'))
|
||||
assert.equal(f.host.read('caption-job-123456').text, 'a warm caption')
|
||||
// busy may briefly remain true during the post-unload hold window
|
||||
assert.ok(typeof f.host.busy() === 'boolean')
|
||||
})
|
||||
|
||||
test('host rejects a second job while busy', async () => {
|
||||
const f = fixture()
|
||||
const dataDir = join(f.root, 'jobs2')
|
||||
let resolveHealth
|
||||
const healthGate = new Promise(resolve => { resolveHealth = resolve })
|
||||
const hung = createCaptionHost({
|
||||
modelsDir: join(f.root, 'models'),
|
||||
llamaServer: join(f.root, 'llama-server.exe'),
|
||||
dataDir,
|
||||
prepare: async () => {},
|
||||
leaseValid: () => true,
|
||||
spawnProcess: () => {
|
||||
const child = new EventEmitter()
|
||||
child.pid = 99
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = () => { setImmediate(() => child.emit('close', 1)); return true }
|
||||
child.exitCode = null
|
||||
return child
|
||||
},
|
||||
fetchImpl: async (url) => {
|
||||
if (String(url).endsWith('/health')) {
|
||||
await healthGate
|
||||
return { ok: true }
|
||||
}
|
||||
return { ok: true, json: async () => ({ choices: [{ message: { content: 'x' } }] }) }
|
||||
}
|
||||
})
|
||||
mkdirSync(join(dataDir, 'caption-job-aaaaaa'), { recursive: true })
|
||||
writeFileSync(join(dataDir, 'caption-job-aaaaaa', 'input.png'), Buffer.alloc(64, 1))
|
||||
const first = hung.start({ id: 'caption-job-aaaaaa', style: 'tags' }, 'lease')
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
await assert.rejects(hung.start({ id: 'caption-job-bbbbbb', style: 'tags' }, 'lease'), /already running/)
|
||||
await hung.cancel('caption-job-aaaaaa')
|
||||
resolveHealth()
|
||||
await assert.rejects(first)
|
||||
})
|
||||
|
||||
test('POST /caption sync shape is documented on host module', () => {
|
||||
assert.equal(typeof createCaptionHost({ prepare: async () => {}, leaseValid: () => true }).captionOnce, 'function')
|
||||
})
|
||||
@@ -3,11 +3,10 @@ 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,hydrateStudio2Job} from '../shared/studio2/input.mjs'
|
||||
import {useAsHero,useAsSource,restoreAll} 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'
|
||||
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
|
||||
|
||||
test('deleted library files never return from job outputs; selection falls back or clears',()=>{
|
||||
const deleted={id:'gone',folderId:'f'},keep={id:'keep',folderId:'f',createdAt:2};const jobs=[{request:{folderId:'f'},outputs:[deleted]}]
|
||||
@@ -29,8 +28,6 @@ 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/)
|
||||
})
|
||||
@@ -77,63 +74,10 @@ 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',()=>{
|
||||
test('qwen21 is generate-only T2I with 25/1 defaults and no locks',()=>{
|
||||
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})
|
||||
assert.equal(q.engine,'qwen21');assert.equal(q.settings.steps,25);assert.equal(q.settings.cfg,1)
|
||||
assert.equal(q.settings.width,1024);assert.equal(q.settings.height,1024)
|
||||
assert.equal(q.lockFace,false);assert.equal(q.lockOutfit,false);assert.deepEqual(q.settings.loraStack,[])
|
||||
assert.equal(q.enhancePrompt,false)
|
||||
assert.equal(q.turbo,false)
|
||||
const wide=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'wide'},settings:{aspect:'16:9'}})
|
||||
assert.equal(wide.settings.width,1536);assert.equal(wide.settings.height,864)
|
||||
const tall=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'tall'},settings:{aspect:'9:16'}})
|
||||
assert.equal(tall.settings.width,864);assert.equal(tall.settings.height,1536)
|
||||
const turbo=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',turbo:true,promptSections:{action:'fast'},settings:{aspect:'1:1',steps:25,cfg:4}})
|
||||
assert.equal(turbo.turbo,true);assert.equal(turbo.settings.steps,6);assert.equal(turbo.settings.cfg,1)
|
||||
assert.match(String(turbo.lora),/viggle-turbo-v0\.2\.1-6step-lora-r128/)
|
||||
assert.match(String(turbo.sigmas),/0\.9375/)
|
||||
const enhanced=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',enhancePrompt:true,promptSections:{action:'corgi'},settings:{aspect:'1:1'}})
|
||||
assert.equal(enhanced.enhancePrompt,true)
|
||||
assert.equal(validateRequest({mode:'generate',engine:'flux',folderId:'f',enhancePrompt:true,turbo:true,promptSections:{action:'x'},settings:{aspect:'1:1'}}).turbo,false)
|
||||
const edit=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'change the sky'},settings:{aspect:'auto'}})
|
||||
assert.equal(edit.mode,'edit');assert.equal(edit.settings.steps,25);assert.equal(edit.settings.cfg,1)
|
||||
assert.equal(edit.lockFace,false);assert.deepEqual(edit.settings.loraStack,[])
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',promptSections:{action:'x'}}),/source still/)
|
||||
assert.throws(()=>validateRequest({mode:'compose',engine:'qwen21',folderId:'f',imageAId:'s',imageBId:'b',promptSections:{action:'x'}}),/Generate and Edit/)
|
||||
assert.throws(()=>validateRequest({mode:'iterate',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/Generate and Edit/)
|
||||
})
|
||||
test('hydrateStudio2Job restores LoRA strengths, locked seed, and clears missing stills',()=>{
|
||||
const form={mode:'generate',engine:'flux',identityStillId:'',imageAId:'',promptSections:{action:''},imageStyles:{positive:[],negative:[]},settings:{aspect:'auto',seed:null,seedMode:'random',loraStack:[]},guides:[]}
|
||||
const saved={mode:'edit',engine:'flux',identityStillId:'hero1',imageAId:'gone',lockFace:true,lockOutfit:false,promptSections:{action:'pokemon trainer'},imageStyles:{positive:[],negative:['blurry','custom bad']},settings:{aspect:'16:9',seed:42,steps:24,cfg:4,loraStack:[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}]}}
|
||||
const {form:next,missing}=hydrateStudio2Job(form,saved,{stillIds:new Set(['hero1']),clipIds:new Set()})
|
||||
assert.equal(next.mode,'edit');assert.equal(next.identityStillId,'hero1');assert.equal(next.imageAId,'')
|
||||
assert.equal(next.settings.seed,42);assert.equal(next.settings.seedMode,'locked');assert.equal(next.settings.aspect,'16:9')
|
||||
assert.deepEqual(next.settings.loraStack,[{name:'klein-pokemon.safetensors',strengthModel:1.6,strengthClip:1}])
|
||||
assert.deepEqual(next.imageStyles.negative,['blurry','custom bad'])
|
||||
assert.match(missing,/start missing/)
|
||||
})
|
||||
test('custom negative chips survive normalizeStyles and bench exposes Load actions',()=>{
|
||||
let styles={positive:[],negative:['blurry','my watermark']}
|
||||
styles=toggleStyle(styles,'deformed',true)
|
||||
assert.deepEqual(styles.negative,['blurry','my watermark','deformed'])
|
||||
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
||||
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Edit source/)
|
||||
assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/)
|
||||
assert.match(src,/Enhance prompt/);assert.match(src,/enhancePrompt/)
|
||||
assert.match(src,/\bTurbo\b/);assert.match(src,/form\.turbo/)
|
||||
const peT2i=readFileSync(new URL('../server/assets/studio2_qwen21_pe_t2i.json',import.meta.url),'utf8')
|
||||
const peEdit=readFileSync(new URL('../server/assets/studio2_qwen21_pe_edit.json',import.meta.url),'utf8')
|
||||
assert.match(peT2i,/QwenImage21_T2IPromptRewrite/);assert.match(peT2i,/pe_t2i\.int8_convrot/)
|
||||
assert.match(peEdit,/QwenImage21_EditPromptRewrite/);assert.match(peEdit,/pe_i2i\.int8_convrot/)
|
||||
assert.doesNotMatch(peT2i,/UnetLoader|KSampler|VAELoader/)
|
||||
assert.doesNotMatch(peEdit,/UnetLoader|KSampler|VAELoader/)
|
||||
const t2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i.json',import.meta.url),'utf8')
|
||||
assert.match(t2i,/EmptyLatentImage/);assert.match(t2i,/"latent_image":\s*\["16",\s*0\]/)
|
||||
const turboT2i=readFileSync(new URL('../server/assets/studio2_qwen21_t2i_turbo.json',import.meta.url),'utf8')
|
||||
const turboEdit=readFileSync(new URL('../server/assets/studio2_qwen21_edit_turbo.json',import.meta.url),'utf8')
|
||||
assert.match(turboT2i,/ViggleTurboLora/);assert.match(turboT2i,/ViggleTurboSigmas/);assert.match(turboT2i,/SamplerCustomAdvanced/)
|
||||
assert.match(turboT2i,/0\.9375/);assert.match(turboEdit,/ViggleTurboLora/);assert.match(turboEdit,/"latent":\s*\["9",\s*2\]/)
|
||||
assert.deepEqual(resolveQwen21Size('16:9'),{width:1536,height:864})
|
||||
assert.deepEqual(resolveQwen21Size('1:1'),{width:1024,height:1024})
|
||||
assert.deepEqual(resolveQwen21Size('9:16'),{width:864,height:1536})
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/T2I in this build/)
|
||||
})
|
||||
|
||||