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,657 +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,
|
:root,
|
||||||
|
[data-display='classic'],
|
||||||
[data-display='studio'] {
|
[data-display='studio'] {
|
||||||
--bg: #0b0b0d;
|
--display-bg: #09090b;
|
||||||
--panel: #141416;
|
--display-fg: #e4e4e7;
|
||||||
--panel-raise: #1a1a1d;
|
--display-muted: #a1a1aa;
|
||||||
--line: #2a2a2e;
|
--display-border: rgba(255, 255, 255, 0.1);
|
||||||
--text: #f3f1ea;
|
--display-amber: #fbbf24;
|
||||||
--muted: #9a9588;
|
--display-amber-fg: #09090b;
|
||||||
--amber: #f5a524;
|
--display-radius: 8px;
|
||||||
--amber-on: #1a1204;
|
--display-focus: rgba(252, 211, 77, 0.5);
|
||||||
--chip-bg: #2a2418;
|
|
||||||
--chip-line: #5a4a28;
|
|
||||||
--chip-text: #e8c37a;
|
|
||||||
--success: #7dda6f;
|
|
||||||
--radius: 8px;
|
|
||||||
--page-gap: 12px;
|
|
||||||
--field-gap: 12px;
|
|
||||||
--header-h: 48px;
|
|
||||||
--rail-task-h: 40px;
|
|
||||||
--rail-model-h: 36px;
|
|
||||||
--left-w: 260px;
|
|
||||||
--right-w: 280px;
|
|
||||||
--type: 14px;
|
|
||||||
--type-sm: 12px;
|
|
||||||
--type-label: 11px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-display='classic'] {
|
[data-display='classic'] {
|
||||||
--field-gap: 8px;
|
--chrome-gap: 0.5rem;
|
||||||
--type: 13px;
|
--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] {
|
[data-display='studio'] {
|
||||||
background: var(--bg);
|
--chrome-gap: 1rem;
|
||||||
color: var(--text);
|
--rail-h: 3rem;
|
||||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
--card-pad: 1rem;
|
||||||
font-size: var(--type);
|
--display-type-title: 1.875rem;
|
||||||
|
--display-type-body: 0.9375rem;
|
||||||
|
--display-viewer-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skin toggles */
|
[data-display] .display-title {
|
||||||
[data-display='classic'] .rail-task,
|
font-size: var(--display-type-title);
|
||||||
[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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bench-root {
|
[data-display] .display-body {
|
||||||
|
font-size: var(--display-type-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-display] .display-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100dvh;
|
|
||||||
max-height: 100dvh;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
gap: var(--chrome-gap);
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bench-root[data-mobile="1"] {
|
[data-display] .display-card {
|
||||||
position: relative;
|
border-radius: var(--display-radius);
|
||||||
|
border: 1px solid var(--display-border);
|
||||||
|
padding: var(--card-pad);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bench-header {
|
[data-display] .display-viewer {
|
||||||
display: grid;
|
border-radius: var(--display-viewer-radius);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bench-brand {
|
[data-display] .display-rail {
|
||||||
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 {
|
|
||||||
display: flex;
|
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;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 0.5rem;
|
||||||
padding: 0 12px;
|
min-height: var(--rail-h);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-task { min-height: var(--rail-task-h); align-items: center; padding-top: 8px; }
|
[data-display] .display-rail__seg {
|
||||||
.rail-model { min-height: var(--rail-model-h); align-items: center; padding-bottom: 8px; }
|
min-height: var(--rail-h);
|
||||||
|
border-radius: var(--display-radius);
|
||||||
.seg {
|
border: 1px solid var(--display-border);
|
||||||
height: 36px;
|
padding: 0.5rem 1rem;
|
||||||
border: 1px solid var(--line);
|
font-size: var(--display-type-body);
|
||||||
border-radius: var(--radius);
|
color: var(--display-fg);
|
||||||
background: var(--panel);
|
background: transparent;
|
||||||
padding: 0 14px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: var(--type);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: capitalize;
|
|
||||||
outline: none;
|
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; }
|
[data-display] .display-rail__seg:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--display-focus);
|
||||||
.seg[aria-pressed='true'] {
|
|
||||||
border-color: var(--amber);
|
|
||||||
background: var(--amber);
|
|
||||||
color: var(--amber-on);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.seg:disabled {
|
[data-display] .display-rail__seg[aria-pressed='true'],
|
||||||
opacity: 0.45;
|
[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;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seg:focus-visible {
|
[data-display='studio'] .display-rail__seg {
|
||||||
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; }
|
|
||||||
|
|
||||||
.transport {
|
|
||||||
display: flex;
|
|
||||||
height: 28px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
padding: 0 10px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: var(--type-sm);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transport .ops {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transport button {
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-col {
|
|
||||||
display: flex;
|
|
||||||
min-height: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-head {
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 12px 8px;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-badge {
|
|
||||||
display: inline-grid;
|
|
||||||
min-width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--amber);
|
|
||||||
color: var(--amber-on);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-list {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0 12px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-card {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--panel-raise);
|
|
||||||
padding: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-card[data-active='true'] {
|
|
||||||
border-width: 2px;
|
|
||||||
border-color: var(--amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-id {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: var(--type-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.job-title {
|
|
||||||
margin-top: 2px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.3;
|
padding: 0.625rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-meta {
|
[data-display='classic'] .display-rail__seg {
|
||||||
margin-top: 4px;
|
min-height: 2.25rem;
|
||||||
color: var(--muted);
|
padding: 0.375rem 0.75rem;
|
||||||
font-size: var(--type-sm);
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-status {
|
[data-display] .display-cta {
|
||||||
display: flex;
|
border-radius: var(--display-radius);
|
||||||
margin-top: 8px;
|
background: var(--display-amber);
|
||||||
align-items: center;
|
color: var(--display-amber-fg);
|
||||||
justify-content: space-between;
|
font-weight: 600;
|
||||||
gap: 8px;
|
|
||||||
font-size: var(--type-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-status .left {
|
[data-display] .display-cta:focus-visible {
|
||||||
display: inline-flex;
|
box-shadow: 0 0 0 2px var(--display-focus);
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-status .dot {
|
[data-display] .display-control {
|
||||||
width: 7px;
|
border-radius: var(--display-radius);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
@click="setDisplayMode('classic')"
|
@click="setDisplayMode('classic')"
|
||||||
>
|
>
|
||||||
<span class="block font-display text-lg font-semibold text-zinc-50">Classic</span>
|
<span class="block font-display text-lg font-semibold text-zinc-50">Classic</span>
|
||||||
<span class="mt-1 block text-xs text-zinc-400">packed header, dropdowns</span>
|
<span class="mt-1 block text-xs text-zinc-400">Packed header, compact selects, tight gaps.</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
@click="setDisplayMode('studio')"
|
@click="setDisplayMode('studio')"
|
||||||
>
|
>
|
||||||
<span class="block font-display text-lg font-semibold text-zinc-50">Studio</span>
|
<span class="block font-display text-lg font-semibold text-zinc-50">Studio</span>
|
||||||
<span class="mt-1 block text-xs text-zinc-400">task 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,24 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="slot-block">
|
<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>
|
||||||
<span class="field-label">{{ label }}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="slot-thumb"
|
|
||||||
:class="asset ? (size==='hero' ? 'slot-thumb--hero' : 'slot-thumb--slot') : 'slot-thumb--empty'"
|
|
||||||
@click="$emit('pick')"
|
|
||||||
>
|
|
||||||
<span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Still loaded · preview hidden</span>
|
|
||||||
<img v-else-if="asset" :src="asset.kind==='clip' ? `/api/library/clips/${asset.id}/thumb` : `/api/library/stills/${asset.id}`" :alt="label">
|
|
||||||
<span v-else class="inline-flex items-center gap-1">Select <span aria-hidden="true">▾</span></span>
|
|
||||||
</button>
|
|
||||||
<p v-if="asset" class="slot-caption" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p>
|
|
||||||
<label v-if="upload" class="slot-upload">Upload {{ label.toLowerCase() }}
|
|
||||||
<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {assetLabel} from '~/shared/studio2/viewer.mjs'
|
import {assetLabel} from '~/shared/studio2/viewer.mjs'
|
||||||
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'])
|
defineEmits(['pick','upload'])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,287 +1,298 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bench-root" :data-display="displayMode" :data-mobile="isMobile ? '1' : undefined">
|
<main class="display-stack w-full min-w-0 p-4 md:p-6 xl:p-8" :data-display="displayMode">
|
||||||
<Studio2Mobile v-if="isMobile" :ctx="mobileCtx" />
|
<header class="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
<template v-else>
|
<h1 class="display-title font-display font-bold tracking-tight text-zinc-50">Studio 2</h1>
|
||||||
<header class="bench-header">
|
<p class="display-body mt-1 text-zinc-400">One hero. Every shot.</p>
|
||||||
<div class="bench-brand">{{ instanceName }}</div>
|
</div>
|
||||||
<div class="bench-crumb" aria-label="Folder">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<span aria-hidden="true">📁</span>
|
<label class="display-body text-zinc-300">Folder
|
||||||
<span>/ Projects /</span>
|
<select v-model="form.folderId" class="display-control ml-2 border border-white/10 bg-zinc-900 px-3 py-2 outline-none ring-amber-300/40 focus:ring-2">
|
||||||
<select v-model="form.folderId" :aria-label="'Folder'">
|
|
||||||
<option v-for="f in library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
<option v-for="f in library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
</label>
|
||||||
|
<div v-if="isClassic && form.mode!=='music' && form.mode!=='caption'" class="flex flex-wrap items-center gap-3 text-sm text-zinc-300" aria-label="Identity locks">
|
||||||
|
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockFace" type="checkbox" class="accent-amber-400">Lock face</label>
|
||||||
|
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockOutfit" type="checkbox" class="accent-amber-400">Lock outfit</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="bench-header-right">
|
<div v-if="isClassic && xaigen" class="flex flex-wrap items-center gap-3 text-sm text-zinc-300" aria-label="Preview hide">
|
||||||
<nav class="bench-nav" aria-label="Studio links">
|
<label class="inline-flex items-center gap-1.5"><input v-model="classicHideInput" type="checkbox" class="accent-amber-400">Hide input</label>
|
||||||
<NuxtLink to="/library">Library</NuxtLink>
|
<label class="inline-flex items-center gap-1.5"><input v-model="classicHideOutput" type="checkbox" class="accent-amber-400">Hide output</label>
|
||||||
<NuxtLink to="/queue">Queue</NuxtLink>
|
</div>
|
||||||
<NuxtLink to="/settings">Settings</NuxtLink>
|
<nav class="flex flex-wrap items-center gap-3 text-sm" aria-label="Studio links">
|
||||||
|
<Studio2GpuStatus :status="gpu" />
|
||||||
|
<NuxtLink to="/queue" class="text-zinc-300 outline-none ring-amber-300/40 focus-visible:ring-2">Queue</NuxtLink>
|
||||||
|
<NuxtLink to="/library" class="text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2">Library</NuxtLink>
|
||||||
|
<NuxtLink to="/settings" class="text-zinc-300 outline-none ring-amber-300/40 focus-visible:ring-2">Settings</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
<span class="gpu-chip" :data-tone="gpu.tone" role="status" :title="gpu.detail">
|
|
||||||
<span class="dot" aria-hidden="true" />
|
|
||||||
{{ gpuChipLabel }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="rail rail-task" aria-label="Tasks">
|
<div class="display-stack min-w-0">
|
||||||
|
<label v-if="isClassic" class="display-body block text-zinc-300">Task
|
||||||
|
<select :value="form.mode" class="display-control mt-1 w-full max-w-xs border border-white/10 bg-zinc-900 px-3 py-2 capitalize outline-none ring-amber-300/40 focus:ring-2" @change="onTaskSelect">
|
||||||
|
<option v-for="task in tasks" :key="task" :value="task" class="capitalize">{{ task }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<nav v-else class="display-rail" aria-label="Tasks">
|
||||||
<button
|
<button
|
||||||
v-for="task in tasks"
|
v-for="task in tasks"
|
||||||
:key="task"
|
:key="task"
|
||||||
type="button"
|
type="button"
|
||||||
class="seg"
|
class="display-rail__seg capitalize"
|
||||||
:aria-pressed="form.mode===task"
|
:aria-pressed="form.mode===task"
|
||||||
@click="chooseTask(task)"
|
@click="chooseTask(task)"
|
||||||
>{{ task }}</button>
|
>{{ task }}</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div v-if="form.mode!=='music'" class="rail rail-model" role="group" aria-label="Model">
|
|
||||||
|
<div v-if="form.mode!=='music' && form.mode!=='caption'" class="min-w-0">
|
||||||
|
<label v-if="isClassic" class="display-body block text-zinc-300">Model
|
||||||
|
<select v-model="form.engine" class="display-control mt-1 w-full max-w-md border border-white/10 bg-zinc-900 px-3 py-2 outline-none ring-amber-300/40 focus:ring-2">
|
||||||
|
<option v-for="opt in engineOptions" :key="opt.value" :value="opt.value" :disabled="opt.disabled">{{ opt.label }}{{ opt.hint ? ' — '+opt.hint : '' }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div v-else class="display-rail" role="group" aria-label="Model">
|
||||||
<button
|
<button
|
||||||
v-for="opt in engineOptions"
|
v-for="opt in engineOptions"
|
||||||
:key="opt.value"
|
:key="opt.value"
|
||||||
type="button"
|
type="button"
|
||||||
class="seg"
|
class="display-rail__seg"
|
||||||
:aria-pressed="form.engine===opt.value"
|
:aria-pressed="form.engine===opt.value"
|
||||||
|
:aria-disabled="opt.disabled || undefined"
|
||||||
:disabled="opt.disabled"
|
:disabled="opt.disabled"
|
||||||
:title="opt.hint || undefined"
|
:title="opt.hint || undefined"
|
||||||
@click="!opt.disabled && (form.engine=opt.value)"
|
@click="!opt.disabled && (form.engine=opt.value)"
|
||||||
>{{ opt.label }}</button>
|
|
||||||
</div>
|
|
||||||
<p v-else class="rail rail-model text-[var(--muted)]">YuE2</p>
|
|
||||||
|
|
||||||
<div class="bench-grid">
|
|
||||||
<!-- LEFT inspector -->
|
|
||||||
<aside class="bench-panel">
|
|
||||||
<div class="bench-scroll inspector">
|
|
||||||
<div class="classic-task-model space-y-2">
|
|
||||||
<label>
|
|
||||||
<span class="field-label">Task</span>
|
|
||||||
<select :value="form.mode" class="field-control capitalize" @change="onTaskSelect">
|
|
||||||
<option v-for="task in tasks" :key="task" :value="task" class="capitalize">{{ task }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label v-if="form.mode!=='music'">
|
|
||||||
<span class="field-label">Model</span>
|
|
||||||
<select v-model="form.engine" class="field-control">
|
|
||||||
<option v-for="opt in engineOptions" :key="opt.value" :value="opt.value" :disabled="opt.disabled">{{ opt.label }}{{ opt.hint ? ' — '+opt.hint : '' }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<p v-else class="text-[var(--type-sm)] text-[var(--muted)]">Model · YuE2</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="form.mode==='music'">
|
|
||||||
<label><span class="field-label">Genre / style tags</span><textarea v-model="music.tags" rows="3" class="field-area" :placeholder="tagsPlaceholder" /></label>
|
|
||||||
<label><span class="field-label">Lyrics</span><textarea v-model="music.lyrics" rows="8" class="field-area" :placeholder="lyricsPlaceholder" /></label>
|
|
||||||
<label><span class="field-label">Target length (sec)</span><input v-model.number="music.duration" type="number" min="15" max="150" class="field-control"></label>
|
|
||||||
<label><span class="field-label">Seed</span><input v-model="music.seed" placeholder="random" class="field-control"></label>
|
|
||||||
<label><span class="field-label">Track name</span><input v-model="music.name" class="field-control" placeholder="Optional"></label>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<Studio2AssetSlot size="hero" label="Hero still" :asset="stills.find((a:any)=>a.id===form.identityStillId)" :hidden="slotHidden(form.identityStillId)" upload :disabled="uploading" @pick="picker='identityStillId'" @upload="upload($event,'hero')" />
|
|
||||||
<Studio2AssetSlot :label="form.mode==='extend'?'Previous clip':'Start still'" :asset="sourceSlot" :hidden="slotHidden(form.mode==='extend'?form.startClipId:form.imageAId)" upload :disabled="uploading" @pick="picker=form.mode==='extend'?'startClipId':'imageAId'" @upload="upload($event,'source')" />
|
|
||||||
<Studio2AssetSlot v-if="form.mode==='compose'" label="Second still" :asset="stills.find((a:any)=>a.id===form.imageBId)" :hidden="slotHidden(form.imageBId)" @pick="picker='imageBId'" />
|
|
||||||
<Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="slotHidden(form.maskId)" @pick="picker='maskId'" />
|
|
||||||
<Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="slotHidden(form.endStillId)" @pick="picker='endStillId'" />
|
|
||||||
|
|
||||||
<div class="identity-slots flex flex-col gap-2 text-[var(--type-sm)] text-[var(--muted)]" aria-label="Identity locks">
|
|
||||||
<label class="inline-flex items-center gap-2"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
|
|
||||||
<label class="inline-flex items-center gap-2"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
|
|
||||||
<p class="text-[11px]">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
<span class="field-label">Prompt</span>
|
|
||||||
<textarea v-model="form.promptSections.action" class="field-area" placeholder="Describe the image or the shot." />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="field-label">Negative prompt</span>
|
|
||||||
<div class="neg-row">
|
|
||||||
<span v-for="chip in negativeChips" :key="chip" class="neg-chip">
|
|
||||||
{{ chip }}
|
|
||||||
<button type="button" :aria-label="'Remove '+chip" @click="removeNegative(chip)">×</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="field-label">LoRA</span>
|
|
||||||
<LoraSelect v-model="form.settings.loraStack" compact :names="pickerLoras" :use="currentLoraUse" :clip-strength="!video" filename-subtitle :aliases="{...loraNames.aliases,...library.loraAliases}" :triggers="{...loraNames.triggers,...library.loraTriggers}" missing-label="None" @insert-trigger="form.promptSections.action += ' '+$event" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="field-label">Seed</span>
|
|
||||||
<div class="seed-row">
|
|
||||||
<input
|
|
||||||
:value="form.settings.seed ?? ''"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="2147483647"
|
|
||||||
class="field-control"
|
|
||||||
@input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'"
|
|
||||||
>
|
>
|
||||||
<button type="button" class="dice-btn" title="Random seed" aria-label="Random seed" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">🎲</button>
|
<span class="block">{{ opt.label }}</span>
|
||||||
|
<span v-if="opt.hint" class="mt-0.5 block text-[11px] font-normal opacity-80">{{ opt.hint }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else-if="form.mode==='music'" class="display-body text-zinc-400">Model · YuE2</p>
|
||||||
<label>
|
<p v-else class="display-body text-zinc-400">Model · Qwen2.5-VL caption</p>
|
||||||
<span class="field-label">Aspect ratio</span>
|
|
||||||
<select v-model="form.settings.aspect" class="field-control">
|
|
||||||
<option v-for="opt in aspectOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="button" class="text-left text-[var(--type-sm)] text-[var(--amber)]" @click="settingsOpen=true">More settings (steps / CFG)…</button>
|
|
||||||
<p v-if="uploading || uploadMessage" role="status" class="text-[var(--type-sm)] text-[var(--amber)]">{{ uploading?'Uploading…':uploadMessage }}</p>
|
|
||||||
<p v-if="error" role="alert" class="text-[var(--type-sm)] text-red-300">{{ error }}</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- CENTER viewer -->
|
|
||||||
<section class="bench-panel">
|
|
||||||
<div v-if="form.mode!=='music'" class="viewer-lock-bar" aria-label="Locks and hide">
|
|
||||||
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
|
|
||||||
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
|
|
||||||
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideInput" type="checkbox" class="accent-[var(--amber)]">Hide input</label>
|
|
||||||
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideOutput" type="checkbox" class="accent-[var(--amber)]">Hide output</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="center-stack">
|
<div class="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(24rem,2fr)] 2xl:gap-8">
|
||||||
<div class="viewer-frame">
|
<section class="min-w-0 space-y-4">
|
||||||
|
<div class="display-viewer relative flex min-h-80 items-center justify-center overflow-hidden border border-white/10 bg-black">
|
||||||
<button
|
<button
|
||||||
v-if="selected && xaigen"
|
v-if="selected && xaigen && isStudio"
|
||||||
type="button"
|
type="button"
|
||||||
class="eye-viewer"
|
class="absolute right-3 top-3 z-10 rounded-full border border-white/20 bg-zinc-950/80 p-2 text-zinc-200 outline-none ring-amber-300/50 hover:border-amber-300/50 focus-visible:ring-2"
|
||||||
:aria-pressed="!isHidden(selected)"
|
:aria-pressed="!isHidden(selected)"
|
||||||
:aria-label="isHidden(selected) ? 'Show preview' : 'Hide preview'"
|
:aria-label="isHidden(selected) ? 'Show preview' : 'Hide preview'"
|
||||||
@click.stop="toggleReveal(selected.id)"
|
@click.stop="toggleReveal(selected.id)"
|
||||||
>
|
>
|
||||||
<img :src="isHidden(selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
|
||||||
</button>
|
<path v-if="isHidden(selected)" d="M3 3l18 18M10.5 10.6a2.5 2.5 0 0 0 3 3M9.4 5.1A10.4 10.4 0 0 1 12 4.5c5 0 9.3 3.1 11 7.5a11.6 11.6 0 0 1-4.2 5.1M6.2 6.2A11.5 11.5 0 0 0 1 12c1.2 2.7 3.3 4.9 5.9 6.3A10.5 10.5 0 0 0 12 19.5c1.2 0 2.4-.2 3.5-.6" stroke-linecap="round"/>
|
||||||
|
<template v-else>
|
||||||
<template v-if="selected && isHidden(selected)">
|
<path d="M2 12s3.5-7.5 10-7.5S22 12 22 12s-3.5 7.5-10 7.5S2 12 2 12Z"/>
|
||||||
<p class="placeholder-still">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : selected.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</p>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="selected?.kind==='track'">
|
</svg>
|
||||||
<div class="w-full max-w-xl px-6 py-4">
|
</button>
|
||||||
|
<p v-if="selected && isHidden(selected)" class="text-zinc-400">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : selected.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</p>
|
||||||
|
<div v-else-if="selected?.kind==='track'" class="w-full max-w-xl px-6 py-4">
|
||||||
<AudioPlayer :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" />
|
<AudioPlayer :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<video v-else-if="selected?.kind==='clip'" :key="selected.id" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full object-contain" />
|
||||||
<template v-else-if="selected?.kind==='clip'">
|
<img v-else-if="selected" :src="stillUrl(selected.id)" class="max-h-[65vh] max-w-full object-contain" alt="Current output">
|
||||||
<video
|
<p v-else class="text-zinc-500">Select a result to view it here</p>
|
||||||
ref="viewerVideo"
|
|
||||||
:key="selected.id"
|
|
||||||
:src="`/api/library/clips/${selected.id}/video`"
|
|
||||||
:muted="viewerMuted"
|
|
||||||
@timeupdate="onVideoTime"
|
|
||||||
@loadedmetadata="onVideoTime"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="selected">
|
|
||||||
<img :src="stillUrl(selected.id)" alt="Selected still">
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<p class="placeholder-still">Select a still or clip in the strip</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filmstrip" aria-label="Current family outputs">
|
<div v-if="selected" class="flex flex-wrap items-start gap-3">
|
||||||
|
<button v-if="selected.kind!=='track'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="restore(selected)">Use as input</button>
|
||||||
|
<button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="Object.assign(form,useAsSource(form,selected))">Use as source still</button>
|
||||||
|
<button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="Object.assign(form,useAsHero(form,selected))">Use as hero</button>
|
||||||
|
<button v-if="selected.kind==='still' || (selected.kind!=='track' && selected.kind!=='clip' && selected.id)" class="rounded-full border border-amber-300/40 px-4 py-2 text-sm text-amber-100 outline-none ring-amber-300/40 focus-visible:ring-2" :disabled="submitting" @click="describeSelected">Describe</button>
|
||||||
|
<button v-if="selected.kind!=='track'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="detailsOpen=true">Details</button>
|
||||||
|
<button class="rounded-full border border-red-400/40 px-4 py-2 text-sm text-red-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="deleteOutput">Delete</button>
|
||||||
|
<VideoUpscaleControl v-if="selected.kind==='clip'" :clip-id="selected.id" queue-only @complete="refresh" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 max-w-full gap-2 overflow-x-auto" aria-label="Current family outputs">
|
||||||
<div
|
<div
|
||||||
v-for="o in family"
|
v-for="o in family"
|
||||||
:key="o.id"
|
:key="o.id"
|
||||||
class="tile"
|
class="relative shrink-0 rounded-lg border p-1"
|
||||||
:data-active="selected?.id===o.id ? 'true' : undefined"
|
:class="selected?.id===o.id ? 'border-amber-300' : 'border-white/10'"
|
||||||
>
|
>
|
||||||
<button type="button" class="pick" @click="selectOutput(o)">
|
<button type="button" class="block outline-none ring-amber-300/40 focus-visible:ring-2" @click="selectOutput(o)">
|
||||||
<span v-if="isHidden(o)" class="flex h-full items-center justify-center p-1 text-[10px] text-[var(--muted)]">Hidden</span>
|
<span v-if="isHidden(o)" class="flex h-20 w-24 items-center justify-center p-1 text-xs text-zinc-400">{{ o.kind==='track' ? 'Audio hidden' : o.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</span>
|
||||||
<span v-else-if="o.kind==='track'" class="flex h-full items-center justify-center text-[10px] text-[var(--amber)]">Audio</span>
|
<span v-else-if="o.kind==='track'" class="flex h-20 w-24 items-center justify-center rounded bg-zinc-900 text-xs text-amber-100">Audio</span>
|
||||||
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : stillUrl(o.id)" alt="">
|
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : stillUrl(o.id)" class="h-20 w-24 rounded object-cover" alt="Family output">
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="xaigen"
|
v-if="xaigen && isStudio"
|
||||||
type="button"
|
type="button"
|
||||||
class="eye-tile"
|
class="absolute right-1 top-1 rounded-md border border-white/20 bg-zinc-950/80 p-1 text-zinc-200 outline-none ring-amber-300/50 focus-visible:ring-2"
|
||||||
:aria-label="isHidden(o) ? 'Show tile' : 'Hide tile'"
|
:aria-label="isHidden(o) ? 'Show tile' : 'Hide tile'"
|
||||||
|
:aria-pressed="!isHidden(o)"
|
||||||
@click.stop="toggleReveal(o.id)"
|
@click.stop="toggleReveal(o.id)"
|
||||||
>
|
>
|
||||||
<img :src="isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
|
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
|
||||||
|
<path v-if="isHidden(o)" d="M3 3l18 18M10.5 10.6a2.5 2.5 0 0 0 3 3M9.4 5.1A10.4 10.4 0 0 1 12 4.5c5 0 9.3 3.1 11 7.5a11.6 11.6 0 0 1-4.2 5.1M6.2 6.2A11.5 11.5 0 0 0 1 12c1.2 2.7 3.3 4.9 5.9 6.3A10.5 10.5 0 0 0 12 19.5c1.2 0 2.4-.2 3.5-.6" stroke-linecap="round"/>
|
||||||
|
<template v-else>
|
||||||
|
<path d="M2 12s3.5-7.5 10-7.5S22 12 22 12s-3.5 7.5-10 7.5S2 12 2 12Z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="selected?.id===o.id"
|
|
||||||
type="button"
|
|
||||||
class="tile-trash"
|
|
||||||
aria-label="Delete selected"
|
|
||||||
title="Delete"
|
|
||||||
@click.stop="deleteOutput"
|
|
||||||
>🗑</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="transport">
|
<section v-if="form.mode!=='music' && form.mode!=='caption'" aria-label="Source" class="display-card">
|
||||||
<span>{{ transportLabel }}</span>
|
<div class="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||||
<div class="ops">
|
<h2 class="text-xs uppercase tracking-wider text-zinc-400">Source</h2>
|
||||||
<button type="button" :aria-pressed="viewerMuted" :aria-label="viewerMuted ? 'Unmute' : 'Mute'" @click="viewerMuted=!viewerMuted">{{ viewerMuted ? '🔇' : '🔊' }}</button>
|
<div v-if="isStudio" class="flex flex-wrap gap-4 text-sm text-zinc-300" aria-label="Identity locks">
|
||||||
<button type="button" aria-label="Fullscreen" @click="toggleFullscreen">⛶</button>
|
<template v-if="form.engine!=='qwen21'">
|
||||||
|
<label class="flex items-start gap-2"><input v-model="form.lockFace" type="checkbox" class="mt-1 accent-amber-400"><span>Lock face<span class="block text-xs text-zinc-500">Hero encode + lock on Klein / Krea stills</span></span></label>
|
||||||
|
<label class="flex items-start gap-2"><input v-model="form.lockOutfit" type="checkbox" class="mt-1 accent-amber-400"><span>Lock outfit<span class="block text-xs text-zinc-500">Hero encode + lock on Klein / Krea stills</span></span></label>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<Studio2AssetSlot label="Hero" :asset="stills.find((a:any)=>a.id===form.identityStillId)" :hidden="slotHidden(form.identityStillId)" upload :disabled="uploading" @pick="picker='identityStillId'" @upload="upload($event,'hero')" />
|
||||||
|
<Studio2AssetSlot :label="form.mode==='extend'?'Previous clip':'Start still'" :asset="sourceSlot" :hidden="slotHidden(form.mode==='extend'?form.startClipId:form.imageAId)" upload :disabled="uploading" @pick="picker=form.mode==='extend'?'startClipId':'imageAId'" @upload="upload($event,'source')" />
|
||||||
|
<Studio2AssetSlot v-if="form.mode==='compose'" label="Second still" :asset="stills.find((a:any)=>a.id===form.imageBId)" :hidden="slotHidden(form.imageBId)" @pick="picker='imageBId'" />
|
||||||
|
<Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="slotHidden(form.maskId)" @pick="picker='maskId'" />
|
||||||
|
<Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="slotHidden(form.endStillId)" @pick="picker='endStillId'" />
|
||||||
</div>
|
</div>
|
||||||
|
<label v-if="form.mode==='refine'" class="mt-3 block text-sm">Mask still (white = change). On-canvas mask comes later.
|
||||||
|
<select v-model="form.maskId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Choose mask still</option><option v-for="a in stills" :key="a.id" :value="a.id">{{ assetLabel(a) }}</option></select>
|
||||||
|
</label>
|
||||||
|
<p v-if="uploading || uploadMessage" role="status" class="mt-2 text-sm text-amber-200">{{ uploading?'Uploading image…':uploadMessage }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- RIGHT queue -->
|
<details v-if="video" class="rounded-xl border border-white/10 p-3"><summary>Add Guide stills</summary>
|
||||||
<aside class="bench-panel queue-col">
|
<div v-for="(g,i) in form.guides" :key="i" class="mt-2 flex gap-2">
|
||||||
<div class="queue-head">
|
<Studio2AssetSlot label="Guide" :asset="stills.find((a:any)=>a.id===g.stillId)" :hidden="slotHidden(g.stillId)" @pick="picker='guide:'+i" />
|
||||||
Queue
|
<input v-model.number="g.frame" type="number" min="1" aria-label="Guide frame" class="w-20 bg-zinc-800 p-2">
|
||||||
<span class="queue-badge">{{ queueCount }}</span>
|
<button type="button" @click="form.guides.splice(i,1)">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="queue-list">
|
<button type="button" class="mt-3 text-amber-200" @click="form.guides.push({stillId:'',frame:24})">Add guide</button>
|
||||||
<div
|
</details>
|
||||||
v-for="card in queueCards"
|
</section>
|
||||||
:key="card.id"
|
|
||||||
class="job-card"
|
<section class="min-w-0 space-y-4">
|
||||||
:data-active="focusedJobId===card.id ? 'true' : undefined"
|
<template v-if="form.mode==='music'">
|
||||||
@click="focusQueueCard(card)"
|
<p class="text-sm text-zinc-400">YuE2 · lyrics-to-song · plan → synthesize → decode</p>
|
||||||
>
|
<label class="block text-sm">Genre / style tags
|
||||||
<div class="job-id">{{ card.code }}</div>
|
<textarea v-model="music.tags" rows="3" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" :placeholder="tagsPlaceholder" />
|
||||||
<div class="job-title">{{ card.title }}</div>
|
</label>
|
||||||
<div class="job-meta">{{ card.meta }}</div>
|
<label class="block text-sm">Lyrics
|
||||||
<div class="job-status" :data-state="card.state">
|
<textarea v-model="music.lyrics" rows="8" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" :placeholder="lyricsPlaceholder" />
|
||||||
<span class="left"><span class="dot" aria-hidden="true" />{{ card.statusLabel }}</span>
|
</label>
|
||||||
<button v-if="card.cancelable" type="button" class="kebab" aria-label="Cancel job" @click.stop="cancel(card.job)">⋮</button>
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="block text-sm">Target length (sec)
|
||||||
|
<input v-model.number="music.duration" type="number" min="15" max="150" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2">
|
||||||
|
<span class="mt-1 block text-[11px] text-zinc-500">Caps semantic tokens (~25/s). Actual length follows the model end token — not an FFmpeg trim.</span>
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">Seed
|
||||||
|
<input v-model="music.seed" placeholder="random" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2">
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<label class="block text-sm">Track name (optional)
|
||||||
<p v-if="!queueCards.length" class="px-1 text-[var(--type-sm)] text-[var(--muted)]">No active jobs</p>
|
<input v-model="music.name" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="Optional track name">
|
||||||
</div>
|
</label>
|
||||||
<div class="queue-foot">
|
</template>
|
||||||
<button type="button" class="generate-cta" :disabled="submitting" @click="queue">
|
<template v-else-if="form.mode==='caption'">
|
||||||
<span aria-hidden="true">▶</span>
|
<p class="text-sm text-zinc-400">Qwen2.5-VL · image-to-text · exclusive GPU · unloads after one shot</p>
|
||||||
{{ submitting ? 'Queuing…' : 'Generate' }}
|
<div class="display-card space-y-3 p-3">
|
||||||
</button>
|
<Studio2AssetSlot label="Still to describe" :asset="captionStill" :hidden="slotHidden(captionStillId)" upload :disabled="uploading" @pick="picker='captionStillId'" @upload="uploadCaption($event)" />
|
||||||
<p class="tiny">
|
<label class="block text-sm text-zinc-300">Caption style
|
||||||
Job queue
|
<select v-model="captionStyle" class="mt-1 w-full rounded-xl border border-white/10 bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2">
|
||||||
<button v-if="canClearFinished" type="button" @click="clearFinished"> · Clear finished</button>
|
<option v-for="style in captionStyles" :key="style" :value="style">{{ style }}</option>
|
||||||
</p>
|
</select>
|
||||||
</div>
|
</label>
|
||||||
</aside>
|
<p v-if="captionStyle==='delta'" class="text-xs text-zinc-500">Delta describes only changes from the locked look (bob / pink tank / blue shorts / Slytherin robe / barefoot).</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label class="block">Prompt<textarea v-model="form.promptSections.action" rows="4" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="Describe the image or the shot." /></label>
|
||||||
|
<Studio2PromptControls v-if="video" v-model="form.promptSections" />
|
||||||
|
<Studio2StyleChips v-else v-model="form.imageStyles" />
|
||||||
|
<label v-if="video" class="block">Extra constraints (optional) — do not repeat the Prompt. Example: keep the black glasses, no extra people.
|
||||||
|
<textarea v-model="form.promptSections.locks" rows="2" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" />
|
||||||
|
</label>
|
||||||
|
<details v-if="video || form.mode==='edit'" class="rounded-xl border border-white/10 p-3">
|
||||||
|
<summary>{{ video?'Queue more shots':'Queue extra edit passes' }}</summary>
|
||||||
|
<p class="mt-2 text-xs text-zinc-400">Each blank-line-separated block is another job. Hero and settings stay. Press Enter twice between shots. A wrapped sentence is still one shot.{{ video?'':' Each extra pass edits the previous result with Klein.' }}</p>
|
||||||
|
<textarea v-model="batchText" rows="5" class="mt-2 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="First additional prompt Next additional prompt" />
|
||||||
|
</details>
|
||||||
|
<button type="button" class="text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="settingsOpen=true">Generation settings</button>
|
||||||
|
<p v-if="!video && form.engine==='krea'" class="text-sm text-zinc-400">Krea does not attach the hero identity reference.</p>
|
||||||
|
<p v-if="!video && form.engine==='qwen21'" class="text-sm text-zinc-400">Qwen 2.1 is T2I — no hero, locks, or LoRA stack in this build.</p>
|
||||||
|
<p v-if="form.mode==='extend'" class="text-sm text-amber-200">{{ form.startClipId ? 'Extend · '+(clips.find((c:any)=>c.id===form.startClipId)?.name || form.startClipId)+' · last frame' : 'Select the clip in the family strip or Previous clip.' }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
|
||||||
|
<button type="button" :disabled="submitting" class="display-cta w-full p-4 outline-none disabled:opacity-50" @click="queue">{{ submitting?'Queuing…':'Queue' }}</button>
|
||||||
|
|
||||||
|
<h2 class="text-sm font-semibold text-zinc-300">Job queue</h2>
|
||||||
|
<div v-for="job in upscales.filter(j=>j.folderId===form.folderId && ['queued','running'].includes(j.status))" :key="job.id" class="space-y-2 rounded-xl border border-amber-300/25 bg-zinc-900 p-4 text-sm" role="status">
|
||||||
|
<div class="flex justify-between gap-3"><strong>Video upscale</strong><span class="capitalize text-amber-200">{{ job.status }}</span></div>
|
||||||
|
<p class="truncate text-zinc-300">{{ job.name }}</p>
|
||||||
|
<p class="text-xs text-zinc-400">{{ job.width?job.width+' × '+job.height+' · ':'' }}{{ Math.round((job.elapsedMs||0)/1000) }}s elapsed</p>
|
||||||
|
<progress class="h-2 w-full accent-amber-300" max="100" :value="job.progress || 0" />
|
||||||
|
<p class="text-xs text-zinc-400">{{ job.message }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-for="job in captionJobs" :key="job.id" class="space-y-2 rounded-xl border border-amber-300/25 bg-zinc-900 p-3 text-sm" aria-live="polite" role="status">
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
|
<img v-if="job.request?.stillId || job.request?.imageAId" :src="stillUrl(job.request.stillId || job.request.imageAId)" class="h-14 w-14 shrink-0 rounded object-cover" alt="Caption source">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<strong class="block truncate">Describe · {{ job.request?.captionStyle || 'caption' }}</strong>
|
||||||
|
<span class="capitalize text-amber-200">{{ job.state==='waiting'?'queued':job.state }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="!['complete','failed','cancelled'].includes(job.state)" type="button" class="shrink-0 text-zinc-300" @click="cancel(job)">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-zinc-400">{{ elapsedCaption(job) }}</p>
|
||||||
|
<p v-if="job.resultText" class="whitespace-pre-wrap text-zinc-200">{{ job.resultText }}</p>
|
||||||
|
<div v-if="job.resultText" class="flex flex-wrap gap-2">
|
||||||
|
<button type="button" class="rounded-full border border-white/20 px-3 py-1.5 text-xs outline-none ring-amber-300/40 focus-visible:ring-2" @click="copyCaption(job.resultText)">Copy</button>
|
||||||
|
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100 outline-none ring-amber-300/40 focus-visible:ring-2" @click="useCaptionAsPrompt(job.resultText)">Use as prompt</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="job.error" role="alert" class="text-red-300">{{ job.error }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-for="job in musicJobs" :key="job.id" class="space-y-2 rounded-xl border border-amber-300/25 bg-zinc-900 p-3 text-sm" aria-live="polite" role="status">
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<strong class="truncate">{{ job.name || 'YuE2' }}</strong>
|
||||||
|
<span class="capitalize text-amber-200">{{ job.live?.status || job.status }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-zinc-400">YuE2 · target {{ job.duration || '—' }}s<span v-if="job.live?.message"> · {{ job.live.message }}</span></p>
|
||||||
|
<progress v-if="job.live?.progress" class="h-2 w-full accent-amber-300" max="100" :value="job.live.progress || 0" />
|
||||||
|
<p v-if="job.lastError || job.live?.error" role="alert" class="text-red-300">{{ job.lastError || job.live?.error }}</p>
|
||||||
|
</div>
|
||||||
|
<Studio2JobProgress v-for="job in benchJobs" :key="job.id" :job="job" :now="now" @select="focusJob(job)" @cancel="cancel(job)" />
|
||||||
|
<button v-if="folderJobs.some(j=>['complete','failed','cancelled'].includes(j.state)) || musicJobs.some(j=>['complete','done','error','cancelled'].includes(j.status)) || captionJobs.some(j=>['complete','failed','cancelled'].includes(j.state))" type="button" class="text-sm text-zinc-400 outline-none ring-amber-300/40 focus-visible:ring-2" @click="clearFinished">Clear finished</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="settingsOpen || detailsOpen" class="fixed inset-0 z-50 bg-black/70" @click.self="settingsOpen=false;detailsOpen=false">
|
<div v-if="settingsOpen || detailsOpen" class="fixed inset-0 z-50 bg-black/70" @click.self="settingsOpen=false;detailsOpen=false">
|
||||||
<aside class="absolute inset-y-0 right-0 w-full max-w-md overflow-auto border-l border-[var(--line)] bg-[var(--bg)] p-6">
|
<aside class="absolute inset-y-0 right-0 w-full max-w-md overflow-auto border-l border-white/15 bg-zinc-950 p-6">
|
||||||
<button type="button" class="float-right" @click="settingsOpen=false;detailsOpen=false">Close</button>
|
<button type="button" class="float-right outline-none ring-amber-300/40 focus-visible:ring-2" @click="settingsOpen=false;detailsOpen=false">Close</button>
|
||||||
<template v-if="settingsOpen">
|
<template v-if="settingsOpen">
|
||||||
<h2 class="mb-6 text-xl font-semibold">More settings</h2>
|
<h2 class="mb-6 text-xl font-semibold">Generation settings</h2>
|
||||||
<p class="mb-3 text-[var(--type-sm)] text-[var(--muted)]">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding</p>
|
<AspectRatioPick v-model="form.settings.aspect" heading="Aspect" :options="aspectOptions" />
|
||||||
|
<p class="my-3 text-sm text-zinc-400">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding, never stretch</p>
|
||||||
<label v-for="key in ['steps','cfg',...(video?['duration','fps']:[])]" :key="key" class="mb-4 block capitalize">{{ key }}
|
<label v-for="key in ['steps','cfg',...(video?['duration','fps']:[])]" :key="key" class="mb-4 block capitalize">{{ key }}
|
||||||
<input v-model.number="form.settings[key]" type="number" class="field-control mt-1">
|
<input v-model.number="form.settings[key]" type="number" class="mt-1 w-full rounded bg-zinc-800 p-2 outline-none ring-amber-300/40 focus:ring-2">
|
||||||
</label>
|
</label>
|
||||||
<label v-if="video" class="mb-4 flex items-center gap-2"><input v-model="form.settings.turbo" type="checkbox" class="accent-[var(--amber)]"> Turbo</label>
|
<label class="mb-4 block">Seed · {{ form.settings.seedMode==='locked'?'Locked':'Random each job' }}
|
||||||
<details v-if="video || form.mode==='edit'" class="mt-4">
|
<input :value="form.settings.seed" type="number" min="0" max="2147483647" class="mt-1 w-full rounded bg-zinc-800 p-2 outline-none ring-amber-300/40 focus:ring-2" @input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'">
|
||||||
<summary>{{ video?'Queue more shots':'Queue extra edit passes' }}</summary>
|
</label>
|
||||||
<textarea v-model="batchText" rows="5" class="field-area mt-2" placeholder="First additional prompt Next additional prompt" />
|
<button type="button" class="mb-4 text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">Random</button>
|
||||||
|
<label v-if="video"><input v-model="form.settings.turbo" type="checkbox" class="accent-amber-400"> Turbo</label>
|
||||||
|
<template v-if="form.engine!=='qwen21'">
|
||||||
|
<LoraSelect v-model="form.settings.loraStack" class="mt-5" :names="pickerLoras" :use="currentLoraUse" :clip-strength="!video" filename-subtitle :aliases="{...loraNames.aliases,...library.loraAliases}" :triggers="{...loraNames.triggers,...library.loraTriggers}" missing-label="No cached LoRAs yet — Refresh to check Comfy" @insert-trigger="form.promptSections.action += ' '+$event" />
|
||||||
|
<button type="button" class="mt-2 text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="loadLoras(true)">Refresh LoRAs</button>
|
||||||
|
</template>
|
||||||
|
<details class="mt-6"><summary>Compiled prompt</summary>
|
||||||
|
<pre class="whitespace-pre-wrap text-xs text-zinc-400">{{ compiled }}</pre>
|
||||||
|
<p v-if="!video" class="mt-3 text-xs text-zinc-400">Negative: {{ stylePrompt(form.imageStyles,true) || 'None' }}</p>
|
||||||
</details>
|
</details>
|
||||||
<button type="button" class="mt-4 text-[var(--type-sm)] text-[var(--amber)]" @click="loadLoras(true)">Refresh LoRAs</button>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<h2 class="mb-6 text-xl font-semibold">Output details</h2>
|
<h2 class="mb-6 text-xl font-semibold">Output details</h2>
|
||||||
@@ -291,38 +302,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="picker" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6" @click.self="picker=''">
|
<div v-if="picker" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6" @click.self="picker=''">
|
||||||
<section role="dialog" aria-modal="true" aria-label="Choose source asset" class="max-h-[80vh] w-full max-w-4xl overflow-auto rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel)] p-5">
|
<section role="dialog" aria-modal="true" aria-label="Choose source asset" class="max-h-[80vh] w-full max-w-4xl overflow-auto rounded-2xl border border-white/15 bg-zinc-950 p-5">
|
||||||
<div class="mb-4 flex justify-between"><h2>Choose {{ picker==='identityStillId'?'hero':'source' }}</h2><button type="button" @click="picker=''">Close</button></div>
|
<div class="mb-4 flex justify-between"><h2>Choose {{ picker==='identityStillId'?'hero':'source' }}</h2><button type="button" @click="picker=''">Close</button></div>
|
||||||
<button type="button" class="mb-4 text-[var(--type-sm)] text-[var(--muted)]" @click="pickAsset(null)">Clear slot</button>
|
<button type="button" class="mb-4 text-sm text-zinc-400" @click="pickAsset(null)">Clear slot</button>
|
||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
||||||
<button v-for="a in pickerAssets" :key="a.id" type="button" class="rounded-[var(--radius)] border border-[var(--line)] p-2 text-left" @click="pickAsset(a)">
|
<button v-for="a in pickerAssets" :key="a.id" type="button" class="rounded-xl border border-white/15 p-2 text-left outline-none ring-amber-300/40 focus-visible:ring-2" @click="pickAsset(a)">
|
||||||
<span v-if="isHidden(a)" class="flex h-24 items-center text-xs text-[var(--muted)]">Still loaded · preview hidden</span>
|
<span v-if="isHidden(a)" class="flex h-24 items-center text-xs text-zinc-400">Still loaded · preview hidden</span>
|
||||||
<img v-else :src="a.kind==='clip'?'/api/library/clips/'+a.id+'/thumb':stillUrl(a.id)" class="h-24 w-full object-contain" alt="">
|
<img v-else :src="a.kind==='clip'?'/api/library/clips/'+a.id+'/thumb':stillUrl(a.id)" class="h-24 w-full object-contain" alt="Source option">
|
||||||
<span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span>
|
<span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
|
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
|
||||||
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
|
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
|
||||||
import {sectionsFrom} from '~/shared/studio2/contracts.mjs'
|
import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs'
|
||||||
import {aspectOptions,resolveSize,bindClip,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs'
|
import {CAPTION_STYLES} from '~/shared/studio2/caption.mjs'
|
||||||
|
import {aspectOptions,resolveSize,bindClip,changeSource,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs'
|
||||||
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
|
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
|
||||||
import {queueJobs} from '~/shared/studio2/queue-view.mjs'
|
import {queueJobs,elapsedSeconds} from '~/shared/studio2/queue-view.mjs'
|
||||||
import {gpuStatus} from '~/shared/studio2/status.mjs'
|
import {gpuStatus} from '~/shared/studio2/status.mjs'
|
||||||
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
|
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
|
||||||
import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs'
|
import {parseBatch,stylePrompt} from '~/shared/studio2/styles.mjs'
|
||||||
import {randomSeed} from '~/shared/studio2/seed.mjs'
|
import {randomSeed} from '~/shared/studio2/seed.mjs'
|
||||||
import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
|
import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
|
||||||
|
|
||||||
const xaigen=useRuntimeConfig().public.studio==='xaigen'
|
const xaigen=useRuntimeConfig().public.studio==='xaigen'
|
||||||
const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen')
|
const {displayMode,isStudio,isClassic}=useDisplayMode()
|
||||||
const {displayMode,isClassic}=useDisplayMode()
|
|
||||||
const {isMobile}=useIsMobile()
|
|
||||||
const classicHideInput=ref(true)
|
const classicHideInput=ref(true)
|
||||||
const classicHideOutput=ref(true)
|
const classicHideOutput=ref(true)
|
||||||
const picker=ref('')
|
const picker=ref('')
|
||||||
@@ -333,23 +343,22 @@ const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),healthState=re
|
|||||||
const library=ref<any>({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
|
const library=ref<any>({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
|
||||||
const studioQueue=ref<any[]>([])
|
const studioQueue=ref<any[]>([])
|
||||||
const liveMusic=ref<Record<string,any>>({})
|
const liveMusic=ref<Record<string,any>>({})
|
||||||
const form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[...defaultNegatives]},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'16:9',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
|
const form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[]},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
|
||||||
const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''})
|
const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''})
|
||||||
const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
|
const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
|
||||||
|
const captionStyles=CAPTION_STYLES
|
||||||
|
const captionStyle=ref('descriptive')
|
||||||
|
const captionStillId=ref('')
|
||||||
let loraMemory=createLoraMemory(xaigen)
|
let loraMemory=createLoraMemory(xaigen)
|
||||||
const currentLoraUse=computed(()=>loraUse(form))
|
const currentLoraUse=computed(()=>loraUse(form))
|
||||||
const pickerLoras=computed(()=>!xaigen && currentLoraUse.value==='ltx'?[]:filterLorasForUse(filterLorasForStudio([...loraNames.value.image,...loraNames.value.video],xaigen),currentLoraUse.value))
|
const pickerLoras=computed(()=>!xaigen && currentLoraUse.value==='ltx'?[]:filterLorasForUse(filterLorasForStudio([...loraNames.value.image,...loraNames.value.video],xaigen),currentLoraUse.value))
|
||||||
watch(()=>[currentLoraUse.value,form.settings.loraStack],()=>loraMemory.sync(form),{deep:true,flush:'sync'})
|
watch(()=>[currentLoraUse.value,form.settings.loraStack],()=>loraMemory.sync(form),{deep:true,flush:'sync'})
|
||||||
const route=useRoute()
|
const route=useRoute()
|
||||||
const tasks=['generate','edit','compose','refine','video','extend','music']
|
const tasks=['generate','edit','compose','refine','video','extend','music','caption']
|
||||||
const settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('')
|
const settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('')
|
||||||
const viewerVideo=ref<HTMLVideoElement|null>(null)
|
|
||||||
const viewerMuted=ref(false)
|
|
||||||
const videoTime=ref(0)
|
|
||||||
const videoDuration=ref(0)
|
|
||||||
const focusedJobId=ref('')
|
|
||||||
const video=computed(()=>['video','extend'].includes(form.mode))
|
const video=computed(()=>['video','extend'].includes(form.mode))
|
||||||
const kreaSticky=computed(()=>['edit','compose','iterate'].includes(form.mode))
|
const kreaSticky=computed(()=>['edit','compose','iterate'].includes(form.mode))
|
||||||
|
const qwenOnlyGenerate=computed(()=>form.mode!=='generate')
|
||||||
const engineOptions=computed(()=>{
|
const engineOptions=computed(()=>{
|
||||||
if(video.value){
|
if(video.value){
|
||||||
return [
|
return [
|
||||||
@@ -359,114 +368,79 @@ const engineOptions=computed(()=>{
|
|||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{value:'flux',label:'Klein',disabled:false,hint:''},
|
{value:'flux',label:'Klein',disabled:false,hint:''},
|
||||||
{value:'krea',label:'Krea 2',disabled:kreaSticky.value,hint:kreaSticky.value?'sticky hero = Klein':''}
|
{value:'krea',label:'Krea 2',disabled:kreaSticky.value,hint:kreaSticky.value?'sticky hero = Klein':''},
|
||||||
|
{value:'qwen21',label:'Qwen 2.1',disabled:qwenOnlyGenerate.value,hint:qwenOnlyGenerate.value?'Qwen 2.1 is T2I in this build':''}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
watch(engineOptions,opts=>{
|
watch(engineOptions,opts=>{
|
||||||
const current=opts.find(o=>o.value===form.engine)
|
const current=opts.find(o=>o.value===form.engine)
|
||||||
if(!current || current.disabled)form.engine=opts.find(o=>!o.disabled)?.value || form.engine
|
if(!current || current.disabled)form.engine=opts.find(o=>!o.disabled)?.value || form.engine
|
||||||
})
|
})
|
||||||
|
watch(()=>form.engine,(engine,prev)=>{
|
||||||
|
if(engine==='qwen21' && prev!=='qwen21'){
|
||||||
|
form.settings.steps=25
|
||||||
|
form.settings.cfg=1
|
||||||
|
form.lockFace=false
|
||||||
|
form.lockOutfit=false
|
||||||
|
form.settings.loraStack=[]
|
||||||
|
}
|
||||||
|
})
|
||||||
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
|
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
|
||||||
const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
|
const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
|
||||||
|
const compiled=computed(()=>compilePrompt(form.promptSections,video.value,form))
|
||||||
const stillUrl=(id:string)=>`/api/library/stills/${id}`
|
const stillUrl=(id:string)=>`/api/library/stills/${id}`
|
||||||
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
|
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
|
||||||
const benchJobs=computed(()=>queueJobs(folderJobs.value))
|
const captionJobs=computed(()=>folderJobs.value.filter(j=>j.request?.mode==='caption'))
|
||||||
|
const benchJobs=computed(()=>queueJobs(folderJobs.value.filter(j=>j.request?.mode!=='caption')))
|
||||||
const musicJobs=computed(()=>studioQueue.value
|
const musicJobs=computed(()=>studioQueue.value
|
||||||
.filter((j:any)=>j.kind==='music' && j.folderId===form.folderId && !['complete','done'].includes(j.status))
|
.filter((j:any)=>j.kind==='music' && j.folderId===form.folderId && !['complete','done'].includes(j.status))
|
||||||
.map((j:any)=>({...j,live:j.liveJobId?liveMusic.value[j.liveJobId]:null})))
|
.map((j:any)=>({...j,live:j.liveJobId?liveMusic.value[j.liveJobId]:null})))
|
||||||
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value))
|
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value,captionJobs.value))
|
||||||
const gpuChipLabel=computed(()=>{
|
const captionStill=computed(()=>stills.value.find((s:any)=>s.id===captionStillId.value))
|
||||||
const raw=String(healthState.value?.comfy?.gpu || healthState.value?.gpuName || healthState.value?.comfy?.device || '').trim()
|
|
||||||
const short=raw.match(/(\d{3,4})\b/)?.[1]
|
|
||||||
if(gpu.value.tone==='ready') return short ? `${short} Ready` : 'Ready'
|
|
||||||
return gpu.value.label
|
|
||||||
})
|
|
||||||
const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value})
|
const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value})
|
||||||
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
|
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
|
||||||
const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
|
const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
|
||||||
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId))
|
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId))
|
||||||
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId)))
|
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId)))
|
||||||
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
|
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
|
||||||
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value))
|
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine))
|
||||||
const negativeChips=computed(()=>form.imageStyles.negative || [])
|
|
||||||
const queueCards=computed(()=>{
|
|
||||||
const cards:any[]=[]
|
|
||||||
for(const job of benchJobs.value){
|
|
||||||
const action=job.request?.promptSections?.action || 'Job'
|
|
||||||
const eng=job.request?.engine==='ltx'?'LTX':job.request?.engine==='krea'?'Krea 2':job.request?.engine==='minimax'?'MiniMax H3':'Klein'
|
|
||||||
const mode=job.request?.mode || 'generate'
|
|
||||||
const modeLabel=mode.charAt(0).toUpperCase()+mode.slice(1)
|
|
||||||
const aspect=job.request?.settings?.aspect || 'auto'
|
|
||||||
const state=job.state==='waiting'?'queued':job.state
|
|
||||||
cards.push({
|
|
||||||
id:job.id,
|
|
||||||
job,
|
|
||||||
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
|
|
||||||
title:action.slice(0,72),
|
|
||||||
meta:`${modeLabel} · ${eng} · ${aspect}`,
|
|
||||||
state,
|
|
||||||
statusLabel:state==='queued'?'Queued':state==='rendering'||state==='submitting'||state==='waking'||state==='saving'?'Running':state==='complete'?'Ready':state,
|
|
||||||
cancelable:!['complete','failed','cancelled'].includes(job.state)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for(const job of musicJobs.value){
|
|
||||||
cards.push({
|
|
||||||
id:job.id,
|
|
||||||
job,
|
|
||||||
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
|
|
||||||
title:job.name || 'YuE2',
|
|
||||||
meta:`Music · YuE2 · ${job.duration || '—'}s`,
|
|
||||||
state:job.live?.status || job.status,
|
|
||||||
statusLabel:job.live?.status || job.status,
|
|
||||||
cancelable:!['complete','done','error','cancelled'].includes(job.status)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for(const job of upscales.value.filter((j:any)=>j.folderId===form.folderId && ['queued','running'].includes(j.status))){
|
|
||||||
cards.push({
|
|
||||||
id:job.id,
|
|
||||||
job,
|
|
||||||
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
|
|
||||||
title:job.name || 'Video upscale',
|
|
||||||
meta:'Upscale',
|
|
||||||
state:job.status,
|
|
||||||
statusLabel:job.status==='running'?'Running':'Queued',
|
|
||||||
cancelable:false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return cards
|
|
||||||
})
|
|
||||||
const queueCount=computed(()=>queueCards.value.length)
|
|
||||||
const canClearFinished=computed(()=>folderJobs.value.some(j=>['complete','failed','cancelled'].includes(j.state)) || musicJobs.value.some(j=>['complete','done','error','cancelled'].includes(j.status)))
|
|
||||||
const transportLabel=computed(()=>{
|
|
||||||
if(selected.value?.kind==='clip') return `${formatTime(videoTime.value)} / ${formatTime(videoDuration.value)}`
|
|
||||||
if(selected.value?.kind==='track') return 'Audio'
|
|
||||||
if(selected.value) return 'Still'
|
|
||||||
return '—'
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatTime(sec:number){
|
function elapsedCaption(job:any){
|
||||||
const s=Math.max(0,Number(sec)||0)
|
const seconds=elapsedSeconds(job,now.value)
|
||||||
const m=Math.floor(s/60)
|
return `${Math.floor(seconds/60)}m ${seconds%60}s ${job.startedAt||job.state==='rendering'||job.state==='saving'?'elapsed':'waiting'}`
|
||||||
const r=s-m*60
|
|
||||||
const whole=Math.floor(r)
|
|
||||||
const frac=Math.floor((r-whole)*100)
|
|
||||||
return `${String(m).padStart(2,'0')}:${String(whole).padStart(2,'0')}.${String(frac).padStart(2,'0')}`
|
|
||||||
}
|
}
|
||||||
function onVideoTime(){
|
async function copyCaption(text:string){
|
||||||
const el=viewerVideo.value
|
try{await navigator.clipboard.writeText(text)}catch{error.value='Could not copy caption.'}
|
||||||
if(!el)return
|
|
||||||
videoTime.value=el.currentTime||0
|
|
||||||
videoDuration.value=el.duration||0
|
|
||||||
}
|
}
|
||||||
function toggleFullscreen(){
|
function useCaptionAsPrompt(text:string){
|
||||||
const el=viewerVideo.value || document.querySelector('.viewer-frame')
|
const current=String(form.promptSections.action||'').trim()
|
||||||
if(el && 'requestFullscreen' in el) void (el as HTMLElement).requestFullscreen()
|
form.promptSections.action=current?`${current}\n\n${text}`:text
|
||||||
|
if(form.mode==='caption' || form.mode==='music')form.mode='generate'
|
||||||
}
|
}
|
||||||
function removeNegative(chip:string){form.imageStyles={...form.imageStyles,negative:(form.imageStyles.negative||[]).filter((c:string)=>c!==chip)}}
|
async function queueCaption(stillId:string){
|
||||||
function focusQueueCard(card:any){
|
if(!stillId){error.value='Choose a still to describe.';return false}
|
||||||
focusedJobId.value=card.id
|
try{
|
||||||
if(card.job?.outputs?.length)focusJob(card.job)
|
await $fetch('/api/studio-2/caption',{method:'POST',body:{folderId:form.folderId,stillId,sourcePath:stillId,captionStyle:captionStyle.value}})
|
||||||
|
await refresh()
|
||||||
|
return true
|
||||||
|
}catch(e:any){error.value=e.data?.statusMessage||e.message;return false}
|
||||||
}
|
}
|
||||||
|
function describeSelected(){
|
||||||
|
const id=selected.value?.kind==='still'?selected.value.id:(selected.value?.kind==='clip'? '' : (selected.value?.id || form.imageAId || form.identityStillId))
|
||||||
|
if(!id){error.value='Select a still to describe.';return}
|
||||||
|
captionStillId.value=id
|
||||||
|
submitting.value=true;error.value=''
|
||||||
|
void queueCaption(id).finally(()=>{submitting.value=false})
|
||||||
|
}
|
||||||
|
async function uploadCaption(event:Event){
|
||||||
|
const input=event.target as HTMLInputElement,file=input.files?.[0];if(!file)return
|
||||||
|
uploading.value=true;uploadMessage.value='';error.value=''
|
||||||
|
const body=new FormData();body.append('image',file);body.append('folderId',form.folderId);body.append('hideInput',String(xaigen))
|
||||||
|
try{const response=await $fetch<any>('/api/studio-2/stills',{method:'POST',body});library.value=response;captionStillId.value=response.still.id;measured[response.still.id]={width:response.still.width,height:response.still.height};uploadMessage.value=`Loaded ${response.still.name || file.name} for caption.`}
|
||||||
|
catch(e:any){error.value=e.data?.statusMessage||e.message}
|
||||||
|
finally{uploading.value=false;input.value=''}
|
||||||
|
}
|
||||||
|
|
||||||
function isHidden(asset:any){
|
function isHidden(asset:any){
|
||||||
if(!xaigen || !asset?.id)return false
|
if(!xaigen || !asset?.id)return false
|
||||||
if(isClassic.value){
|
if(isClassic.value){
|
||||||
@@ -494,10 +468,14 @@ function loadRevealed(folderId:string){
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(()=>sourceAsset.value?.id,id=>{const asset=sourceAsset.value;if(!import.meta.client||!asset||measured[id])return;const media=asset.id===form.startClipId?document.createElement('video'):new Image();if(media.tagName==='VIDEO')(media as HTMLVideoElement).preload='metadata';const done=()=>{measured[asset.id]={width:(media as HTMLVideoElement).videoWidth||(media as HTMLImageElement).naturalWidth,height:(media as HTMLVideoElement).videoHeight||(media as HTMLImageElement).naturalHeight}};media.addEventListener(media.tagName==='VIDEO'?'loadedmetadata':'load',done,{once:true});media.src=media.tagName==='VIDEO'?`/api/library/clips/${asset.id}/video`:stillUrl(asset.id)})
|
watch(()=>sourceAsset.value?.id,id=>{const asset=sourceAsset.value;if(!import.meta.client||!asset||measured[id])return;const media=asset.id===form.startClipId?document.createElement('video'):new Image();if(media.tagName==='VIDEO')(media as HTMLVideoElement).preload='metadata';const done=()=>{measured[asset.id]={width:(media as HTMLVideoElement).videoWidth||(media as HTMLImageElement).naturalWidth,height:(media as HTMLVideoElement).videoHeight||(media as HTMLImageElement).naturalHeight}};media.addEventListener(media.tagName==='VIDEO'?'loadedmetadata':'load',done,{once:true});media.src=media.tagName==='VIDEO'?`/api/library/clips/${asset.id}/video`:stillUrl(asset.id)})
|
||||||
watch(()=>selected.value?.id,()=>{videoTime.value=0;videoDuration.value=0})
|
|
||||||
|
|
||||||
function chooseTask(task:string){
|
function chooseTask(task:string){
|
||||||
if(task==='music'){form.mode='music';return}
|
if(task==='music'){form.mode='music';return}
|
||||||
|
if(task==='caption'){
|
||||||
|
form.mode='caption'
|
||||||
|
captionStillId.value=selected.value?.kind==='still'?selected.value.id:form.imageAId||form.identityStillId||captionStillId.value
|
||||||
|
return
|
||||||
|
}
|
||||||
if(task==='extend'){
|
if(task==='extend'){
|
||||||
form.mode='extend'
|
form.mode='extend'
|
||||||
form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'
|
form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'
|
||||||
@@ -509,15 +487,14 @@ function chooseTask(task:string){
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const was=video.value;form.mode=task;form.startClipId='';if(video.value!==was){form.engine=video.value?(xaigen && form.engine==='ltx'?'ltx':'minimax'):'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4}
|
const was=video.value;form.mode=task;form.startClipId='';if(video.value!==was){form.engine=video.value?(xaigen && form.engine==='ltx'?'ltx':'minimax'):'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4}
|
||||||
if(selected.value?.kind==='track' && task!=='music') selected.value=family.value.find((a:any)=>a.kind!=='track') || null
|
|
||||||
}
|
}
|
||||||
function onTaskSelect(event:Event){chooseTask((event.target as HTMLSelectElement).value)}
|
function onTaskSelect(event:Event){chooseTask((event.target as HTMLSelectElement).value)}
|
||||||
function chooseClip(id:string){if(!id){form.startClipId='';return}const clip=clips.value.find((c:any)=>c.id===id);if(clip){try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}catch(e:any){error.value=e?.message||'Could not copy clip settings.';form.startClipId=clip.id;form.mode='extend';form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'}}}
|
function chooseClip(id:string){if(!id){form.startClipId='';return}const clip=clips.value.find((c:any)=>c.id===id);if(clip){try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}catch(e:any){error.value=e?.message||'Could not copy clip settings.';form.startClipId=clip.id;form.mode='extend';form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'}}}
|
||||||
function selectOutput(item:any){selected.value=item;if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)}
|
function selectOutput(item:any){selected.value=item;if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)}
|
||||||
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
|
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
|
||||||
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null);focusedJobId.value=job.id}
|
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null)}
|
||||||
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
|
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
|
||||||
function pickAsset(asset:any){const key=picker.value;if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||'';else if(key==='startClipId')chooseClip(asset?.id||'');else form[key]=asset?.id||'';picker.value=''}
|
function pickAsset(asset:any){const key=picker.value;if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||'';else if(key==='startClipId')chooseClip(asset?.id||'');else if(key==='captionStillId')captionStillId.value=asset?.id||'';else form[key]=asset?.id||'';picker.value=''}
|
||||||
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
|
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
|
||||||
async function deleteOutput(){
|
async function deleteOutput(){
|
||||||
if(!selected.value)return
|
if(!selected.value)return
|
||||||
@@ -535,6 +512,10 @@ async function queue(){
|
|||||||
await refresh()
|
await refresh()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if(form.mode==='caption'){
|
||||||
|
await queueCaption(captionStillId.value || form.imageAId || form.identityStillId)
|
||||||
|
return
|
||||||
|
}
|
||||||
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
|
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
|
||||||
const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body:{...form,mode:form.mode==='edit' && parseBatch(batchText.value).length?'iterate':form.mode,hideInput:xaigen,hideOutput:false,promptSections:video.value?form.promptSections:{action:form.promptSections.action},settings:{...form.settings,...resolvedSize.value},batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]}})
|
const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body:{...form,mode:form.mode==='edit' && parseBatch(batchText.value).length?'iterate':form.mode,hideInput:xaigen,hideOutput:false,promptSections:video.value?form.promptSections:{action:form.promptSections.action},settings:{...form.settings,...resolvedSize.value},batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]}})
|
||||||
form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
|
form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
|
||||||
@@ -573,12 +554,8 @@ async function refresh(){
|
|||||||
for(const job of nextJobs)for(const output of job.outputs||[])seenOutputs.add(output.id)
|
for(const job of nextJobs)for(const output of job.outputs||[])seenOutputs.add(output.id)
|
||||||
const existingFresh=family.value.find(a=>a.id===fresh?.id)
|
const existingFresh=family.value.find(a=>a.id===fresh?.id)
|
||||||
const newestTrack=(library.value.tracks||[]).filter((t:any)=>t.folderId===form.folderId).sort((a:any,b:any)=>(b.createdAt||0)-(a.createdAt||0))[0]
|
const newestTrack=(library.value.tracks||[]).filter((t:any)=>t.folderId===form.folderId).sort((a:any,b:any)=>(b.createdAt||0)-(a.createdAt||0))[0]
|
||||||
// Only auto-select new tracks on Music — never leave a waveform mounted over Generate/Klein/Video.
|
if(newestTrack && !seenOutputs.has(newestTrack.id)){seenOutputs.add(newestTrack.id);selected.value={...newestTrack,kind:'track'};return}
|
||||||
if(form.mode==='music' && newestTrack && !seenOutputs.has(newestTrack.id)){seenOutputs.add(newestTrack.id);selected.value={...newestTrack,kind:'track'};return}
|
selected.value=existingFresh || reconcileSelection(selected.value,family.value)
|
||||||
if(newestTrack) seenOutputs.add(newestTrack.id)
|
|
||||||
let next=existingFresh || reconcileSelection(selected.value,family.value)
|
|
||||||
if(form.mode!=='music' && next?.kind==='track') next=family.value.find((a:any)=>a.kind!=='track') || null
|
|
||||||
selected.value=next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.startClipId='';loadRevealed(id);if(import.meta.client)form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
|
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.startClipId='';loadRevealed(id);if(import.meta.client)form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
|
||||||
@@ -593,58 +570,8 @@ onMounted(async()=>{
|
|||||||
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
|
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
|
||||||
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
|
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
|
||||||
if(route.query.task==='music')form.mode='music'
|
if(route.query.task==='music')form.mode='music'
|
||||||
|
if(route.query.task==='caption')form.mode='caption'
|
||||||
const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()
|
const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()
|
||||||
})
|
})
|
||||||
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
|
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
|
||||||
|
|
||||||
function randomizeSeed(){form.settings.seedMode='random';form.settings.seed=randomSeed()}
|
|
||||||
function setPicker(key:string){picker.value=key}
|
|
||||||
|
|
||||||
const mobileCtx=computed(()=>({
|
|
||||||
instanceName,
|
|
||||||
xaigen,
|
|
||||||
form,
|
|
||||||
music,
|
|
||||||
library:library.value,
|
|
||||||
stills:stills.value,
|
|
||||||
tasks,
|
|
||||||
engineOptions:engineOptions.value,
|
|
||||||
gpu:gpu.value,
|
|
||||||
gpuChipLabel:gpuChipLabel.value,
|
|
||||||
queueCount:queueCount.value,
|
|
||||||
queueCards:queueCards.value,
|
|
||||||
focusedJobId:focusedJobId.value,
|
|
||||||
canClearFinished:canClearFinished.value,
|
|
||||||
selected:selected.value,
|
|
||||||
family:family.value,
|
|
||||||
sourceSlot:sourceSlot.value,
|
|
||||||
video:video.value,
|
|
||||||
submitting:submitting.value,
|
|
||||||
uploading:uploading.value,
|
|
||||||
uploadMessage:uploadMessage.value,
|
|
||||||
error:error.value,
|
|
||||||
batchText:batchText.value,
|
|
||||||
negativeChips:negativeChips.value,
|
|
||||||
pickerLoras:pickerLoras.value,
|
|
||||||
currentLoraUse:currentLoraUse.value,
|
|
||||||
loraAliases:{...loraNames.value.aliases,...library.value.loraAliases},
|
|
||||||
loraTriggers:{...loraNames.value.triggers,...library.value.loraTriggers},
|
|
||||||
aspectOptions,
|
|
||||||
stillUrl,
|
|
||||||
isHidden,
|
|
||||||
slotHidden,
|
|
||||||
toggleReveal,
|
|
||||||
chooseTask,
|
|
||||||
selectOutput,
|
|
||||||
deleteOutput,
|
|
||||||
queue,
|
|
||||||
upload,
|
|
||||||
cancel,
|
|
||||||
focusQueueCard,
|
|
||||||
clearFinished,
|
|
||||||
removeNegative,
|
|
||||||
randomizeSeed,
|
|
||||||
setPicker,
|
|
||||||
setBatchText:(v:string)=>{batchText.value=v},
|
|
||||||
}))
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,543 +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 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="Hero still" :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.form.mode==='extend'?'Previous clip':'Start still'" :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')" />
|
|
||||||
<Studio2AssetSlot v-if="ctx.form.mode==='compose'" label="Second still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.imageBId)" :hidden="ctx.slotHidden(ctx.form.imageBId)" @pick="ctx.setPicker('imageBId')" />
|
|
||||||
<Studio2AssetSlot v-if="ctx.form.mode==='refine'" label="Mask" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.maskId)" :hidden="ctx.slotHidden(ctx.form.maskId)" @pick="ctx.setPicker('maskId')" />
|
|
||||||
<Studio2AssetSlot v-if="ctx.video" label="End still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.endStillId)" :hidden="ctx.slotHidden(ctx.form.endStillId)" @pick="ctx.setPicker('endStillId')" />
|
|
||||||
<label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label>
|
|
||||||
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label>
|
|
||||||
<p class="m-hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
|
||||||
<p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<aside v-if="sheet==='prompt'" class="m-sheet" role="dialog" aria-label="Prompt">
|
|
||||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
|
||||||
<div class="m-sheet-head"><strong>Prompt</strong><button type="button" @click="sheet=''">Done</button></div>
|
|
||||||
<div class="m-sheet-body">
|
|
||||||
<template v-if="ctx.form.mode==='music'">
|
|
||||||
<label class="m-label">Genre / style tags<textarea v-model="ctx.music.tags" rows="3" class="m-area" /></label>
|
|
||||||
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label>
|
|
||||||
<div>
|
|
||||||
<span class="m-label">Negative prompt</span>
|
|
||||||
<div class="m-neg">
|
|
||||||
<span v-for="chip in ctx.negativeChips" :key="chip" class="m-neg-chip">
|
|
||||||
{{ chip }}
|
|
||||||
<button type="button" :aria-label="'Remove '+chip" @click="ctx.removeNegative(chip)">×</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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-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.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Qwen Image 2.1 (engine `qwen21`) — host weights + Comfy graph
|
||||||
|
|
||||||
|
## Host paths (this RTX 5080 box)
|
||||||
|
|
||||||
|
| Role | Resolved path |
|
||||||
|
|---|---|
|
||||||
|
| ComfyUI root (Klein / host agent `ComfyUI (1)`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI` |
|
||||||
|
| Models root (Desktop Shared via `shared_model_paths.yaml`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Shared\models` |
|
||||||
|
|
||||||
|
Weights must land under Shared so every Desktop instance sees them:
|
||||||
|
|
||||||
|
- `diffusion_models\qwen-image-2.1-Q8_0.gguf` (~7.59 GiB) — from `abenzerps/Qwen-Image-2.1-Uncensored-GGUF`
|
||||||
|
- `text_encoders\qwen3vl_8b_int8_convrot.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (INT8; BF16 8B VL OOMs on 16 GB)
|
||||||
|
- `vae\qwen_image_2.1_vae_bf16.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (**not** the old Qwen-Image 1.0 VAE)
|
||||||
|
|
||||||
|
Custom node: `custom_nodes\ComfyUI-GGUF` (`UnetLoaderGGUF`). Do not install a second GGUF pack.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
DiT download uses the exact host invocation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||||
|
```
|
||||||
|
|
||||||
|
That lands in the Hugging Face hub cache; the setup script copies it into Shared `diffusion_models`. The abenzerps GGUF ships with `kv_count=0` (no `general.architecture`) **and** Q8_0-quantized 1D RMSNorm weights (logical 128 → packed 136), which breaks Comfy’s fused `rms_rope`. `scripts/tag-qwen21-gguf.py` rewrites the file with `general.architecture=qwen_image` and promotes small/1D tensors to F32 so city96 `UnetLoaderGGUF` can load it.
|
||||||
|
|
||||||
|
Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false`). Confirm `object_info` lists `UnetLoaderGGUF` and the three filenames.
|
||||||
|
|
||||||
|
## App
|
||||||
|
|
||||||
|
- Engine key: `qwen21` · UI label: **Qwen 2.1**
|
||||||
|
- Generate (T2I) 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.
|
||||||
|
|
||||||
|
Graph: `server/assets/studio2_qwen21_t2i.json`.
|
||||||
@@ -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 { createGpuReservation } from './gpu-reservation.mjs'
|
||||||
import { createGpuProxy } from './gpu-proxy.mjs'
|
import { createGpuProxy } from './gpu-proxy.mjs'
|
||||||
import { createYue2Host } from './yue2-host.mjs'
|
import { createYue2Host } from './yue2-host.mjs'
|
||||||
|
import { createCaptionHost } from './caption-host.mjs'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
import net from 'node:net'
|
import net from 'node:net'
|
||||||
import { execFile, spawn } from 'node:child_process'
|
import { execFile, spawn } from 'node:child_process'
|
||||||
@@ -147,7 +148,7 @@ let proxyTarget = 0
|
|||||||
|
|
||||||
function ensureProxyListening() {
|
function ensureProxyListening() {
|
||||||
if (proxyServer) return
|
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) => {
|
proxyServer.on('error', (error) => {
|
||||||
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || 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 () => {
|
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
|
if ((await trainingLock()).busy) return false
|
||||||
const healthy = await syncProxy()
|
const healthy = await syncProxy()
|
||||||
if (healthy) {
|
if (healthy) {
|
||||||
@@ -830,14 +831,15 @@ const gpuReservation = createGpuReservation({ idle: async () => {
|
|||||||
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
||||||
} })
|
} })
|
||||||
|
|
||||||
const yue2 = createYue2Host({
|
async function prepareExclusiveGpu(label) {
|
||||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
|
||||||
prepare: async () => {
|
|
||||||
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
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()
|
const healthy = await syncProxy()
|
||||||
if (healthy) {
|
if (healthy) {
|
||||||
const queue = await fetchLocalQueue(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.
|
// Stop Comfy and refuse to launch while its Python still owns VRAM.
|
||||||
if (healthy || await processUp() || await pythonMainUp().catch(() => false)) {
|
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.')
|
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 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) {
|
async function handleControl(req, res) {
|
||||||
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
|
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
|
||||||
const url = new URL(req.url || '/', 'http://localhost')
|
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/')) {
|
if (url.pathname.startsWith('/upscale/')) {
|
||||||
const match = url.pathname.match(/^\/upscale\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/video|\/cancel)?$/)
|
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'] || '')))
|
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(),
|
gpu: gpuReservation.availability(),
|
||||||
training: { busy: lastTraining.busy },
|
training: { busy: lastTraining.busy },
|
||||||
yue2: { busy: yue2.busy(), configured: yue2.configured() },
|
yue2: { busy: yue2.busy(), configured: yue2.configured() },
|
||||||
|
caption: { busy: caption.busy(), configured: caption.configured() },
|
||||||
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
|
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && url.pathname === '/start') {
|
if (req.method === 'POST' && url.pathname === '/start') {
|
||||||
if (upscale.busy()) return json(res, 409, { message: 'Local upscale is using the GPU.' })
|
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 (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()
|
const training = await trainingLock()
|
||||||
if (training.busy) {
|
if (training.busy) {
|
||||||
return json(res, 409, {
|
return json(res, 409, {
|
||||||
@@ -1016,7 +1057,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
} else await handleControl(req, res)
|
} else await handleControl(req, res)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
req.resume()
|
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.' })
|
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."
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# Download Qwen Image 2.1 GGUF + companions into the live Comfy Desktop Shared models tree.
|
||||||
|
# Also ensures city96/ComfyUI-GGUF is present on the ComfyUI (1) install.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||||
|
# powershell -File scripts\setup-qwen21.ps1 -SkipDownload
|
||||||
|
# powershell -File scripts\setup-qwen21.ps1 -ComfyRoot "C:\...\ComfyUI (1)\ComfyUI"
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ComfyRoot = '',
|
||||||
|
[string]$ModelsRoot = '',
|
||||||
|
[switch]$SkipDownload,
|
||||||
|
[switch]$SkipNode,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'Continue'
|
||||||
|
|
||||||
|
function Write-Step([string]$Message) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ok([string]$Message) {
|
||||||
|
Write-Host " $Message" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Warn([string]$Message) {
|
||||||
|
Write-Host " warn $Message" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ComfyRoot([string]$Hint) {
|
||||||
|
$candidates = @(
|
||||||
|
$Hint,
|
||||||
|
$env:COMFY_ROOT,
|
||||||
|
$env:COMFYUI_ROOT,
|
||||||
|
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI'),
|
||||||
|
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI\ComfyUI'),
|
||||||
|
'C:\ComfyUI',
|
||||||
|
(Join-Path $env:USERPROFILE 'ComfyUI')
|
||||||
|
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||||
|
|
||||||
|
foreach ($path in $candidates) {
|
||||||
|
if (Test-Path (Join-Path $path 'main.py')) { return (Resolve-Path $path).Path }
|
||||||
|
if (Test-Path (Join-Path $path 'ComfyUI\main.py')) { return (Resolve-Path (Join-Path $path 'ComfyUI')).Path }
|
||||||
|
}
|
||||||
|
throw "Could not find ComfyUI root (main.py). Pass -ComfyRoot."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ModelsRoot([string]$Hint, [string]$ComfyRootPath) {
|
||||||
|
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
|
||||||
|
$candidates = @(
|
||||||
|
$Hint,
|
||||||
|
$env:COMFY_MODELS_ROOT,
|
||||||
|
$shared,
|
||||||
|
(Join-Path $ComfyRootPath 'models')
|
||||||
|
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||||
|
|
||||||
|
foreach ($path in $candidates) {
|
||||||
|
if (Test-Path $path) { return (Resolve-Path $path).Path }
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path $shared | Out-Null
|
||||||
|
return (Resolve-Path $shared).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-HfCli {
|
||||||
|
foreach ($name in @('hf', 'huggingface-cli')) {
|
||||||
|
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||||
|
if ($cmd) { return $cmd.Source }
|
||||||
|
}
|
||||||
|
throw "Hugging Face CLI not found (hf / huggingface-cli). Install with: pip install -U huggingface_hub"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-Dir([string]$Path) {
|
||||||
|
if (-not (Test-Path $Path)) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $Path | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ModelFile([string]$Path, [long]$MinBytes) {
|
||||||
|
if (-not (Test-Path $Path)) { throw "Missing model file: $Path" }
|
||||||
|
$item = Get-Item $Path
|
||||||
|
if ($item.Length -lt $MinBytes) {
|
||||||
|
throw "Suspicious size for $Path ($($item.Length) bytes). Expected >= $MinBytes. Delete and re-run."
|
||||||
|
}
|
||||||
|
Write-Ok ("{0} {1:N0} bytes" -f $item.FullName, $item.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Flatten-IfNested([string]$DestDir, [string]$FileName) {
|
||||||
|
$nested = Get-ChildItem $DestDir -Recurse -File -Filter $FileName -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.DirectoryName -ne $DestDir } |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($nested) {
|
||||||
|
$target = Join-Path $DestDir $FileName
|
||||||
|
if ((Test-Path $target) -and -not $Force) {
|
||||||
|
Write-Warn "Nested copy found but destination already exists: $target"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Move-Item -Force $nested.FullName $target
|
||||||
|
Write-Ok "Flattened $($nested.FullName) -> $target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-HfFile {
|
||||||
|
param(
|
||||||
|
[string]$Cli,
|
||||||
|
[string]$Repo,
|
||||||
|
[string]$File,
|
||||||
|
[string]$LocalDir
|
||||||
|
)
|
||||||
|
Ensure-Dir $LocalDir
|
||||||
|
$dest = Join-Path $LocalDir (Split-Path $File -Leaf)
|
||||||
|
if ((Test-Path $dest) -and -not $Force) {
|
||||||
|
$len = (Get-Item $dest).Length
|
||||||
|
if ($len -gt 1MB) {
|
||||||
|
Write-Ok "Already present: $dest ($len bytes)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Step "hf download $Repo $File"
|
||||||
|
& $Cli download $Repo $File --local-dir $LocalDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "hf download failed for $Repo / $File (exit $LASTEXITCODE)" }
|
||||||
|
Flatten-IfNested -DestDir $LocalDir -FileName (Split-Path $File -Leaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- resolve paths ---
|
||||||
|
Write-Step "Resolve ComfyUI root"
|
||||||
|
$root = Resolve-ComfyRoot $ComfyRoot
|
||||||
|
Write-Ok $root
|
||||||
|
|
||||||
|
Write-Step "Resolve models root (Desktop Shared preferred)"
|
||||||
|
$models = Resolve-ModelsRoot $ModelsRoot $root
|
||||||
|
Write-Ok $models
|
||||||
|
|
||||||
|
$Diff = Join-Path $models 'diffusion_models'
|
||||||
|
$TE = Join-Path $models 'text_encoders'
|
||||||
|
$VAE = Join-Path $models 'vae'
|
||||||
|
Ensure-Dir $Diff
|
||||||
|
Ensure-Dir $TE
|
||||||
|
Ensure-Dir $VAE
|
||||||
|
|
||||||
|
if (-not $SkipDownload) {
|
||||||
|
$cli = Find-HfCli
|
||||||
|
Write-Ok "HF CLI: $cli"
|
||||||
|
|
||||||
|
# DiT GGUF only (no Q6/Q5/Q4, no full bf16 DiT)
|
||||||
|
# Exact invocation required on this host:
|
||||||
|
# hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||||
|
# That form writes into the Hugging Face hub cache (not --local-dir).
|
||||||
|
Write-Step "hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf"
|
||||||
|
$ditName = 'qwen-image-2.1-Q8_0.gguf'
|
||||||
|
$ditDest = Join-Path $Diff $ditName
|
||||||
|
if ((Test-Path $ditDest) -and -not $Force -and (Get-Item $ditDest).Length -gt 1MB) {
|
||||||
|
Write-Ok "Already present: $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||||
|
} else {
|
||||||
|
& $cli download "hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/$ditName"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "hf download failed for DiT GGUF (exit $LASTEXITCODE)" }
|
||||||
|
$cacheRoot = Join-Path $env:USERPROFILE '.cache\huggingface\hub\models--abenzerps--Qwen-Image-2.1-Uncensored-GGUF'
|
||||||
|
$found = Get-ChildItem $cacheRoot -Recurse -File -Filter $ditName -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $found) { throw "hf download finished but $ditName was not found under $cacheRoot" }
|
||||||
|
Ensure-Dir $Diff
|
||||||
|
Copy-Item -Force $found.FullName $ditDest
|
||||||
|
Write-Ok "Copied $($found.FullName) -> $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# abenzerps ships kv_count=0 + Q8_0 1D RMSNorms (packed 136 vs logical 128).
|
||||||
|
# Tag architecture=qwen_image and promote small/1D tensors to F32 for ComfyUI-GGUF.
|
||||||
|
Write-Step "Tag DiT GGUF (architecture + F32 norms)"
|
||||||
|
$tagger = Join-Path $PSScriptRoot 'tag-qwen21-gguf.py'
|
||||||
|
$pyCandidates = @(
|
||||||
|
(Join-Path $root '.venv\Scripts\python.exe'),
|
||||||
|
(Join-Path (Split-Path $root) 'standalone-env\python.exe'),
|
||||||
|
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)
|
||||||
|
) | Where-Object { $_ -and (Test-Path $_) }
|
||||||
|
$py = $pyCandidates | Select-Object -First 1
|
||||||
|
if (-not $py) { throw "No Python found to run $tagger" }
|
||||||
|
$fixed = Join-Path $Diff 'qwen-image-2.1-Q8_0.fixed.gguf'
|
||||||
|
& $py $tagger $ditDest $fixed
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "tag-qwen21-gguf.py failed (exit $LASTEXITCODE)" }
|
||||||
|
Copy-Item -Force $fixed $ditDest
|
||||||
|
Write-Ok "Installed tagged GGUF at $ditDest"
|
||||||
|
|
||||||
|
# TE + VAE companions from Comfy-Org (INT8 TE for 16 GB VRAM)
|
||||||
|
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'text_encoders/qwen3vl_8b_int8_convrot.safetensors' -LocalDir $TE
|
||||||
|
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'vae/qwen_image_2.1_vae_bf16.safetensors' -LocalDir $VAE
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Verify weights"
|
||||||
|
Assert-ModelFile (Join-Path $Diff 'qwen-image-2.1-Q8_0.gguf') 7000000000
|
||||||
|
Assert-ModelFile (Join-Path $TE 'qwen3vl_8b_int8_convrot.safetensors') 1000000000
|
||||||
|
Assert-ModelFile (Join-Path $VAE 'qwen_image_2.1_vae_bf16.safetensors') 100000000
|
||||||
|
|
||||||
|
if (-not $SkipNode) {
|
||||||
|
Write-Step "city96 ComfyUI-GGUF"
|
||||||
|
$nodes = Join-Path $root 'custom_nodes\ComfyUI-GGUF'
|
||||||
|
if (-not (Test-Path $nodes)) {
|
||||||
|
git clone https://github.com/city96/ComfyUI-GGUF $nodes
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git clone ComfyUI-GGUF failed" }
|
||||||
|
Write-Ok "Cloned $nodes"
|
||||||
|
} else {
|
||||||
|
Write-Ok "Already present: $nodes"
|
||||||
|
}
|
||||||
|
|
||||||
|
$py = Join-Path $root '.venv\Scripts\python.exe'
|
||||||
|
if (-not (Test-Path $py)) {
|
||||||
|
$py = Join-Path (Split-Path $root) 'standalone-env\python.exe'
|
||||||
|
}
|
||||||
|
if (Test-Path $py) {
|
||||||
|
Write-Step "Ensure gguf package in Comfy python ($py)"
|
||||||
|
& $py -m pip install -q gguf
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Warn "pip install gguf returned $LASTEXITCODE" }
|
||||||
|
else { Write-Ok "gguf importable" }
|
||||||
|
} else {
|
||||||
|
Write-Warn "Comfy python not found; install gguf into the interpreter that runs main.py"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Done. Restart Comfy only when /status shows gpu.busy=false." -ForegroundColor Cyan
|
||||||
|
Write-Host "ComfyRoot=$root"
|
||||||
|
Write-Host "ModelsRoot=$models"
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Make abenzerps Qwen Image 2.1 DiT GGUF loadable in city96 ComfyUI-GGUF.
|
||||||
|
|
||||||
|
abenzerps/Qwen-Image-2.1-Uncensored-GGUF ships Q8_0 with:
|
||||||
|
- kv_count=0 (no general.architecture)
|
||||||
|
- 1D RMSNorm weights quantized to Q8_0 (logical 128 -> packed 136),
|
||||||
|
which breaks Comfy's fused rms_rope path
|
||||||
|
|
||||||
|
This rewrite:
|
||||||
|
1. Adds general.architecture=qwen_image
|
||||||
|
2. Dequantizes small / 1D tensors to F32 (city96 convert keeps them hiprec)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Match city96 ComfyUI-GGUF/tools/convert.py QUANTIZATION_THRESHOLD
|
||||||
|
QUANTIZATION_THRESHOLD = 1024
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("src", type=Path)
|
||||||
|
ap.add_argument("dst", type=Path, nargs="?", default=None)
|
||||||
|
ap.add_argument("--arch", default="qwen_image")
|
||||||
|
ap.add_argument("--name", default="qwen-image-2.1")
|
||||||
|
ap.add_argument("--inplace", action="store_true", help="Replace src after a successful tag")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
import gguf
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
src = args.src.resolve()
|
||||||
|
if not src.is_file():
|
||||||
|
print(f"missing source: {src}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
dst = (args.dst.resolve() if args.dst else src.with_name(src.stem + ".tagged.gguf"))
|
||||||
|
|
||||||
|
reader = gguf.GGUFReader(str(src))
|
||||||
|
|
||||||
|
def get_field(name: str):
|
||||||
|
field = reader.fields.get(name)
|
||||||
|
if field is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return field.contents()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if dst == src:
|
||||||
|
dst = src.with_name(src.stem + ".retag.gguf")
|
||||||
|
|
||||||
|
f32 = gguf.GGMLQuantizationType.F32
|
||||||
|
compat = {gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16, gguf.GGMLQuantizationType.BF16}
|
||||||
|
|
||||||
|
print(f"rewriting {src} -> {dst} (arch={args.arch}, tensors={len(reader.tensors)})")
|
||||||
|
writer = gguf.GGUFWriter(str(dst), arch=args.arch, use_temp_file=True)
|
||||||
|
writer.add_name(args.name)
|
||||||
|
try:
|
||||||
|
writer.add_type("model")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
promoted = 0
|
||||||
|
for tensor in reader.tensors:
|
||||||
|
logical = tuple(int(x) for x in tensor.shape)
|
||||||
|
# GGUF stores dims reversed vs torch; logical numel is what matters
|
||||||
|
numel = 1
|
||||||
|
for d in logical:
|
||||||
|
numel *= d
|
||||||
|
qtype = tensor.tensor_type
|
||||||
|
data = tensor.data
|
||||||
|
if hasattr(data, "copy"):
|
||||||
|
data = data.copy()
|
||||||
|
|
||||||
|
needs_f32 = qtype not in compat and (len(logical) <= 1 or numel <= QUANTIZATION_THRESHOLD)
|
||||||
|
if needs_f32:
|
||||||
|
# Dequantize packed blocks -> float32 with logical shape (GGUF order)
|
||||||
|
dequant = gguf.quants.dequantize(np.asarray(data), qtype).astype(np.float32, copy=False)
|
||||||
|
expected = numel
|
||||||
|
if dequant.size != expected:
|
||||||
|
print(
|
||||||
|
f"dequant size mismatch {tensor.name}: got {dequant.size} want {expected} "
|
||||||
|
f"shape={logical} qtype={qtype}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 3
|
||||||
|
data = dequant.reshape(logical)
|
||||||
|
qtype = f32
|
||||||
|
promoted += 1
|
||||||
|
|
||||||
|
writer.add_tensor(tensor.name, data, raw_dtype=qtype)
|
||||||
|
|
||||||
|
writer.write_header_to_file()
|
||||||
|
writer.write_kv_data_to_file()
|
||||||
|
writer.write_tensors_to_file(progress=True)
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
check = gguf.GGUFReader(str(dst))
|
||||||
|
field = check.fields.get("general.architecture")
|
||||||
|
got = field.contents() if field is not None else None
|
||||||
|
print(
|
||||||
|
f"verified architecture={got!r} tensors={len(check.tensors)} "
|
||||||
|
f"promoted_f32={promoted} size={dst.stat().st_size}"
|
||||||
|
)
|
||||||
|
if got != args.arch or len(check.tensors) != len(reader.tensors):
|
||||||
|
print("tag failed", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Spot-check a known norm weight is F32 @ 128
|
||||||
|
sample = next((t for t in check.tensors if t.name.endswith("attn.norm_q.weight")), None)
|
||||||
|
if sample is not None:
|
||||||
|
print(
|
||||||
|
f"sample {sample.name}: shape={tuple(int(x) for x in sample.shape)} "
|
||||||
|
f"type={sample.tensor_type.name} data_shape={tuple(sample.data.shape)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.inplace:
|
||||||
|
bak = src.with_suffix(src.suffix + ".untagged.bak")
|
||||||
|
if bak.exists():
|
||||||
|
bak.unlink()
|
||||||
|
src.replace(bak)
|
||||||
|
dst.replace(src)
|
||||||
|
print(f"inplace: {src} (backup {bak})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -48,7 +48,7 @@ export default defineEventHandler(async () => {
|
|||||||
host: comfy.host || '',
|
host: comfy.host || '',
|
||||||
queueRunning: queue.running,
|
queueRunning: queue.running,
|
||||||
queuePending: queue.pending,
|
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),
|
processUp: Boolean(control?.process ?? control?.processUp),
|
||||||
lastActivityAt: control?.lastActivityAt || null,
|
lastActivityAt: control?.lastActivityAt || null,
|
||||||
stats: comfy.ok ? comfy.stats : undefined
|
stats: comfy.ok ? comfy.stats : undefined
|
||||||
@@ -58,6 +58,11 @@ export default defineEventHandler(async () => {
|
|||||||
busy: Boolean(control?.yue2?.busy),
|
busy: Boolean(control?.yue2?.busy),
|
||||||
backend: control?.yue2?.backend || 'yue2'
|
backend: control?.yue2?.backend || 'yue2'
|
||||||
},
|
},
|
||||||
|
caption: {
|
||||||
|
configured: Boolean(control?.caption?.configured),
|
||||||
|
busy: Boolean(control?.caption?.busy),
|
||||||
|
backend: control?.caption?.backend || 'llama.cpp'
|
||||||
|
},
|
||||||
imageComfy: {
|
imageComfy: {
|
||||||
configured: imageConfigured,
|
configured: imageConfigured,
|
||||||
ok: imageOk,
|
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 }
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||||
|
},
|
||||||
|
"class_type": "UnetLoaderGGUF",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"inputs": {
|
||||||
|
"shift": 3.1,
|
||||||
|
"model": ["4", 0]
|
||||||
|
},
|
||||||
|
"class_type": "ModelSamplingAuraFlow",
|
||||||
|
"_meta": { "title": "Qwen 2.1 shift" }
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"resolution": 1024
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImage21",
|
||||||
|
"_meta": { "title": "Text Encode Qwen Image 2.1" }
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": 1,
|
||||||
|
"steps": 25,
|
||||||
|
"cfg": 1,
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "simple",
|
||||||
|
"denoise": 1,
|
||||||
|
"model": ["3", 0],
|
||||||
|
"positive": ["9", 0],
|
||||||
|
"negative": ["9", 1],
|
||||||
|
"latent_image": ["9", 2]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"_meta": { "title": "KSampler" }
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["15", 0],
|
||||||
|
"vae": ["6", 0]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": { "title": "VAE Decode" }
|
||||||
|
},
|
||||||
|
"21": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "studio2-qwen21",
|
||||||
|
"images": ["20", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": { "title": "Save Image" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,9 +36,11 @@ export interface Job {
|
|||||||
upscale?: boolean
|
upscale?: boolean
|
||||||
yueGp?: boolean
|
yueGp?: boolean
|
||||||
yue2?: boolean
|
yue2?: boolean
|
||||||
|
caption?: boolean
|
||||||
|
resultText?: string
|
||||||
musicActivity?: { checkedAt: number; running: boolean }
|
musicActivity?: { checkedAt: number; running: boolean }
|
||||||
id: string
|
id: string
|
||||||
kind?: 'video' | 'edit' | 'music'
|
kind?: 'video' | 'edit' | 'music' | 'caption'
|
||||||
promptId?: string
|
promptId?: string
|
||||||
clientId: string
|
clientId: string
|
||||||
status: JobStatus
|
status: JobStatus
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { queueSeeds } from '~/shared/studio2/seed.mjs';
|
|||||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import template from '../../assets/studio2_minimax_native.json';
|
import template from '../../assets/studio2_minimax_native.json';
|
||||||
|
import qwen21Template from '../../assets/studio2_qwen21_t2i.json';
|
||||||
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
||||||
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
||||||
|
import { resolveQwen21Size } from '~/shared/studio2/input.mjs';
|
||||||
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
||||||
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
||||||
import { readRecord, saveRecord, studio2Root, records } from './store';
|
import { readRecord, saveRecord, studio2Root, records } from './store';
|
||||||
@@ -126,6 +128,28 @@ async function prepareGraph(r: any) {
|
|||||||
r.graphId = 'studio2_minimax_native.json';
|
r.graphId = 'studio2_minimax_native.json';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (q.engine === 'qwen21') {
|
||||||
|
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 {
|
else {
|
||||||
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
||||||
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
||||||
|
|||||||
@@ -21,5 +21,5 @@ export async function resolveRequestSize(owner: string, request: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
|
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
|
||||||
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions))
|
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions,undefined,request.engine))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { imageV2StackSpecials } from '~/utils/imageV2'
|
|||||||
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
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 {
|
export interface StudioJobPayload {
|
||||||
studio2Id?: string
|
studio2Id?: string
|
||||||
@@ -40,6 +40,7 @@ export interface StudioJobPayload {
|
|||||||
useIdentityRefs: boolean
|
useIdentityRefs: boolean
|
||||||
stillId?: string
|
stillId?: string
|
||||||
stillFilename?: string
|
stillFilename?: string
|
||||||
|
captionStyle?: string
|
||||||
hideThumbnail: boolean
|
hideThumbnail: boolean
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
@@ -224,6 +225,7 @@ export function listStudioJobs(owner: string) {
|
|||||||
export function studioJobKind(job: Pick<StudioJob, 'kind'> | { kind?: string }) {
|
export function studioJobKind(job: Pick<StudioJob, 'kind'> | { kind?: string }) {
|
||||||
if (job.kind === 'edit') return 'edit'
|
if (job.kind === 'edit') return 'edit'
|
||||||
if (job.kind === 'music') return 'music'
|
if (job.kind === 'music') return 'music'
|
||||||
|
if (job.kind === 'caption') return 'caption'
|
||||||
return 'video'
|
return 'video'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +290,7 @@ function failZombieLiveJob(job: Job, error: string) {
|
|||||||
function sweepStaleLiveJobs() {
|
function sweepStaleLiveJobs() {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const job of listJobs()) {
|
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.saving) continue
|
||||||
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
|
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
|
||||||
failZombieLiveJob(job, 'Job never started')
|
failZombieLiveJob(job, 'Job never started')
|
||||||
@@ -308,12 +310,12 @@ async function reapZombieLiveJobs() {
|
|||||||
const { fetchHistory } = await import('~/server/utils/comfy')
|
const { fetchHistory } = await import('~/server/utils/comfy')
|
||||||
for (const job of listJobs()) {
|
for (const job of listJobs()) {
|
||||||
// Never interrupt download/stitch/library save — Comfy is idle then by design.
|
// 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.saving) continue
|
||||||
if (job.library?.chainContinuing) continue
|
if (job.library?.chainContinuing) continue
|
||||||
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
|
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
|
||||||
if (jobIsLocallySubmitting(job)) 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 (jobAgeMs(job) < zombieMs) continue
|
||||||
if (!job.promptId) {
|
if (!job.promptId) {
|
||||||
if (jobAgeMs(job) >= QUEUED_GRACE_MS) failZombieLiveJob(job, 'Job never started on ComfyUI')
|
if (jobAgeMs(job) >= QUEUED_GRACE_MS) failZombieLiveJob(job, 'Job never started on ComfyUI')
|
||||||
@@ -353,7 +355,7 @@ async function reapZombieLiveJobs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function liveJobOwnsGpu(job: Job) {
|
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?.stopAfterCurrent) return false
|
||||||
if (job.library?.chainContinuing) return true
|
if (job.library?.chainContinuing) return true
|
||||||
if (job.saving) return true
|
if (job.saving) return true
|
||||||
@@ -373,7 +375,7 @@ function liveJobOwnsGpu(job: Job) {
|
|||||||
*/
|
*/
|
||||||
function clearDeadGpuClaimsForForceStart() {
|
function clearDeadGpuClaimsForForceStart() {
|
||||||
for (const live of listJobs()) {
|
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 (live.saving) continue
|
||||||
if (jobIsLocallySubmitting(live)) continue
|
if (jobIsLocallySubmitting(live)) continue
|
||||||
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
|
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
|
||||||
@@ -467,8 +469,11 @@ export async function addStudioJob(params: {
|
|||||||
kind?: StudioJobKind
|
kind?: StudioJobKind
|
||||||
}) {
|
}) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const kind = params.kind === 'edit' ? 'edit' : params.kind === 'music' ? 'music' : 'video'
|
const kind = params.kind === 'edit' ? 'edit'
|
||||||
const shotCount = kind === 'music'
|
: params.kind === 'music' ? 'music'
|
||||||
|
: params.kind === 'caption' ? 'caption'
|
||||||
|
: 'video'
|
||||||
|
const shotCount = kind === 'music' || kind === 'caption'
|
||||||
? 1
|
? 1
|
||||||
: kind === 'edit'
|
: kind === 'edit'
|
||||||
? 1 + (params.payload.passes?.length || 0)
|
? 1 + (params.payload.passes?.length || 0)
|
||||||
@@ -604,6 +609,10 @@ export async function clearStuckStudioWork(owner: string) {
|
|||||||
const { cancelYue2Job } = await import('./yue2')
|
const { cancelYue2Job } = await import('./yue2')
|
||||||
await cancelYue2Job(job)
|
await cancelYue2Job(job)
|
||||||
}
|
}
|
||||||
|
if (job.caption) {
|
||||||
|
const { cancelCaptionJob } = await import('./caption')
|
||||||
|
await cancelCaptionJob(job)
|
||||||
|
}
|
||||||
job.status = 'cancelled'
|
job.status = 'cancelled'
|
||||||
job.error = 'Cleared by force reset'
|
job.error = 'Cleared by force reset'
|
||||||
if (job.library) {
|
if (job.library) {
|
||||||
@@ -676,6 +685,11 @@ async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
|
|||||||
await cancelYue2Job(live)
|
await cancelYue2Job(live)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (live?.caption) {
|
||||||
|
const { cancelCaptionJob } = await import('./caption')
|
||||||
|
await cancelCaptionJob(live)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (live) {
|
if (live) {
|
||||||
live.status = 'cancelled'
|
live.status = 'cancelled'
|
||||||
if (live.library) {
|
if (live.library) {
|
||||||
@@ -887,6 +901,10 @@ function pendingAlive(job: StudioJob) {
|
|||||||
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yue2-pending', `${job.liveJobId}.json`)
|
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yue2-pending', `${job.liveJobId}.json`)
|
||||||
if (existsSync(root)) return true
|
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
|
if (!job.shotQueueId) return false
|
||||||
return listPendingJobs().some(pending => (
|
return listPendingJobs().some(pending => (
|
||||||
@@ -1395,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) {
|
async function startStudioMusicJob(item: StudioJob) {
|
||||||
let live: import('~/server/utils/jobs').Job | undefined
|
let live: import('~/server/utils/jobs').Job | undefined
|
||||||
try {
|
try {
|
||||||
@@ -1437,6 +1491,10 @@ export async function startStudioJob(item: StudioJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startStudioJobReserved(item: StudioJob) {
|
async function startStudioJobReserved(item: StudioJob) {
|
||||||
|
if (studioJobKind(item) === 'caption') {
|
||||||
|
await startStudioCaptionJob(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (item.payload.studio2Id) {
|
if (item.payload.studio2Id) {
|
||||||
try {
|
try {
|
||||||
const { startStudio2Job } = await import('./studio2/runner')
|
const { startStudio2Job } = await import('./studio2/runner')
|
||||||
@@ -1701,7 +1759,7 @@ export async function onLiveVideoSettled(job: Job) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const remaining = remainingStudioShots(job)
|
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
|
const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail
|
||||||
|
|
||||||
await mutateStore(owner, (store) => {
|
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: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
|
|||||||
export function compilePrompt(value, video = false, locks = {}) {
|
export function compilePrompt(value, video = false, locks = {}) {
|
||||||
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
|
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
|
||||||
const hasImage=!!(locks.imageAId || locks.identityStillId)
|
const hasImage=!!(locks.imageAId || locks.identityStillId)
|
||||||
const attached=hasImage && locks.engine !== 'krea'
|
const attached=hasImage && locks.engine !== 'krea' && locks.engine !== 'qwen21'
|
||||||
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
|
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
|
||||||
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
|
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
|
||||||
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
|
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
|
||||||
@@ -31,9 +31,10 @@ export function validateRequest(raw, xaigen = false) {
|
|||||||
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
||||||
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
||||||
const video = ['video','extend'].includes(mode)
|
const video = ['video','extend'].includes(mode)
|
||||||
if (!(video ? ['minimax','ltx'] : ['flux','krea']).includes(engine)) fail('Engine does not support this task.')
|
if (!(video ? ['minimax','ltx'] : ['flux','krea','qwen21']).includes(engine)) fail('Engine does not support this task.')
|
||||||
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
||||||
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
||||||
|
if (engine === 'qwen21' && 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.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
||||||
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
||||||
if (!raw.folderId) fail('Choose a folder.')
|
if (!raw.folderId) fail('Choose a folder.')
|
||||||
@@ -46,8 +47,13 @@ export function validateRequest(raw, xaigen = false) {
|
|||||||
const promptSections = video ? allSections : {action:allSections.action}
|
const promptSections = video ? allSections : {action:allSections.action}
|
||||||
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
||||||
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
||||||
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
|
const qwen = engine === 'qwen21'
|
||||||
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
const defaultSteps = video ? 8 : qwen ? 25 : 24
|
||||||
|
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
|
||||||
|
const 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)
|
const duration=number('duration',5,1,15)
|
||||||
if (video) {
|
if (video) {
|
||||||
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
||||||
@@ -55,10 +61,10 @@ export function validateRequest(raw, xaigen = false) {
|
|||||||
}
|
}
|
||||||
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
||||||
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
||||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
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 || ''},
|
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
||||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
|
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
|
||||||
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
|
settings: { aspect:['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} }),
|
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
|
||||||
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
|
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
||||||
import { restoreInput } from './contracts.mjs'
|
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 const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
|
||||||
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) {
|
export function resolveQwen21Size(aspect) {
|
||||||
|
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)
|
||||||
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
||||||
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
||||||
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
|
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
|
||||||
@@ -35,7 +41,7 @@ export function restoreAsset(form, item, xaigen = false, memory) {
|
|||||||
const restored = restoreInput(item)
|
const restored = restoreInput(item)
|
||||||
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
|
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
|
||||||
next.settings.seedMode='random'
|
next.settings.seedMode='random'
|
||||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||||
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
|
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
|
||||||
next.imageAId=item.id
|
next.imageAId=item.id
|
||||||
next.startClipId=''
|
next.startClipId=''
|
||||||
@@ -62,7 +68,7 @@ export function restoreAll(form,item,xaigen=false,memory) {
|
|||||||
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
|
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
|
||||||
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
|
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
|
||||||
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
|
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
|
||||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||||
next.imageAId=item.id
|
next.imageAId=item.id
|
||||||
next.startClipId=''
|
next.startClipId=''
|
||||||
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
|
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {filterLoraStackForUse} from '../../utils/loras.ts'
|
|||||||
|
|
||||||
export function loraUse(form) {
|
export function loraUse(form) {
|
||||||
const video=['video','extend'].includes(form.mode)
|
const video=['video','extend'].includes(form.mode)
|
||||||
return video ? (form.engine==='ltx'?'ltx':'minimax') : (form.engine==='krea'?'krea':'flux')
|
if (video) return form.engine==='ltx'?'ltx':'minimax'
|
||||||
|
if (form.engine==='qwen21') return 'qwen21'
|
||||||
|
return form.engine==='krea'?'krea':'flux'
|
||||||
}
|
}
|
||||||
|
|
||||||
// One controller per bench. Storage is optional (SSR/private browsing still works).
|
// One controller per bench. Storage is optional (SSR/private browsing still works).
|
||||||
|
|||||||
@@ -1,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…'}
|
if (!health) return {label:'Status unavailable',tone:'unknown',detail:'Checking GPU connection…'}
|
||||||
const upscale=upscales.find(j=>j.status==='running')
|
const upscale=upscales.find(j=>j.status==='running')
|
||||||
if(upscale)return {label:'Upscaling',tone:'busy',detail:upscale.name || 'Video upscale'}
|
if(upscale)return {label:'Upscaling',tone:'busy',detail:upscale.name || 'Video upscale'}
|
||||||
@@ -7,6 +7,9 @@ export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
|
|||||||
const stage=music.live?.message || music.live?.stage || music.waitReason || 'YuE2'
|
const stage=music.live?.message || music.live?.stage || music.waitReason || 'YuE2'
|
||||||
return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)}
|
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'}
|
if(health.yue2?.busy)return {label:'Generating',tone:'busy',detail:'YuE2 is using the GPU'}
|
||||||
const job=jobs.find(j=>['waking','submitting','rendering','saving'].includes(j.state))
|
const job=jobs.find(j=>['waking','submitting','rendering','saving'].includes(j.state))
|
||||||
if(job)return {label:{waking:'Waking up',submitting:'Submitting',rendering:'Generating',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
|
if(job)return {label:{waking:'Waking up',submitting:'Submitting',rendering:'Generating',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
|
||||||
|
|||||||
@@ -1,7 +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 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();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 normalizeStyles(value={}) { const known=[...Object.values(styleGroups).flat(),...defaultNegatives];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 toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next }
|
||||||
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
|
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
|
||||||
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
|
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -74,3 +74,10 @@ test('image chips compile positive and negative independently; video chips do no
|
|||||||
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
||||||
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
||||||
})
|
})
|
||||||
|
test('qwen21 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.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/T2I in this build/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export function kleinLoraOnKreaMessage(name: string) {
|
|||||||
return imageLoraEngineMismatchMessage(name, 'krea')
|
return imageLoraEngineMismatchMessage(name, 'krea')
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea'
|
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea' | 'qwen21'
|
||||||
|
|
||||||
export function isSystemGraphLora(name: string) {
|
export function isSystemGraphLora(name: string) {
|
||||||
const id = loraIdentityKey(name)
|
const id = loraIdentityKey(name)
|
||||||
@@ -301,16 +301,19 @@ export function inferLoraUse(name: string): LoraUse[] | null {
|
|||||||
if (/ltx/.test(value)) uses.push('ltx')
|
if (/ltx/.test(value)) uses.push('ltx')
|
||||||
if (/klein|flux/.test(value)) uses.push('flux')
|
if (/klein|flux/.test(value)) uses.push('flux')
|
||||||
if (/krea/.test(value)) uses.push('krea')
|
if (/krea/.test(value)) uses.push('krea')
|
||||||
|
if (/qwen/.test(value)) uses.push('qwen21')
|
||||||
return uses.length ? uses : null
|
return uses.length ? uses : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loraMatchesUse(name: string, use: LoraUse) {
|
export function loraMatchesUse(name: string, use: LoraUse) {
|
||||||
|
if (use === 'qwen21') return false
|
||||||
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
|
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
|
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
|
||||||
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
|
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
|
||||||
if (use === 'ltx' && !xaigen) return []
|
if (use === 'ltx' && !xaigen) return []
|
||||||
|
if (use === 'qwen21') return []
|
||||||
return filterLoraStackForStudio(stack, xaigen).filter(item => {
|
return filterLoraStackForStudio(stack, xaigen).filter(item => {
|
||||||
const uses = inferLoraUse(item.name)
|
const uses = inferLoraUse(item.name)
|
||||||
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
|
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
|
||||||
@@ -318,6 +321,7 @@ export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function filterLorasForUse(names: string[], use: LoraUse) {
|
export function filterLorasForUse(names: string[], use: LoraUse) {
|
||||||
|
if (use === 'qwen21') return []
|
||||||
return names.filter(name => loraMatchesUse(name, use))
|
return names.filter(name => loraMatchesUse(name, use))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||