Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e97cc1c3 | ||
|
|
b6eef6a511 | ||
|
|
d803d57a78 | ||
|
|
a8ae61704e | ||
|
|
7799529788 | ||
|
|
790c33fcf0 | ||
|
|
1bf97568bf | ||
|
|
5788d74001 | ||
|
|
4f40641050 | ||
|
|
14dcd234f3 | ||
|
|
15ef891b26 | ||
|
|
9481791ac5 | ||
|
|
17bb42bea4 | ||
|
|
ccf852cb0c | ||
|
|
4ec8ffa8d6 | ||
|
|
a465dd68ec | ||
|
|
cfabfd37ee | ||
|
|
ffdaa7ce42 | ||
|
|
aa0aa871e4 | ||
|
|
845ee5bbf6 | ||
|
|
ba83520923 | ||
|
|
322e5a276e | ||
|
|
5f6a7aa9bc | ||
|
|
930bbf7c7b | ||
|
|
c4e74628ee | ||
|
|
fe560d7e40 | ||
|
|
d61f99d6fd | ||
|
|
f9903ed76e | ||
|
|
a33cadb6c2 | ||
|
|
5b0d7370ed | ||
|
|
7242d0faef | ||
|
|
e06afe4a17 | ||
|
|
50319e03a9 | ||
|
|
5c2248a3fd | ||
|
|
58c5458c87 | ||
|
|
c46e827c68 | ||
|
|
a51118d92a | ||
|
|
99b61e67d7 | ||
|
|
30be4bd5b0 | ||
|
|
ecc4c69a8b | ||
|
|
17cbc99f8f | ||
|
|
95873744cd | ||
|
|
2842855c7b | ||
|
|
e0292c8d2a | ||
|
|
bb27af506e | ||
|
|
df5ef0949e | ||
|
|
206984b6ec | ||
|
|
52a01e0adc | ||
|
|
8609ba24a4 | ||
|
|
776d585345 | ||
|
|
608f7d8d47 | ||
|
|
68731916ca | ||
|
|
08d507c491 | ||
|
|
d65420f34a | ||
|
|
d733e80881 | ||
|
|
dccfafa08b | ||
|
|
589791cdb8 | ||
|
|
c0289afa0e | ||
|
|
a9799e4920 | ||
|
|
52bd6cf4a8 | ||
|
|
eba701a82c | ||
|
|
bdc1ab6c2d | ||
|
|
d9743dd8c9 | ||
|
|
1b29452455 | ||
|
|
292d53a20e | ||
|
|
b194b3cefa | ||
|
|
7221b6a8cf | ||
|
|
36d24feb9c | ||
|
|
5694104226 | ||
|
|
2ebc3066e6 | ||
|
|
c868605d8a | ||
|
|
1db62e9187 | ||
|
|
a216afc4ce | ||
|
|
cce1faa9b1 | ||
|
|
c50bde723f | ||
|
|
17cc07e3bc | ||
|
|
df885d5732 | ||
|
|
840440d7ef | ||
|
|
ee84a99035 | ||
|
|
62671d05da | ||
|
|
fae3b23e37 | ||
|
|
d1ff05089f | ||
|
|
71a61b1765 | ||
|
|
ec5791c595 | ||
|
|
5f68205f01 | ||
|
|
85ef087364 |
@@ -0,0 +1,99 @@
|
|||||||
|
# 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) │
|
||||||
|
│ Library (stage chrome) · eye ≥48px top-right │
|
||||||
|
├ [Slots] [Prompt] [Look] [Gen] Generate (amber, 48px) ─┤
|
||||||
|
└───────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Library is a **stage button** that opens the Library modal/sheet (same tiles,
|
||||||
|
filters, Use as photo / Copy / Load / Delete). The under-stage filmstrip row
|
||||||
|
is revoked — recents live in the modal (newest first).```
|
||||||
|
|
||||||
|
Bottom **dock** is 4 tabs that open sheets, plus Generate always visible:
|
||||||
|
|
||||||
|
| Tab | Sheet contents (all desktop fields) |
|
||||||
|
|---|---|
|
||||||
|
| **Slots** | Hero, Start, End, Guides, Upload, Lock face, Lock outfit + Klein caption |
|
||||||
|
| **Prompt** | Prompt textarea, negative chips, style/render/quality/lighting chips |
|
||||||
|
| **Look** | LoRA stack (filename + Model/CLIP sliders + numbers), Seed + dice, Aspect, extra generation settings (steps/CFG only if that engine uses them) |
|
||||||
|
| **Gen** | Engine-specific video/music extras: duration/target length, fps, turbo, camera beats, YuE2 hints. Not a second Generate button. |
|
||||||
|
|
||||||
|
**Generate** is the rightmost dock control — 48×48 or a 120px amber pill. Never labeled Queue.
|
||||||
|
|
||||||
|
Header **☰** or badge **Queue (n)** opens a **right/bottom sheet** of job cards (same cards as desktop right column).
|
||||||
|
|
||||||
|
Library and Settings stay header links (or a `…` menu). Do not drop them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage
|
||||||
|
|
||||||
|
- Same contain rule as desktop: `max-width/height 100%`, `object-fit: contain`, no inner max-width cap.
|
||||||
|
- Still / video / audio switch on `selected.kind`. No waveform on a still.
|
||||||
|
- Safe area: `padding-bottom: env(safe-area-inset-bottom)` on the dock.
|
||||||
|
- Filmstrip under stage, 72×48 tiles, amber ring on selected, horizontal scroll. Trash on selected tile only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sheets
|
||||||
|
|
||||||
|
- One sheet at a time. Height `min(85vh, content)`. Drag handle + Done.
|
||||||
|
- Fields keep desktop order and payload keys.
|
||||||
|
- LoRA card = filename + two (or one) sliders + numeric `0.00–2.00`. No hashed meters.
|
||||||
|
- Krea disabled-on-Edit with the same one-line reason.
|
||||||
|
- LTX disabled on AIGen with `xAIGen only`.
|
||||||
|
- xAIGen hide defaults: eye on stage + each tile. No global hide checkbox required on mobile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you must not do
|
||||||
|
|
||||||
|
- No desktop 3-column at `<768`.
|
||||||
|
- No “rotate device” dead end.
|
||||||
|
- No missing task (Iterate if desktop has it — add the chip).
|
||||||
|
- No second `form` object.
|
||||||
|
- Do not change Classic/Studio desktop tokens in this pass.
|
||||||
|
|
||||||
|
`isMobile` from `window.matchMedia('(max-width: 767px)')` + resize listener. SSR: default desktop to avoid hydration flip, then switch on mount.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
390×844 portrait:
|
||||||
|
|
||||||
|
- Stage fills the phone; dock + chips visible without page-zoom.
|
||||||
|
- Slots, Prompt, Look, Gen sheets each reachable; Generate queues a job.
|
||||||
|
- Queue sheet lists jobs.
|
||||||
|
- Rotate to ≥768: desktop 3-column returns, same job state.
|
||||||
|
|
||||||
|
One or two present-tense commits. Screenshot 390 and 1440.
|
||||||
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,149 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>AIGen — Display mock (Classic | Studio)</title>
|
||||||
|
<link rel="stylesheet" href="./tokens/display.css" />
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: ui-sans-serif, system-ui, Inter, sans-serif; background: #0e0e10; color: #f3f1ea; }
|
||||||
|
.app { min-height: 100vh; display: flex; flex-direction: column; gap: var(--chrome-gap); padding: var(--chrome-gap); }
|
||||||
|
header.top {
|
||||||
|
height: var(--header-h); display: flex; align-items: center; gap: 12px;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius); padding: 0 12px; background: var(--bg-raise);
|
||||||
|
}
|
||||||
|
.brand { font-weight: 650; letter-spacing: .02em; }
|
||||||
|
.grow { flex: 1; }
|
||||||
|
.pill { font-size: var(--type-sm); color: #b8e0a8; border: 1px solid #2d4a2a; background: #142016; border-radius: 999px; padding: 4px 10px; }
|
||||||
|
.nav { color: var(--muted); font-size: var(--type-sm); display: flex; gap: 14px; }
|
||||||
|
.nav b { color: var(--text); font-weight: 600; }
|
||||||
|
.identity-header, .classic-hide { display: flex; gap: 10px; align-items: center; font-size: var(--type-sm); color: var(--muted); }
|
||||||
|
.rail { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.seg {
|
||||||
|
height: var(--rail-h); padding: 0 12px; border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--line); background: var(--bg-slot); color: var(--muted); cursor: pointer;
|
||||||
|
}
|
||||||
|
.seg[aria-pressed="true"] { background: #2a1f0c; border-color: var(--amber); color: var(--amber); }
|
||||||
|
.seg:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.classic-selects { display: flex; gap: 8px; }
|
||||||
|
.classic-selects select {
|
||||||
|
background: var(--bg-slot); color: var(--text); border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius); height: var(--rail-h); padding: 0 8px;
|
||||||
|
}
|
||||||
|
.grid { display: grid; grid-template-columns: 240px 1fr 260px; gap: var(--chrome-gap); flex: 1; }
|
||||||
|
.col { background: var(--bg-raise); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--card-pad); }
|
||||||
|
.slot { background: var(--bg-slot); border: 1px dashed var(--line); border-radius: var(--radius); min-height: 72px; margin-bottom: 8px; padding: 8px; font-size: var(--type-sm); color: var(--muted); }
|
||||||
|
.slot strong { display: block; color: var(--text); margin-bottom: 4px; }
|
||||||
|
.viewer { position: relative; background: #111; border-radius: var(--radius); min-height: 320px; display: grid; place-items: center; color: var(--muted); border: 1px solid var(--line); }
|
||||||
|
.eye-viewer { position: absolute; top: 8px; right: 8px; background: #000a; border: 1px solid var(--line); border-radius: 6px; padding: 4px; }
|
||||||
|
.strip { display: flex; gap: 8px; margin-top: 10px; }
|
||||||
|
.tile { width: 72px; height: 48px; background: #222; border-radius: 6px; border: 1px solid var(--line); position: relative; }
|
||||||
|
.eye-tile { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; }
|
||||||
|
textarea, input {
|
||||||
|
width: 100%; background: var(--bg-slot); color: var(--text); border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius); padding: 8px; font: inherit;
|
||||||
|
}
|
||||||
|
.cta { width: 100%; height: 44px; border: 0; border-radius: var(--radius); background: var(--amber); color: #1a1204; font-weight: 700; cursor: pointer; margin-top: 10px; }
|
||||||
|
.hint { font-size: 11px; color: var(--muted); margin: 6px 0 10px; }
|
||||||
|
.settings {
|
||||||
|
position: fixed; right: 16px; bottom: 16px; width: 360px; background: var(--bg-raise);
|
||||||
|
border: 1px solid var(--line); border-radius: 10px; padding: 14px; box-shadow: 0 12px 40px #0008;
|
||||||
|
}
|
||||||
|
.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; padding: 10px; background: var(--bg-slot); cursor: pointer; text-align: left; color: inherit;
|
||||||
|
}
|
||||||
|
.card[aria-pressed="true"] { border-color: var(--amber); background: #2a1f0c; }
|
||||||
|
.card small { display: block; color: var(--muted); margin-top: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app" data-display="studio" id="root">
|
||||||
|
<header class="top">
|
||||||
|
<span class="brand">AIGen</span>
|
||||||
|
<span class="muted" style="color:var(--muted);font-size:12px">/ Inbox</span>
|
||||||
|
<div class="grow"></div>
|
||||||
|
<div class="identity-header">
|
||||||
|
<label><input type="checkbox" checked /> Lock face</label>
|
||||||
|
<label><input type="checkbox" checked /> Lock outfit</label>
|
||||||
|
</div>
|
||||||
|
<div class="classic-hide">
|
||||||
|
<label><input type="checkbox" /> Hide input</label>
|
||||||
|
<label><input type="checkbox" /> Hide output</label>
|
||||||
|
</div>
|
||||||
|
<nav class="nav"><span>Library</span><b>Queue</b><span>Settings</span></nav>
|
||||||
|
<span class="pill">5080 Ready</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="rail rail-task" role="tablist">
|
||||||
|
<button class="seg" aria-pressed="false">Image</button>
|
||||||
|
<button class="seg" aria-pressed="false">Edit</button>
|
||||||
|
<button class="seg" aria-pressed="false">Refine</button>
|
||||||
|
<button class="seg" aria-pressed="true">Video</button>
|
||||||
|
<button class="seg" aria-pressed="false">Extend</button>
|
||||||
|
<button class="seg" aria-pressed="false">Music</button>
|
||||||
|
</div>
|
||||||
|
<div class="rail rail-model">
|
||||||
|
<button class="seg" aria-pressed="true">MiniMax H3</button>
|
||||||
|
<button class="seg" disabled title="xAIGen only">LTX</button>
|
||||||
|
</div>
|
||||||
|
<div class="classic-selects">
|
||||||
|
<select><option>Video</option><option>Image</option><option>Extend</option><option>Music</option></select>
|
||||||
|
<select><option>MiniMax H3</option><option disabled>LTX (xAIGen only)</option></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<aside class="col">
|
||||||
|
<div class="slot"><strong>Hero</strong>car_bee still</div>
|
||||||
|
<div class="slot"><strong>Start</strong>Select still or clip</div>
|
||||||
|
<div class="slot"><strong>End</strong>Optional</div>
|
||||||
|
<div class="identity-slots">
|
||||||
|
<label><input type="checkbox" checked /> Lock face</label><br />
|
||||||
|
<label><input type="checkbox" checked /> Lock outfit</label>
|
||||||
|
<p class="hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="col">
|
||||||
|
<div class="viewer">
|
||||||
|
<button class="eye-viewer" title="Toggle viewer">eye</button>
|
||||||
|
viewer · last frame / waveform
|
||||||
|
</div>
|
||||||
|
<div class="strip">
|
||||||
|
<div class="tile"><span class="eye-tile">👁</span></div>
|
||||||
|
<div class="tile"><span class="eye-tile">👁</span></div>
|
||||||
|
<div class="tile"><span class="eye-tile">👁</span></div>
|
||||||
|
<div class="tile"><span class="eye-tile">👁</span></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<aside class="col">
|
||||||
|
<textarea rows="5">car_bee walks the Great Hall, cinematic</textarea>
|
||||||
|
<p class="hint">YuE2 target length is a plan hint, not a trim.</p>
|
||||||
|
<input value="seed unlocked" />
|
||||||
|
<button class="cta">Generate</button>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="settings">
|
||||||
|
<strong>Settings → Display</strong>
|
||||||
|
<p class="hint">Layout only. Jobs, engines, and library IDs stay the same.</p>
|
||||||
|
<div class="cards">
|
||||||
|
<button class="card" id="btnClassic" aria-pressed="false">Classic<small>packed header, dropdowns</small></button>
|
||||||
|
<button class="card" id="btnStudio" aria-pressed="true">Studio<small>task rail, model rail, slots + eyes</small></button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<script>
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
const classic = document.getElementById("btnClassic");
|
||||||
|
const studio = document.getElementById("btnStudio");
|
||||||
|
function setMode(mode) {
|
||||||
|
root.setAttribute("data-display", mode);
|
||||||
|
classic.setAttribute("aria-pressed", mode === "classic" ? "true" : "false");
|
||||||
|
studio.setAttribute("aria-pressed", mode === "studio" ? "true" : "false");
|
||||||
|
}
|
||||||
|
classic.onclick = () => setMode("classic");
|
||||||
|
studio.onclick = () => setMode("studio");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M3 3l18 18"/>
|
||||||
|
<path d="M10.6 10.6A3 3 0 0 0 12 15a3 3 0 0 0 2.4-4.4"/>
|
||||||
|
<path d="M9.9 5.1A11 11 0 0 1 12 5c6.5 0 10 7 10 7a18 18 0 0 1-3.2 3.8"/>
|
||||||
|
<path d="M6.1 6.1C3.7 7.8 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.2-.9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||||
|
<path d="M8 11V8a4 4 0 0 1 8 0v3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1,56 @@
|
|||||||
|
/* AIGen display tokens — Classic | Studio
|
||||||
|
Apply on [data-display] wrapper. Do not fork components. */
|
||||||
|
|
||||||
|
:root,
|
||||||
|
[data-display="studio"] {
|
||||||
|
--bg: #0e0e10;
|
||||||
|
--bg-raise: #161618;
|
||||||
|
--bg-slot: #1c1c1f;
|
||||||
|
--line: #2a2a2e;
|
||||||
|
--text: #f3f1ea;
|
||||||
|
--muted: #9a9588;
|
||||||
|
--amber: #f5a524;
|
||||||
|
--amber-dim: #c4841c;
|
||||||
|
--radius: 8px;
|
||||||
|
--chrome-gap: 16px;
|
||||||
|
--rail-h: 40px;
|
||||||
|
--card-pad: 14px;
|
||||||
|
--header-h: 52px;
|
||||||
|
--type: 14px;
|
||||||
|
--type-sm: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-display="classic"] {
|
||||||
|
--chrome-gap: 8px;
|
||||||
|
--rail-h: 32px;
|
||||||
|
--card-pad: 10px;
|
||||||
|
--header-h: 44px;
|
||||||
|
--type: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-display] {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-display="classic"] .rail-task,
|
||||||
|
[data-display="classic"] .rail-model {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-display="studio"] .classic-selects {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-display="classic"] .identity-slots {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-display="studio"] .identity-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-display="classic"] .eye-tile,
|
||||||
|
[data-display="classic"] .eye-viewer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
[data-display="studio"] .classic-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,993 @@
|
|||||||
|
/* Pixel grid tokens — Classic | Studio. Match TARGET-classic-layout.png */
|
||||||
|
|
||||||
|
:root,
|
||||||
|
[data-display='studio'] {
|
||||||
|
--bg: #0b0b0d;
|
||||||
|
--panel: #141416;
|
||||||
|
--panel-raise: #1a1a1d;
|
||||||
|
--line: #2a2a2e;
|
||||||
|
--text: #f3f1ea;
|
||||||
|
--muted: #9a9588;
|
||||||
|
--amber: #f5a524;
|
||||||
|
--amber-on: #1a1204;
|
||||||
|
--chip-bg: #2a2418;
|
||||||
|
--chip-line: #5a4a28;
|
||||||
|
--chip-text: #e8c37a;
|
||||||
|
--success: #7dda6f;
|
||||||
|
--radius: 8px;
|
||||||
|
--page-gap: 12px;
|
||||||
|
--field-gap: 12px;
|
||||||
|
--header-h: 48px;
|
||||||
|
--rail-task-h: 40px;
|
||||||
|
--rail-model-h: 36px;
|
||||||
|
--left-w: 260px;
|
||||||
|
--right-w: 280px;
|
||||||
|
--type: 14px;
|
||||||
|
--type-sm: 12px;
|
||||||
|
--type-label: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-display='classic'] {
|
||||||
|
--field-gap: 8px;
|
||||||
|
--type: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-display] {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: var(--type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skin toggles */
|
||||||
|
[data-display='classic'] .rail-task,
|
||||||
|
[data-display='classic'] .rail-model,
|
||||||
|
[data-display='classic'] .identity-slots,
|
||||||
|
[data-display='classic'] .eye-viewer,
|
||||||
|
[data-display='classic'] .eye-tile {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
[data-display='studio'] .classic-task-model,
|
||||||
|
[data-display='studio'] .viewer-lock-bar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-root {
|
||||||
|
display: flex;
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-root[data-mobile="1"] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--header-h);
|
||||||
|
min-height: var(--header-h);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-brand {
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-crumb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-crumb select {
|
||||||
|
max-width: 14rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-nav a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-nav a:hover,
|
||||||
|
.bench-nav a.router-link-active {
|
||||||
|
color: var(--amber);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-raise);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-chip .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gpu-chip[data-tone='ready'] .dot { background: var(--success); }
|
||||||
|
.gpu-chip[data-tone='busy'] .dot { background: var(--amber); animation: pulse 1.2s infinite; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
50% { opacity: 0.45; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-task { min-height: var(--rail-task-h); align-items: center; padding-top: 8px; }
|
||||||
|
.rail-model { min-height: var(--rail-model-h); align-items: center; padding-bottom: 8px; }
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 0 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-model .seg { height: 32px; font-weight: 500; }
|
||||||
|
|
||||||
|
.seg[aria-pressed='true'] {
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: var(--amber);
|
||||||
|
color: var(--amber-on);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-grid {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: var(--left-w) minmax(0, 1fr) var(--right-w);
|
||||||
|
gap: var(--page-gap);
|
||||||
|
padding: var(--page-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-panel {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bench-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--field-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-label);
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-control {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-raise);
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-control:focus-visible,
|
||||||
|
.field-area:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-area {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 88px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-raise);
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--chip-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--chip-bg);
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: var(--chip-text);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-chip button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed-row .field-control { flex: 1; }
|
||||||
|
|
||||||
|
.dice-btn {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-raise);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-lock-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-stack {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage: fills center column above 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,
|
||||||
|
.viewer-still {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-still--veiled {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-frame--veiled .viewer-veil-label {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top chrome is a sibling of the media pane — never absolute over pixels */
|
||||||
|
.stage-chrome {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: #0b0b0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-chrome > .eye-viewer {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-library {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #141416;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-library:hover {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-library-count {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-actions > button,
|
||||||
|
.stage-split-trigger {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #141416;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-actions > button:hover,
|
||||||
|
.stage-split-trigger:hover {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-actions .stage-action-danger {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-split {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-split-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-split-trigger::after {
|
||||||
|
content: '▾';
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-split-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 8;
|
||||||
|
display: flex;
|
||||||
|
min-width: 160px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #141416;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0 8px 24px #0008;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-split-menu button {
|
||||||
|
min-height: 36px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0 10px;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-split-menu button:hover {
|
||||||
|
background: #ffffff10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye-viewer {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #141416;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye-viewer:hover {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-media-row {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-nav {
|
||||||
|
display: grid;
|
||||||
|
width: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
place-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #0b0b0d;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-nav:hover {
|
||||||
|
color: var(--amber);
|
||||||
|
background: #121214;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-nav-prev {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-nav-next {
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-inputs-toggle {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-inputs-toggle:hover {
|
||||||
|
border-color: var(--amber);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-collapsed {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-add-link {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--amber);
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filmstrip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile[data-active='true'] {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile button.pick {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile .eye-tile,
|
||||||
|
.tile .tile-trash {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
right: 3px;
|
||||||
|
display: grid;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #000a;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile .tile-trash { right: 22px; }
|
||||||
|
|
||||||
|
.strip-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-add-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neg-add-row .field-control {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport {
|
||||||
|
display: flex;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport .ops {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-col {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-col[data-collapsed='1'] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-head {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 12px 8px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-toggle-mark {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status .left {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status .dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status[data-state='queued'] .dot,
|
||||||
|
.job-status[data-state='waiting'] .dot {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-status[data-state='enhancing'] .dot,
|
||||||
|
.job-status[data-state='waking'] .dot,
|
||||||
|
.job-status[data-state='submitting'] .dot,
|
||||||
|
.job-status[data-state='rendering'] .dot,
|
||||||
|
.job-status[data-state='saving'] .dot {
|
||||||
|
background: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
accent-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-progress[data-indeterminate='true'] .job-progress-bar {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-elapsed {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kebab {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-foot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--amber);
|
||||||
|
color: var(--amber-on);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-cta:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-foot-error {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-add {
|
||||||
|
align-self: flex-start;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card .slot-thumb--slot { height: 88px; }
|
||||||
|
.photo-card .slot-thumb--empty { height: 88px; }
|
||||||
|
|
||||||
|
.photo-card-body {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-role-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-role-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-actions button,
|
||||||
|
.slot-token-btn {
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-token-btn {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-still {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
}
|
||||||
@@ -55,6 +55,13 @@
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
@click="menuOpen = false"
|
@click="menuOpen = false"
|
||||||
>Director</NuxtLink>
|
>Director</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/prompt-builder"
|
||||||
|
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
||||||
|
:class="current === 'prompt-builder' ? 'text-amber-100' : 'text-zinc-200'"
|
||||||
|
role="menuitem"
|
||||||
|
@click="menuOpen = false"
|
||||||
|
>Prompt Builder</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/music"
|
to="/music"
|
||||||
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
||||||
@@ -131,7 +138,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
subtitle: string
|
subtitle: string
|
||||||
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music'
|
current?: 'studio' | 'library' | 'director' | 'prompt-builder' | 'queue' | 'settings' | 'music'
|
||||||
wide?: boolean
|
wide?: boolean
|
||||||
queueCount?: number
|
queueCount?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
>
|
>
|
||||||
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
||||||
<img
|
<img
|
||||||
:src="`/api/library/stills/${still.id}?thumb=1`"
|
:src="`/api/library/stills/${still.id}/thumb`"
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
|||||||
@@ -6,22 +6,21 @@
|
|||||||
<li
|
<li
|
||||||
v-for="(item, index) in model"
|
v-for="(item, index) in model"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
class="rounded-xl border bg-zinc-950/80"
|
class="rounded-lg border border-[#2A2A2E] bg-[#1A1A1D] p-2.5"
|
||||||
:class="[
|
:class="[
|
||||||
compact ? 'px-2 py-1.5' : 'px-3 py-2',
|
dragOverIndex === index ? 'border-amber-300/50' : '',
|
||||||
dragOverIndex === index ? 'border-amber-300/50' : 'border-white/10',
|
|
||||||
draggingIndex === index ? 'opacity-60' : ''
|
draggingIndex === index ? 'opacity-60' : ''
|
||||||
]"
|
]"
|
||||||
@dragover.prevent="onDragOver(index)"
|
@dragover.prevent="onDragOver(index)"
|
||||||
@drop.prevent="onDrop(index)"
|
@drop.prevent="onDrop(index)"
|
||||||
@dragleave="onDragLeave(index)"
|
@dragleave="onDragLeave(index)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 flex-1 items-start gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="model.length > 1"
|
v-if="model.length > 1"
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
|
class="mt-0.5 shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
title="Drag to reorder"
|
title="Drag to reorder"
|
||||||
@@ -31,61 +30,91 @@
|
|||||||
>
|
>
|
||||||
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
|
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="min-w-0"><span class="block truncate text-zinc-200">{{ labelFor(item.name) }}</span><span v-if="filenameSubtitle" class="block truncate text-[11px] text-zinc-500" :title="item.name">{{ item.name }}</span></span>
|
<span class="min-w-0 flex-1">
|
||||||
<LoraTriggerTip
|
<span class="flex min-w-0 items-center gap-1.5">
|
||||||
:text="triggerFor(item.name)"
|
<span class="block truncate font-medium text-[#F3F1EA]" :title="labelFor(item.name)">{{ labelFor(item.name) }}</span>
|
||||||
:aria-label="'Activation for ' + labelFor(item.name)"
|
<LoraTriggerTip
|
||||||
@insert="emit('insert-trigger', $event)"
|
:text="triggerFor(item.name)"
|
||||||
@save="saveTrigger(item.name, $event)"
|
:aria-label="'Activation for ' + labelFor(item.name)"
|
||||||
/>
|
@insert="emit('insert-trigger', $event)"
|
||||||
|
@save="saveTrigger(item.name, $event)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span v-if="filenameSubtitle" class="mt-0.5 block truncate text-[11px] text-[#9A9588]" :title="item.name">{{ stemFor(item.name) }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 text-[11px] text-zinc-500 hover:text-red-300 disabled:opacity-50"
|
class="shrink-0 text-[11px] text-[#9A9588] hover:text-red-300 disabled:opacity-50"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@click="removeAt(index)"
|
@click="removeAt(index)"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1.5 flex flex-wrap gap-3">
|
|
||||||
<Thumbwheel
|
<div class="mt-2 space-y-1.5">
|
||||||
:model-value="item.strengthModel"
|
<label class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]">
|
||||||
label="Model"
|
<span>{{ clipStrength ? 'Model' : 'Strength' }}</span>
|
||||||
:lock-key="`lora-model:${item.name}`"
|
<input
|
||||||
:min="LORA_STRENGTH_MIN"
|
type="range"
|
||||||
:max="LORA_STRENGTH_MAX"
|
class="lora-strength-range min-w-0"
|
||||||
:step="LORA_STRENGTH_STEP"
|
:min="0"
|
||||||
:fine-step="LORA_STRENGTH_STEP"
|
:max="2"
|
||||||
:coarse-step="0.25"
|
:step="LORA_STRENGTH_STEP"
|
||||||
:digits="2"
|
:disabled="disabled"
|
||||||
compact
|
:value="displayStrength(item.strengthModel)"
|
||||||
:disabled="disabled"
|
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' for ' + labelFor(item.name)"
|
||||||
@update:model-value="setStrength(index, 'strengthModel', $event)"
|
@input="onRange(index, 'strengthModel', $event)"
|
||||||
/>
|
>
|
||||||
<Thumbwheel
|
<input
|
||||||
:model-value="item.strengthClip"
|
type="number"
|
||||||
label="CLIP"
|
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
|
||||||
:lock-key="`lora-clip:${item.name}`"
|
:min="0"
|
||||||
:min="LORA_STRENGTH_MIN"
|
:max="2"
|
||||||
:max="LORA_STRENGTH_MAX"
|
:step="LORA_STRENGTH_STEP"
|
||||||
:step="LORA_STRENGTH_STEP"
|
:disabled="disabled"
|
||||||
:fine-step="LORA_STRENGTH_STEP"
|
:value="displayStrength(item.strengthModel).toFixed(2)"
|
||||||
:coarse-step="0.25"
|
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' value'"
|
||||||
:digits="2"
|
@change="onNumber(index, 'strengthModel', $event)"
|
||||||
compact
|
>
|
||||||
:disabled="disabled || !clipStrength"
|
</label>
|
||||||
@update:model-value="setStrength(index, 'strengthClip', $event)"
|
<label
|
||||||
/>
|
v-if="clipStrength"
|
||||||
|
class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]"
|
||||||
|
>
|
||||||
|
<span>CLIP</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="lora-strength-range min-w-0"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
|
:step="LORA_STRENGTH_STEP"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="displayStrength(item.strengthClip)"
|
||||||
|
:aria-label="'CLIP for ' + labelFor(item.name)"
|
||||||
|
@input="onRange(index, 'strengthClip', $event)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
|
||||||
|
:min="0"
|
||||||
|
:max="2"
|
||||||
|
:step="LORA_STRENGTH_STEP"
|
||||||
|
:disabled="disabled"
|
||||||
|
:value="displayStrength(item.strengthClip).toFixed(2)"
|
||||||
|
:aria-label="'CLIP value'"
|
||||||
|
@change="onNumber(index, 'strengthClip', $event)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-if="!clipStrength && model.length" class="mt-1 text-[11px] text-zinc-500">CLIP unused on this graph — model strength only.</p>
|
|
||||||
<select
|
<select
|
||||||
v-if="model.length < LORA_STACK_MAX"
|
v-if="model.length < LORA_STACK_MAX"
|
||||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 outline-none ring-amber-300/40 focus:ring-2 disabled:opacity-50"
|
class="w-full rounded-lg border border-[#2A2A2E] bg-[#0B0B0D] text-[#F3F1EA] outline-none ring-[#F5A524]/40 focus:ring-2 disabled:opacity-50"
|
||||||
:class="[
|
:class="[
|
||||||
compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm',
|
compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
|
||||||
model.length ? 'mt-2' : ''
|
model.length ? 'mt-2' : ''
|
||||||
]"
|
]"
|
||||||
:disabled="disabled || !availableNames.length"
|
:disabled="disabled || !availableNames.length"
|
||||||
@@ -103,13 +132,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lora-strength-range {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2a2a2e;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f5a524;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-moz-range-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2a2a2e;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f5a524;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lora-strength-range:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.lora-strength-range::-webkit-slider-runnable-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2a2a2e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
LORA_STACK_MAX,
|
LORA_STACK_MAX,
|
||||||
LORA_STRENGTH_MAX,
|
LORA_STRENGTH_DEFAULT,
|
||||||
LORA_STRENGTH_MIN,
|
|
||||||
LORA_STRENGTH_STEP,
|
LORA_STRENGTH_STEP,
|
||||||
clampLoraStrength,
|
loraDisplayName,
|
||||||
loraFriendlyName,
|
loraFriendlyName,
|
||||||
loraIdentityKey,
|
loraIdentityKey,
|
||||||
loraMatchesImageEngine,
|
loraMatchesImageEngine,
|
||||||
@@ -120,6 +191,9 @@ import {
|
|||||||
type LoraStackItem
|
type LoraStackItem
|
||||||
} from '~/utils/loras'
|
} from '~/utils/loras'
|
||||||
|
|
||||||
|
const UI_MIN = 0
|
||||||
|
const UI_MAX = 2
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
names: string[]
|
names: string[]
|
||||||
filenameSubtitle?: boolean
|
filenameSubtitle?: boolean
|
||||||
@@ -175,6 +249,17 @@ const selectPlaceholder = computed(() => {
|
|||||||
return model.value.length ? props.addLabel : props.emptyLabel
|
return model.value.length ? props.addLabel : props.emptyLabel
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function clampUi(raw: unknown) {
|
||||||
|
const value = Number(raw)
|
||||||
|
if (!Number.isFinite(value)) return LORA_STRENGTH_DEFAULT
|
||||||
|
const snapped = Math.round(value / LORA_STRENGTH_STEP) * LORA_STRENGTH_STEP
|
||||||
|
return Math.min(UI_MAX, Math.max(UI_MIN, Math.round(snapped * 100) / 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayStrength(value: unknown) {
|
||||||
|
return clampUi(value ?? LORA_STRENGTH_DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
function onAdd(event: Event) {
|
function onAdd(event: Event) {
|
||||||
const name = (event.target as HTMLSelectElement).value
|
const name = (event.target as HTMLSelectElement).value
|
||||||
;(event.target as HTMLSelectElement).value = ''
|
;(event.target as HTMLSelectElement).value = ''
|
||||||
@@ -186,7 +271,11 @@ function addLora(name: string) {
|
|||||||
if (!item || model.value.length >= LORA_STACK_MAX) return
|
if (!item || model.value.length >= LORA_STACK_MAX) return
|
||||||
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
|
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
|
||||||
if (taken.has(loraIdentityKey(item.name))) return
|
if (taken.has(loraIdentityKey(item.name))) return
|
||||||
model.value = [...model.value, item]
|
model.value = [...model.value, {
|
||||||
|
...item,
|
||||||
|
strengthModel: clampUi(item.strengthModel ?? LORA_STRENGTH_DEFAULT),
|
||||||
|
strengthClip: clampUi(item.strengthClip ?? LORA_STRENGTH_DEFAULT)
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAt(index: number) {
|
function removeAt(index: number) {
|
||||||
@@ -234,8 +323,22 @@ function onDragEnd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
|
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
|
||||||
const next = clampLoraStrength(value)
|
const next = clampUi(value)
|
||||||
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item)
|
model.value = model.value.map((item, i) => {
|
||||||
|
if (i !== index) return item
|
||||||
|
if (!props.clipStrength) {
|
||||||
|
return { ...item, strengthModel: next, strengthClip: next }
|
||||||
|
}
|
||||||
|
return { ...item, [key]: next }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRange(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||||
|
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNumber(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||||
|
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerFor(name: string) {
|
function triggerFor(name: string) {
|
||||||
@@ -246,6 +349,10 @@ function labelFor(name: string) {
|
|||||||
return loraFriendlyName(name, props.aliases)
|
return loraFriendlyName(name, props.aliases)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stemFor(name: string) {
|
||||||
|
return loraDisplayName(name)
|
||||||
|
}
|
||||||
|
|
||||||
function saveTrigger(name: string, trigger: string) {
|
function saveTrigger(name: string, trigger: string) {
|
||||||
emit('update-trigger', name, trigger)
|
emit('update-trigger', name, trigger)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,37 @@
|
|||||||
:class="noticeError ? 'border-red-400/30 bg-red-950/40 text-red-100' : 'border-emerald-400/20 bg-emerald-950/30 text-emerald-100'"
|
:class="noticeError ? 'border-red-400/30 bg-red-950/40 text-red-100' : 'border-emerald-400/20 bg-emerald-950/30 text-emerald-100'"
|
||||||
>{{ notice }}</p>
|
>{{ notice }}</p>
|
||||||
|
|
||||||
|
<section v-if="activePanel === 'display'" class="glass rounded-3xl p-5 sm:p-6">
|
||||||
|
<h2 class="font-display text-xl font-bold">Display</h2>
|
||||||
|
<p class="mt-1 text-sm text-zinc-400">Layout only. Jobs, engines, and library IDs stay the same.</p>
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl border p-4 text-left outline-none ring-amber-300/40 focus-visible:ring-2"
|
||||||
|
:class="storedDisplayMode === 'classic'
|
||||||
|
? 'border-amber-300/60 bg-amber-400/10'
|
||||||
|
: 'border-white/10 bg-zinc-950/50 hover:border-white/25'"
|
||||||
|
:aria-pressed="storedDisplayMode === 'classic'"
|
||||||
|
@click="setDisplayMode('classic')"
|
||||||
|
>
|
||||||
|
<span class="block font-display text-lg font-semibold text-zinc-50">Classic</span>
|
||||||
|
<span class="mt-1 block text-xs text-zinc-400">packed header, dropdowns</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl border p-4 text-left outline-none ring-amber-300/40 focus-visible:ring-2"
|
||||||
|
:class="storedDisplayMode === 'studio'
|
||||||
|
? 'border-amber-300/60 bg-amber-400/10'
|
||||||
|
: 'border-white/10 bg-zinc-950/50 hover:border-white/25'"
|
||||||
|
:aria-pressed="storedDisplayMode === 'studio'"
|
||||||
|
@click="setDisplayMode('studio')"
|
||||||
|
>
|
||||||
|
<span class="block font-display text-lg font-semibold text-zinc-50">Studio</span>
|
||||||
|
<span class="mt-1 block text-xs text-zinc-400">task rail, model rail, slots + eyes</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="activePanel === 'gpu'" class="glass rounded-3xl p-5 sm:p-6">
|
<section v-if="activePanel === 'gpu'" class="glass rounded-3xl p-5 sm:p-6">
|
||||||
<h2 class="font-display text-xl font-bold">GPU & defaults</h2>
|
<h2 class="font-display text-xl font-bold">GPU & defaults</h2>
|
||||||
<div class="mt-4 space-y-3 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
|
<div class="mt-4 space-y-3 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
|
||||||
@@ -422,11 +453,12 @@ export interface SettingsFolder {
|
|||||||
unlocked: boolean
|
unlocked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsPanel = 'gpu' | 'storage' | 'login' | 'folders' | 'loras' | 'keeps' | 'prompts'
|
type SettingsPanel = 'display' | 'gpu' | 'storage' | 'login' | 'folders' | 'loras' | 'keeps' | 'prompts'
|
||||||
type LoraKindFilter = 'all' | 'image' | 'video'
|
type LoraKindFilter = 'all' | 'image' | 'video'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { storedDisplayMode, setDisplayMode } = useDisplayMode()
|
||||||
|
|
||||||
const folders = ref<SettingsFolder[]>([])
|
const folders = ref<SettingsFolder[]>([])
|
||||||
const authMode = ref('')
|
const authMode = ref('')
|
||||||
@@ -435,7 +467,7 @@ const notice = ref('')
|
|||||||
const noticeError = ref(false)
|
const noticeError = ref(false)
|
||||||
let noticeTimer: ReturnType<typeof setTimeout> | null = null
|
let noticeTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const panelIds: SettingsPanel[] = ['gpu', 'storage', 'login', 'folders', 'loras', 'keeps', 'prompts']
|
const panelIds: SettingsPanel[] = ['display', 'gpu', 'storage', 'login', 'folders', 'loras', 'keeps', 'prompts']
|
||||||
|
|
||||||
function parsePanel(raw: unknown): SettingsPanel {
|
function parsePanel(raw: unknown): SettingsPanel {
|
||||||
const value = String(raw || '')
|
const value = String(raw || '')
|
||||||
@@ -456,6 +488,7 @@ const activePanel = computed({
|
|||||||
|
|
||||||
const navItems = computed(() => {
|
const navItems = computed(() => {
|
||||||
const items: Array<{ id: SettingsPanel; label: string }> = [
|
const items: Array<{ id: SettingsPanel; label: string }> = [
|
||||||
|
{ id: 'display', label: 'Display' },
|
||||||
{ id: 'gpu', label: 'GPU & defaults' },
|
{ id: 'gpu', label: 'GPU & defaults' },
|
||||||
{ id: 'storage', label: 'Storage' }
|
{ id: 'storage', label: 'Storage' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav v-if="!advanced" aria-label="Generation type" class="grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-zinc-950/50 p-2">
|
<nav v-if="!advanced" aria-label="Generation type" class="grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-zinc-950/50 p-2">
|
||||||
<button v-for="kind in ['image', 'video'] as const" :key="kind" type="button" :aria-pressed="current === kind" class="rounded-xl px-3 py-3 font-semibold capitalize" :class="current === kind ? 'bg-amber-400 text-zinc-950' : 'text-zinc-300 hover:bg-white/5'" @click="emit('select', kind)">{{ kind }}</button>
|
<button v-for="kind in ['image', 'video'] as const" :key="kind" type="button" :aria-pressed="current === kind" class="rounded-xl px-3 py-3 font-semibold capitalize" :class="current === kind ? 'bg-amber-400 text-zinc-950' : 'text-zinc-300 hover:bg-white/5'" @click="emit('select', kind)">{{ kind }}</button>
|
||||||
<NuxtLink to="/music" class="rounded-xl px-3 py-3 text-center font-semibold" :aria-current="current === 'music' ? 'page' : undefined" :class="current === 'music' ? 'bg-amber-400 text-zinc-950' : 'text-zinc-300 hover:bg-white/5'">Music</NuxtLink>
|
<NuxtLink to="/music?engine=yue2" class="rounded-xl px-3 py-3 text-center font-semibold" :aria-current="current === 'music' ? 'page' : undefined" :class="current === 'music' ? 'bg-amber-400 text-zinc-950' : 'text-zinc-300 hover:bg-white/5'">Music</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
<div v-else class="mb-4 grid grid-cols-3 gap-3">
|
<div v-else class="mb-4 grid grid-cols-3 gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
:class="current === 'music' ? 'border-amber-300 bg-amber-400/10 text-amber-50' : 'border-white/10 text-zinc-400'"
|
:class="current === 'music' ? 'border-amber-300 bg-amber-400/10 text-amber-50' : 'border-white/10 text-zinc-400'"
|
||||||
>
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/music"
|
to="/music?engine=yue2"
|
||||||
class="block w-full px-4 pb-3 pt-4 text-left transition"
|
class="block w-full px-4 pb-3 pt-4 text-left transition"
|
||||||
:class="current === 'music' ? '' : 'hover:text-zinc-200'"
|
:class="current === 'music' ? '' : 'hover:text-zinc-200'"
|
||||||
>
|
>
|
||||||
@@ -99,26 +99,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Music</span>
|
<span class="mt-3 block font-display text-lg font-bold text-zinc-100">Music</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="grid grid-cols-2 border-t border-white/10">
|
<p class="border-t border-white/10 px-4 py-2 text-xs text-zinc-400">YuE2 · 3B lyrics-to-song</p>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="border-r border-white/10 px-3 py-2.5 text-left text-sm transition"
|
|
||||||
:class="engineBtnClass(current === 'music' && musicEngine === 'ace')"
|
|
||||||
@click="pickMusic('ace')"
|
|
||||||
>
|
|
||||||
<span class="block font-semibold">ACE</span>
|
|
||||||
<span class="text-xs opacity-80">Step</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-2.5 text-left text-sm transition"
|
|
||||||
:class="engineBtnClass(current === 'music' && musicEngine === 'yue')"
|
|
||||||
@click="pickMusic('yue')"
|
|
||||||
>
|
|
||||||
<span class="block font-semibold">YuE</span>
|
|
||||||
<span class="text-xs opacity-80">Lyrics</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -131,20 +112,19 @@ const props = withDefaults(defineProps<{
|
|||||||
videoEngine?: 'minimax' | 'ltx'
|
videoEngine?: 'minimax' | 'ltx'
|
||||||
videoStart?: 'still' | 'text'
|
videoStart?: 'still' | 'text'
|
||||||
imageEngine?: 'flux' | 'krea'
|
imageEngine?: 'flux' | 'krea'
|
||||||
musicEngine?: 'ace' | 'yue'
|
musicEngine?: 'yue2'
|
||||||
}>(), {
|
}>(), {
|
||||||
ltxEnabled: false,
|
ltxEnabled: false,
|
||||||
videoEngine: 'minimax',
|
videoEngine: 'minimax',
|
||||||
videoStart: 'still',
|
videoStart: 'still',
|
||||||
imageEngine: 'flux',
|
imageEngine: 'flux',
|
||||||
musicEngine: 'ace'
|
musicEngine: 'yue2'
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [kind: 'video' | 'image']
|
select: [kind: 'video' | 'image']
|
||||||
'video-engine': [engine: 'minimax' | 'ltx']
|
'video-engine': [engine: 'minimax' | 'ltx']
|
||||||
'image-engine': [engine: 'flux' | 'krea']
|
'image-engine': [engine: 'flux' | 'krea']
|
||||||
'music-engine': [engine: 'ace' | 'yue']
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function engineBtnClass(active: boolean) {
|
function engineBtnClass(active: boolean) {
|
||||||
@@ -161,12 +141,4 @@ function pickImage(engine: 'flux' | 'krea') {
|
|||||||
emit('image-engine', engine)
|
emit('image-engine', engine)
|
||||||
if (props.current !== 'image') emit('select', 'image')
|
if (props.current !== 'image') emit('select', 'image')
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickMusic(engine: 'ace' | 'yue') {
|
|
||||||
if (props.current === 'music') {
|
|
||||||
emit('music-engine', engine)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void navigateTo({ path: '/music', query: { engine } })
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-36 shrink-0 space-y-2"><span class="text-xs text-zinc-400">{{ label }}</span><button class="flex h-24 w-full items-center justify-center overflow-hidden rounded-xl border border-white/15 bg-black" @click="$emit('pick')"><span v-if="asset && hidden" class="p-2 text-xs text-zinc-400">Still loaded · preview hidden</span><img v-else-if="asset" :src="asset.kind==='clip'?`/api/library/clips/${asset.id}/thumb`:`/api/library/stills/${asset.id}`" class="h-full w-full object-contain" :alt="label"><span v-else class="text-xs text-zinc-400">Choose {{ label.toLowerCase() }}</span></button><p v-if="asset" class="truncate text-xs" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p><label v-if="upload" class="block cursor-pointer text-xs text-amber-200">Upload {{ label.toLowerCase() }}<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)"></label></div>
|
<div class="photo-card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="slot-thumb"
|
||||||
|
:class="asset ? 'slot-thumb--slot' : 'slot-thumb--empty'"
|
||||||
|
:aria-label="asset ? 'Replace photo' : 'Choose a photo'"
|
||||||
|
@click="$emit('pick')"
|
||||||
|
>
|
||||||
|
<span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Photo loaded</span>
|
||||||
|
<img v-else-if="asset" :src="thumbSrc" alt="">
|
||||||
|
<span v-else class="inline-flex flex-col items-center gap-1 px-2 text-center">
|
||||||
|
<span>Choose a photo</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="photo-card-body">
|
||||||
|
<label class="photo-role-label">
|
||||||
|
<span class="field-label">This photo is:</span>
|
||||||
|
<select
|
||||||
|
class="field-control photo-role-select"
|
||||||
|
:value="role"
|
||||||
|
:disabled="readonly"
|
||||||
|
@change="$emit('update:role', ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option v-for="opt in roleOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="slot-actions">
|
||||||
|
<button
|
||||||
|
v-if="!readonly && asset"
|
||||||
|
type="button"
|
||||||
|
class="slot-token-btn"
|
||||||
|
title="Mention this photo in the prompt"
|
||||||
|
@click.stop="$emit('mention')"
|
||||||
|
>Mention in prompt</button>
|
||||||
|
<button
|
||||||
|
v-if="!readonly && asset"
|
||||||
|
type="button"
|
||||||
|
@click.stop="$emit('pick')"
|
||||||
|
>Replace</button>
|
||||||
|
<button v-if="!readonly" type="button" @click.stop="$emit('remove')">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {assetLabel} from '~/shared/studio2/viewer.mjs'
|
const props = withDefaults(defineProps<{
|
||||||
defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean}>()
|
asset?: any
|
||||||
defineEmits(['pick','upload'])
|
hidden?: boolean
|
||||||
|
role: string
|
||||||
|
roleOptions: { id: string; label: string }[]
|
||||||
|
readonly?: boolean
|
||||||
|
}>(), { readonly: false, hidden: false })
|
||||||
|
defineEmits(['pick', 'remove', 'mention', 'update:role'])
|
||||||
|
const thumbSrc = computed(() => {
|
||||||
|
if (!props.asset) return ''
|
||||||
|
if (props.asset.kind === 'clip') return `/api/library/clips/${props.asset.id}/thumb`
|
||||||
|
return `/api/library/stills/${props.asset.id}/thumb`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,18 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props=defineProps<{metadata?:any}>()
|
const props=defineProps<{metadata?:any}>()
|
||||||
|
function promptRows(m:any){
|
||||||
|
const used=String(m.prompt || m.compiledPrompt || '').trim()
|
||||||
|
const typed=String(m.promptRaw || '').trim()
|
||||||
|
if(typed && typed!==used){
|
||||||
|
return [
|
||||||
|
{label:'Typed',value:typed},
|
||||||
|
...(used?[{label:'Used',value:used}]:[])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if(used) return [{label:'Prompt',value:used}]
|
||||||
|
return []
|
||||||
|
}
|
||||||
const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=(v:any)=>v?new Date(v).toLocaleString():null;return [
|
const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=(v:any)=>v?new Date(v).toLocaleString():null;return [
|
||||||
{label:'Kind / mode',value:[m.kind,m.mode].filter(Boolean).join(' / ')},
|
{label:'Kind / mode',value:[m.kind,m.mode].filter(Boolean).join(' / ')},
|
||||||
{label:'Engine',value:m.engine},{label:'Graph ID',value:m.graphId},
|
{label:'Engine',value:m.engine},{label:'Graph ID',value:m.graphId},
|
||||||
@@ -19,7 +31,21 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=
|
|||||||
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
|
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
|
||||||
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
|
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
|
||||||
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
|
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
|
||||||
{label:'Compiled prompt',value:m.compiledPrompt},{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
|
...promptRows(m),
|
||||||
|
{label:'Task',value:m.task || m.mode || null},
|
||||||
|
{label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'},
|
||||||
|
{label:'Enhance',value:m.enhance?.refused?'Enhance skipped (model refused) — used your prompt.':m.enhance?.skippedAsDescribe?'Enhance skipped (describe rewrite) — keep stanza + typed only.':m.enhance?.used?'Applied':m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Applied':'Parse incomplete'},
|
||||||
|
{label:'Enhance used',value:m.enhance?.used==null?null:m.enhance.used?'Yes':'No'},
|
||||||
|
{label:'Enhance refused',value:m.enhance?.refused==null?null:m.enhance.refused?'Yes':'No'},
|
||||||
|
{label:'Enhance skip reason',value:m.enhance?.skipReason || (m.enhance?.skippedAsDescribe?'skippedAsDescribe':null)},
|
||||||
|
{label:'Turbo',value:m.turbo==null?null:m.turbo?'On':'Off'},
|
||||||
|
{label:'Turbo LoRA',value:m.lora || null},
|
||||||
|
{label:'Turbo sigmas',value:m.sigmas || null},
|
||||||
|
{label:'Enhance wh_ratio',value:m.enhance?.wh_ratio || null},
|
||||||
|
{label:'Enhance ratio_follow',value:m.enhance?.ratio_follow || null},
|
||||||
|
{label:'Enhance parse_ok',value:m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Yes':'No'},
|
||||||
|
{label:'Enhance thinking',value:m.enhance?.thinking || null},
|
||||||
|
{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)},
|
||||||
{label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult}
|
{label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult}
|
||||||
]})
|
]})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<div class="space-y-2 rounded-xl border border-white/10 p-3 text-sm" aria-live="polite">
|
<div class="space-y-2 rounded-xl border border-white/10 p-3 text-sm" aria-live="polite">
|
||||||
<div class="flex justify-between gap-3"><button class="text-left" @click="$emit('select')"><strong>{{ job.request.promptSections.action.slice(0,70) }}</strong><span class="block capitalize text-amber-200">{{ job.state==='waiting'?'queued':job.state }} · Shot {{ shot }}/{{ total }}</span></button><button v-if="!terminal" @click="$emit('cancel')">Cancel</button></div>
|
<div class="flex justify-between gap-3"><button class="text-left" @click="$emit('select')"><strong>{{ job.request.promptSections.action.slice(0,70) }}</strong><span class="block capitalize text-amber-200">{{ job.state==='waiting'?'queued':job.state }} · Shot {{ shot }}/{{ total }}</span></button><button v-if="!terminal" @click="$emit('cancel')">Cancel</button></div>
|
||||||
<p>{{ elapsed }} {{ job.startedAt?'elapsed':'waiting' }} · {{ job.request.settings.duration }}s target<span v-if="job.state==='rendering' && job.progress"> · Step {{ job.progress.step }}/{{ job.progress.maxStep }} ({{ job.progress.percent }}%)</span></p>
|
<p>{{ elapsed }} {{ job.startedAt?'elapsed':'waiting' }} · {{ job.request.settings.duration }}s target<span v-if="job.state==='rendering' && job.progress"> · Step {{ job.progress.step }}/{{ job.progress.maxStep }} ({{ job.progress.percent }}%)</span></p>
|
||||||
<progress v-if="job.state==='rendering'" class="h-2 w-full accent-amber-300" :value="job.progress?.percent" max="100" />
|
<progress v-if="job.state==='enhancing'" class="h-2 w-full accent-amber-300" max="100" :value="enhancePercent" />
|
||||||
|
<progress v-else-if="job.state==='rendering'" class="h-2 w-full accent-amber-300" :value="job.progress?.percent" max="100" />
|
||||||
<p v-if="job.error" role="alert" class="text-red-300">{{ job.error }}</p>
|
<p v-if="job.error" role="alert" class="text-red-300">{{ job.error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -13,4 +14,9 @@ const terminal=computed(()=>['complete','failed','cancelled'].includes(props.job
|
|||||||
const total=computed(()=>props.job.prompts?.length || (props.job.request.batch?.length || 0)+1)
|
const total=computed(()=>props.job.prompts?.length || (props.job.request.batch?.length || 0)+1)
|
||||||
const shot=computed(()=>Math.min(total.value,(props.job.index || 0)+1))
|
const shot=computed(()=>Math.min(total.value,(props.job.index || 0)+1))
|
||||||
const elapsed=computed(()=>{const seconds=elapsedSeconds(props.job,props.now);return `${Math.floor(seconds/60)}m ${seconds%60}s`})
|
const elapsed=computed(()=>{const seconds=elapsedSeconds(props.job,props.now);return `${Math.floor(seconds/60)}m ${seconds%60}s`})
|
||||||
|
const enhancePercent=computed(()=>{
|
||||||
|
const p=props.job.progress
|
||||||
|
if(p && Number(p.maxStep)>0) return Number(p.percent)||Math.round(Number(p.step)/Number(p.maxStep)*100)
|
||||||
|
return undefined // indeterminate while PE runs without Comfy step events
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,701 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="lib-modal-root"
|
||||||
|
role="presentation"
|
||||||
|
@keydown.esc.prevent="close"
|
||||||
|
>
|
||||||
|
<div class="lib-modal-backdrop" @click="close" />
|
||||||
|
<section
|
||||||
|
ref="dialogEl"
|
||||||
|
class="lib-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="lib-modal-title"
|
||||||
|
tabindex="-1"
|
||||||
|
@keydown.tab="onTab"
|
||||||
|
>
|
||||||
|
<header class="lib-modal-head">
|
||||||
|
<h2 id="lib-modal-title">Library</h2>
|
||||||
|
<div class="lib-modal-head-actions">
|
||||||
|
<NuxtLink
|
||||||
|
class="lib-modal-full"
|
||||||
|
:to="{ path: '/library', query: activeFolderId && activeFolderId !== 'all' ? { folder: activeFolderId } : {} }"
|
||||||
|
@click="close"
|
||||||
|
>Full library</NuxtLink>
|
||||||
|
<button type="button" class="lib-modal-x" aria-label="Close" @click="close">×</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="lib-modal-toolbar">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="search"
|
||||||
|
class="lib-modal-search"
|
||||||
|
placeholder="Name or prompt"
|
||||||
|
aria-label="Search library"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="lib-modal-preview-all"
|
||||||
|
@click="$emit('toggle-preview')"
|
||||||
|
>{{ previewHidden ? 'Show all previews' : 'Hide all previews' }}</button>
|
||||||
|
<div class="lib-modal-filters" role="group" aria-label="Type">
|
||||||
|
<button
|
||||||
|
v-for="opt in filterOptions"
|
||||||
|
:key="opt.id"
|
||||||
|
type="button"
|
||||||
|
class="lib-modal-chip"
|
||||||
|
:data-on="filter === opt.id ? 'true' : undefined"
|
||||||
|
@click="filter = opt.id"
|
||||||
|
>{{ opt.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lib-modal-body">
|
||||||
|
<div ref="gridScrollEl" class="lib-modal-grid" aria-label="Folder assets">
|
||||||
|
<p v-if="loading && !entries.length" class="lib-modal-empty">Loading library…</p>
|
||||||
|
<p v-else-if="loadError" class="lib-modal-empty">{{ loadError }}</p>
|
||||||
|
<p v-else-if="!entries.length" class="lib-modal-empty">No items in this folder yet.</p>
|
||||||
|
<article
|
||||||
|
v-for="entry in entries"
|
||||||
|
:key="entry.key"
|
||||||
|
class="lib-modal-tile"
|
||||||
|
:data-thumb-key="entry.key"
|
||||||
|
:data-active="entry.key === focusedKey ? 'true' : undefined"
|
||||||
|
@click="pickEntry(entry)"
|
||||||
|
>
|
||||||
|
<div class="lib-modal-media">
|
||||||
|
<div
|
||||||
|
v-if="entry.kind === 'track'"
|
||||||
|
class="lib-modal-track"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="n in 16"
|
||||||
|
:key="n"
|
||||||
|
class="lib-modal-bar"
|
||||||
|
:style="{ height: `${20 + ((n * 17) % 60)}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
v-else-if="!previewHidden && liveThumbs.has(entry.key) && thumbSrc(entry)"
|
||||||
|
:src="thumbSrc(entry)"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
<span v-else class="lib-modal-fallback">{{ previewHidden ? 'Hidden' : '' }}</span>
|
||||||
|
<span class="lib-modal-kind">
|
||||||
|
{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="lib-modal-caption">
|
||||||
|
<p class="lib-modal-name">{{ entry.name }}</p>
|
||||||
|
<p class="lib-modal-meta">{{ cardMeta(entry) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<aside v-if="detailAsset" class="lib-modal-detail">
|
||||||
|
<p class="lib-modal-detail-name">{{ detailLabel }}</p>
|
||||||
|
<div v-if="detailAsset.kind !== 'track'" class="lib-modal-actions">
|
||||||
|
<button type="button" @click="$emit('use-image', detailAsset)">Use image</button>
|
||||||
|
<button type="button" @click="$emit('use-prompt', detailAsset)">Use prompt</button>
|
||||||
|
<button type="button" @click="$emit('copy-prompt', detailAsset)">Copy prompt</button>
|
||||||
|
<button type="button" @click="$emit('copy-raw-prompt', detailAsset)">Copy raw prompt</button>
|
||||||
|
<button type="button" @click="$emit('load-input', detailAsset)">Load input</button>
|
||||||
|
<button type="button" @click="$emit('load-input-raw', detailAsset)">Load input raw</button>
|
||||||
|
<button type="button" @click="$emit('toggle-preview')">{{ previewHidden ? 'Show output' : 'Hide output' }}</button>
|
||||||
|
<button type="button" class="danger" @click="onDeleteClick">Delete</button>
|
||||||
|
<button type="button" class="primary" @click="useAndClose">Use and close</button>
|
||||||
|
</div>
|
||||||
|
<LibrarySettingsDetails :rows="detailRows" empty="No generator settings were saved with this item." />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
buildLibraryEntries,
|
||||||
|
formatClipDuration,
|
||||||
|
formatWhen,
|
||||||
|
type LibraryClip,
|
||||||
|
type LibraryEntry,
|
||||||
|
type LibraryFilter,
|
||||||
|
type LibraryFolder,
|
||||||
|
type LibraryStill,
|
||||||
|
type LibraryTrack
|
||||||
|
} from '~/utils/libraryBrowse'
|
||||||
|
import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration'
|
||||||
|
import { assetLabel } from '~/shared/studio2/viewer.mjs'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
assets?: any[]
|
||||||
|
selected?: any
|
||||||
|
folderId?: string
|
||||||
|
previewHidden?: boolean
|
||||||
|
isHidden?: (asset: any) => boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
select: [asset: any]
|
||||||
|
'use-image': [asset: any]
|
||||||
|
'use-prompt': [asset: any]
|
||||||
|
'copy-prompt': [asset: any]
|
||||||
|
'copy-raw-prompt': [asset: any]
|
||||||
|
'load-input': [asset: any]
|
||||||
|
'load-input-raw': [asset: any]
|
||||||
|
delete: [asset: any]
|
||||||
|
'toggle-preview': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const filterOptions = [
|
||||||
|
{ id: 'all' as const, label: 'All' },
|
||||||
|
{ id: 'images' as const, label: 'Images' },
|
||||||
|
{ id: 'videos' as const, label: 'Videos' },
|
||||||
|
{ id: 'music' as const, label: 'Music' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const filter = ref<LibraryFilter>('all')
|
||||||
|
const dialogEl = ref<HTMLElement | null>(null)
|
||||||
|
const gridScrollEl = ref<HTMLElement | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const folders = ref<LibraryFolder[]>([])
|
||||||
|
const clips = ref<LibraryClip[]>([])
|
||||||
|
const stills = ref<LibraryStill[]>([])
|
||||||
|
const tracks = ref<LibraryTrack[]>([])
|
||||||
|
const fetchedAt = ref(0)
|
||||||
|
const nextCursor = ref<string | null>(null)
|
||||||
|
const liveThumbs = ref(new Set<string>())
|
||||||
|
let thumbObserver: IntersectionObserver | null = null
|
||||||
|
let scrollLoadBound = false
|
||||||
|
|
||||||
|
const activeFolderId = computed(() => props.folderId || folders.value[0]?.id || 'all')
|
||||||
|
|
||||||
|
const entries = computed(() => buildLibraryEntries({
|
||||||
|
clips: clips.value,
|
||||||
|
stills: stills.value,
|
||||||
|
tracks: tracks.value,
|
||||||
|
folderId: activeFolderId.value,
|
||||||
|
filter: filter.value,
|
||||||
|
search: query.value,
|
||||||
|
sort: 'date-desc'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const focusedKey = computed(() => {
|
||||||
|
const id = props.selected?.id
|
||||||
|
if (!id) return ''
|
||||||
|
const hit = entries.value.find(entry => assetFromEntry(entry)?.id === id)
|
||||||
|
return hit?.key || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailAsset = computed(() => {
|
||||||
|
if (props.selected) return props.selected
|
||||||
|
const first = entries.value[0]
|
||||||
|
return first ? assetFromEntry(first) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailLabel = computed(() => assetLabel(detailAsset.value))
|
||||||
|
|
||||||
|
const detailRows = computed(() => {
|
||||||
|
const item = detailAsset.value
|
||||||
|
if (!item) return []
|
||||||
|
if (item.kind === 'clip') return clipSettingsRows(item)
|
||||||
|
if (item.kind === 'track') return trackSettingsRows(item)
|
||||||
|
return stillSettingsRows(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
function thumbSrc(entry: LibraryEntry) {
|
||||||
|
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
|
||||||
|
if (entry.kind === 'track') return ''
|
||||||
|
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}/thumb`
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardMeta(entry: LibraryEntry) {
|
||||||
|
if (entry.kind === 'clip' && entry.group) {
|
||||||
|
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
if (entry.kind === 'track' && entry.track) {
|
||||||
|
return [formatClipDuration(entry.track.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
return formatWhen(entry.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetFromEntry(entry: LibraryEntry) {
|
||||||
|
if (entry.kind === 'clip' && entry.group) return { ...entry.group.head, kind: 'clip' as const }
|
||||||
|
if (entry.kind === 'track' && entry.track) return { ...entry.track, kind: 'track' as const }
|
||||||
|
if (entry.stillGroup) return { ...entry.stillGroup.head, kind: 'still' as const }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickEntry(entry: LibraryEntry) {
|
||||||
|
const asset = assetFromEntry(entry)
|
||||||
|
if (!asset) return
|
||||||
|
const fromFamily = (props.assets || []).find((a: any) => a.id === asset.id)
|
||||||
|
emit('select', fromFamily ? { ...asset, ...fromFamily, kind: asset.kind } : asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() { emit('close') }
|
||||||
|
|
||||||
|
function useAndClose() {
|
||||||
|
if (detailAsset.value) emit('use-image', detailAsset.value)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLocalAsset(asset: any) {
|
||||||
|
if (!asset?.id) return
|
||||||
|
stills.value = stills.value.filter(s => s.id !== asset.id)
|
||||||
|
clips.value = clips.value.filter(c => c.id !== asset.id)
|
||||||
|
tracks.value = tracks.value.filter(t => t.id !== asset.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteClick() {
|
||||||
|
const asset = detailAsset.value
|
||||||
|
if (!asset) return
|
||||||
|
emit('delete', asset)
|
||||||
|
removeLocalAsset(asset)
|
||||||
|
window.setTimeout(() => { void fetchLibrary(true) }, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeIds(prev: { id: string }[], next: { id: string }[]) {
|
||||||
|
const map = new Map(prev.map(item => [item.id, item]))
|
||||||
|
for (const item of next) map.set(item.id, item)
|
||||||
|
return [...map.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLibraryPage(data: any, replace: boolean) {
|
||||||
|
folders.value = data?.folders || folders.value
|
||||||
|
if (replace) {
|
||||||
|
clips.value = data?.clips || []
|
||||||
|
stills.value = data?.stills || []
|
||||||
|
tracks.value = data?.tracks || []
|
||||||
|
} else {
|
||||||
|
clips.value = mergeIds(clips.value, data?.clips || []) as LibraryClip[]
|
||||||
|
stills.value = mergeIds(stills.value, data?.stills || []) as LibraryStill[]
|
||||||
|
tracks.value = mergeIds(tracks.value, data?.tracks || []) as LibraryTrack[]
|
||||||
|
}
|
||||||
|
nextCursor.value = data?.nextCursor || null
|
||||||
|
fetchedAt.value = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLibraryPage(cursor: string | null) {
|
||||||
|
const folder = activeFolderId.value && activeFolderId.value !== 'all' ? activeFolderId.value : ''
|
||||||
|
const type = filter.value === 'all' ? 'all' : filter.value === 'images' ? 'images' : filter.value === 'videos' ? 'videos' : 'music'
|
||||||
|
const qs = new URLSearchParams({ limit: '50' })
|
||||||
|
if (folder) qs.set('folderId', folder)
|
||||||
|
if (type !== 'all') qs.set('type', type)
|
||||||
|
if (cursor) qs.set('cursor', cursor)
|
||||||
|
return await $fetch<any>(`/api/library?${qs.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetchGen = 0
|
||||||
|
|
||||||
|
async function fetchLibrary(force = false) {
|
||||||
|
const stale = !fetchedAt.value || Date.now() - fetchedAt.value > 15_000
|
||||||
|
const empty = !stills.value.length && !clips.value.length && !tracks.value.length
|
||||||
|
if (!force && !empty && !stale) return
|
||||||
|
const gen = ++fetchGen
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
liveThumbs.value = new Set()
|
||||||
|
try {
|
||||||
|
const first = await fetchLibraryPage(null)
|
||||||
|
if (gen !== fetchGen) return
|
||||||
|
applyLibraryPage(first, true)
|
||||||
|
loading.value = false
|
||||||
|
nextTick(() => observeThumbs())
|
||||||
|
// Keep fetching until the folder is exhausted — 50 is a page size, not a cap
|
||||||
|
let cursor = first?.nextCursor || null
|
||||||
|
while (cursor && gen === fetchGen) {
|
||||||
|
const more = await fetchLibraryPage(cursor)
|
||||||
|
if (gen !== fetchGen) return
|
||||||
|
applyLibraryPage(more, false)
|
||||||
|
cursor = more?.nextCursor || null
|
||||||
|
nextTick(() => observeThumbs())
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (gen !== fetchGen) return
|
||||||
|
loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library'
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeThumbs() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
const root = gridScrollEl.value
|
||||||
|
if (!root) return
|
||||||
|
if (thumbObserver) thumbObserver.disconnect()
|
||||||
|
thumbObserver = new IntersectionObserver((entries) => {
|
||||||
|
let changed = false
|
||||||
|
const next = new Set(liveThumbs.value)
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue
|
||||||
|
const key = (entry.target as HTMLElement).dataset.thumbKey
|
||||||
|
if (!key || next.has(key)) continue
|
||||||
|
next.add(key)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) liveThumbs.value = next
|
||||||
|
}, { root, rootMargin: '200px 0px', threshold: 0.01 })
|
||||||
|
root.querySelectorAll<HTMLElement>('[data-thumb-key]').forEach((el) => thumbObserver!.observe(el))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGridScroll() {
|
||||||
|
const el = gridScrollEl.value
|
||||||
|
if (!el || !nextCursor.value || loading.value) return
|
||||||
|
if (el.scrollTop + el.clientHeight < el.scrollHeight - 240) return
|
||||||
|
void fetchMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMore() {
|
||||||
|
if (!nextCursor.value || loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const more = await fetchLibraryPage(nextCursor.value)
|
||||||
|
applyLibraryPage(more, false)
|
||||||
|
nextTick(() => observeThumbs())
|
||||||
|
} catch { /* keep what we have */ }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTab(e: KeyboardEvent) {
|
||||||
|
const root = dialogEl.value
|
||||||
|
if (!root) return
|
||||||
|
const focusable = [...root.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
)].filter(el => el.offsetParent !== null || el === document.activeElement)
|
||||||
|
if (!focusable.length) return
|
||||||
|
const first = focusable[0]
|
||||||
|
const last = focusable[focusable.length - 1]
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, (on) => {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
if (on) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
void fetchLibrary(true)
|
||||||
|
nextTick(() => {
|
||||||
|
dialogEl.value?.focus()
|
||||||
|
if (gridScrollEl.value) {
|
||||||
|
gridScrollEl.value.scrollTop = 0
|
||||||
|
if (!scrollLoadBound) {
|
||||||
|
gridScrollEl.value.addEventListener('scroll', onGridScroll, { passive: true })
|
||||||
|
scrollLoadBound = true
|
||||||
|
}
|
||||||
|
observeThumbs()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
thumbObserver?.disconnect()
|
||||||
|
thumbObserver = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(entries, () => nextTick(() => observeThumbs()))
|
||||||
|
|
||||||
|
watch([filter, activeFolderId], () => {
|
||||||
|
if (!props.open) return
|
||||||
|
void fetchLibrary(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
gridScrollEl.value?.removeEventListener('scroll', onGridScroll)
|
||||||
|
thumbObserver?.disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lib-modal-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.lib-modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: #000a;
|
||||||
|
}
|
||||||
|
.lib-modal {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
max-height: min(92vh, 900px);
|
||||||
|
height: min(92vh, 900px);
|
||||||
|
margin: auto;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #2a2a2e;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #141416;
|
||||||
|
color: #f3f1ea;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.lib-modal-root { align-items: flex-end; }
|
||||||
|
.lib-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100dvh;
|
||||||
|
height: 100dvh;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lib-modal-head {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid #2a2a2e;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.lib-modal-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.lib-modal-head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.lib-modal-full {
|
||||||
|
color: #f5a524;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.lib-modal-x {
|
||||||
|
display: grid;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #2a2a2e;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lib-modal-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #2a2a2e;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
.lib-modal-search {
|
||||||
|
min-width: 12rem;
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #2a2a2e;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0b0b0d;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.lib-modal-preview-all {
|
||||||
|
border: 1px solid #2a2a2e;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lib-modal-preview-all:hover {
|
||||||
|
border-color: #f5a524;
|
||||||
|
color: #f5a524;
|
||||||
|
}
|
||||||
|
.lib-modal-filters { display: inline-flex; gap: 4px; }
|
||||||
|
.lib-modal-chip {
|
||||||
|
border: 1px solid #2a2a2e;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: #9a9588;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lib-modal-chip[data-on='true'] {
|
||||||
|
border-color: #f5a524;
|
||||||
|
background: #f5a52422;
|
||||||
|
color: #f5a524;
|
||||||
|
}
|
||||||
|
.lib-modal-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.lib-modal-body { flex-direction: column; }
|
||||||
|
}
|
||||||
|
.lib-modal-grid {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
align-content: start;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.lib-modal-tile {
|
||||||
|
display: flex;
|
||||||
|
min-height: 120px;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid #2a2a2e;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0b0b0d;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lib-modal-tile[data-active='true'] {
|
||||||
|
border-color: #f5a524;
|
||||||
|
box-shadow: 0 0 0 2px #f5a524;
|
||||||
|
}
|
||||||
|
.lib-modal-media {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
min-height: 120px;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #18181b;
|
||||||
|
}
|
||||||
|
.lib-modal-media > img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.lib-modal-fallback {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #27272a;
|
||||||
|
}
|
||||||
|
.lib-modal-track {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.lib-modal-bar {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(245, 165, 36, 0.7);
|
||||||
|
}
|
||||||
|
.lib-modal-kind {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #000a;
|
||||||
|
padding: 2px 6px;
|
||||||
|
color: #e4e4e7;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.lib-modal-caption {
|
||||||
|
padding: 6px 8px 8px;
|
||||||
|
}
|
||||||
|
.lib-modal-name {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #f4f4f5;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lib-modal-meta {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #71717a;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lib-modal-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #9a9588;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.lib-modal-detail {
|
||||||
|
flex: 0 0 min(320px, 38%);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border-left: 1px solid #2a2a2e;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.lib-modal-detail {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-height: 40%;
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid #2a2a2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lib-modal-detail-name {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.lib-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.lib-modal-actions button {
|
||||||
|
border: 1px solid #2a2a2e;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lib-modal-actions button.primary {
|
||||||
|
border-color: #f5a524;
|
||||||
|
background: #f5a524;
|
||||||
|
color: #1a1204;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.lib-modal-actions button.danger { color: #fca5a5; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,891 @@
|
|||||||
|
<template>
|
||||||
|
<div class="m-shell" data-mobile="1" :data-queue-collapsed="queueRailCollapsed ? '1' : undefined">
|
||||||
|
<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 m-queue-chip-portrait" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
|
||||||
|
<button type="button" class="m-chip m-queue-chip-landscape" :aria-expanded="queueRailCollapsed ? 'false' : 'true'" @click="queueRailCollapsed=!queueRailCollapsed">
|
||||||
|
Queue {{ ctx.queueCount }} {{ queueRailCollapsed ? '▸' : '▾' }}
|
||||||
|
</button>
|
||||||
|
<details class="m-more">
|
||||||
|
<summary aria-label="More">⋯</summary>
|
||||||
|
<div class="m-more-menu">
|
||||||
|
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
|
||||||
|
<NuxtLink to="/prompt-builder" @click="closeMenus">Prompt Builder</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"
|
||||||
|
:class="{ 'm-stage--veiled': ctx.selected && ctx.isHidden(ctx.selected) }"
|
||||||
|
@touchstart.passive="onStageTouchStart"
|
||||||
|
@touchend.passive="onStageTouchEnd"
|
||||||
|
>
|
||||||
|
<div class="m-stage-chrome" aria-label="Stage controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="m-icon-btn m-library"
|
||||||
|
:aria-expanded="ctx.libraryOpen ? 'true' : 'false'"
|
||||||
|
aria-label="Library"
|
||||||
|
@click="ctx.openLibrary()"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<rect x="3" y="4" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="14" y="4" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="3" y="13" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="14" y="13" width="7" height="7" rx="1.5" />
|
||||||
|
</svg>
|
||||||
|
<span v-if="ctx.familyStillCount" class="m-library-badge">{{ ctx.familyStillCount > 99 ? '99+' : ctx.familyStillCount }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="m-stage-chrome-end">
|
||||||
|
<button
|
||||||
|
v-if="ctx.selected && ctx.selected.kind!=='track'"
|
||||||
|
type="button"
|
||||||
|
class="m-icon-btn"
|
||||||
|
aria-label="More"
|
||||||
|
:aria-expanded="sheet==='actions' ? 'true' : 'false'"
|
||||||
|
@click.stop="toggleSheet('actions')"
|
||||||
|
>•••</button>
|
||||||
|
<button
|
||||||
|
v-if="ctx.selected"
|
||||||
|
type="button"
|
||||||
|
class="m-icon-btn m-eye"
|
||||||
|
:aria-label="ctx.eyeOpen ? 'Hide output' : 'Show output'"
|
||||||
|
@click.stop="ctx.toggleReveal()"
|
||||||
|
>
|
||||||
|
<img :src="ctx.eyeOpen ? '/icons/display/eye.svg' : '/icons/display/eye-off.svg'" alt="" width="20" height="20">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="(ctx.family || []).length > 1"
|
||||||
|
type="button"
|
||||||
|
class="m-nav m-nav-prev"
|
||||||
|
aria-label="Previous"
|
||||||
|
@click.stop="ctx.stepFamily(-1)"
|
||||||
|
>‹</button>
|
||||||
|
<button
|
||||||
|
v-if="(ctx.family || []).length > 1"
|
||||||
|
type="button"
|
||||||
|
class="m-nav m-nav-next"
|
||||||
|
aria-label="Next"
|
||||||
|
@click.stop="ctx.stepFamily(1)"
|
||||||
|
>›</button>
|
||||||
|
<p
|
||||||
|
v-show="ctx.selected && ctx.isHidden(ctx.selected)"
|
||||||
|
class="m-hidden-note"
|
||||||
|
>{{ ctx.selected?.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
|
||||||
|
<template v-if="ctx.selected?.kind==='track'">
|
||||||
|
<div v-show="!ctx.isHidden(ctx.selected)" 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'"
|
||||||
|
v-show="!ctx.isHidden(ctx.selected)"
|
||||||
|
:key="ctx.selected.id"
|
||||||
|
:src="`/api/library/clips/${ctx.selected.id}/video`"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else-if="ctx.selected"
|
||||||
|
v-show="!ctx.isHidden(ctx.selected)"
|
||||||
|
:key="ctx.selected.id+'-'+(ctx.stagePreviewNonce||0)"
|
||||||
|
class="m-stage-still"
|
||||||
|
:src="ctx.stagePreviewSrc || ctx.stillPreviewUrl(ctx.selected.id)"
|
||||||
|
alt="Selected still"
|
||||||
|
decoding="async"
|
||||||
|
@error="ctx.onStagePreviewError?.()"
|
||||||
|
>
|
||||||
|
<p v-else class="m-muted">Open Library to pick a still or clip</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Studio2LibraryModal
|
||||||
|
:open="!!ctx.libraryOpen"
|
||||||
|
:assets="ctx.family || []"
|
||||||
|
:selected="ctx.selected"
|
||||||
|
:folder-id="ctx.form?.folderId"
|
||||||
|
:preview-hidden="!ctx.eyeOpen"
|
||||||
|
:is-hidden="ctx.isHidden"
|
||||||
|
@close="ctx.closeLibrary()"
|
||||||
|
@select="ctx.selectOutput"
|
||||||
|
@use-image="ctx.onLibraryUseImage"
|
||||||
|
@use-prompt="ctx.onLibraryUsePrompt"
|
||||||
|
@copy-prompt="ctx.onLibraryCopyPrompt"
|
||||||
|
@copy-raw-prompt="ctx.onLibraryCopyRawPrompt"
|
||||||
|
@load-input="ctx.onLibraryLoadInput"
|
||||||
|
@load-input-raw="ctx.onLibraryLoadInputRaw"
|
||||||
|
@delete="ctx.onLibraryDelete"
|
||||||
|
@toggle-preview="ctx.toggleReveal()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="m-dock">
|
||||||
|
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Photos</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==='actions'" class="m-sheet m-sheet-actions" role="dialog" aria-label="Still actions">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Actions</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body m-actions-list">
|
||||||
|
<button type="button" @click="runAction('use-image')">Use image</button>
|
||||||
|
<button type="button" @click="runAction('use-prompt')">Use prompt</button>
|
||||||
|
<button type="button" @click="runAction('copy')">Copy prompt</button>
|
||||||
|
<button type="button" @click="runAction('copy-raw')">Copy raw prompt</button>
|
||||||
|
<button type="button" @click="runAction('load-input')">Load input</button>
|
||||||
|
<button type="button" @click="runAction('load-input-raw')">Load input raw</button>
|
||||||
|
<button type="button" class="m-danger" @click="runAction('delete')">Delete</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Photos">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Photos</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<template v-if="ctx.form.mode==='music' || !ctx.photosPlanState?.show">
|
||||||
|
<p class="m-muted">No photos for this task.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="ctx.photosPlanState.handoffReadonly">
|
||||||
|
<p class="m-muted">Continues from last frame</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="m-photos-head">
|
||||||
|
<button type="button" class="m-link" @click="ctx.toggleHideInputs()">
|
||||||
|
{{ ctx.hideInputs ? 'Show inputs' : 'Hide inputs' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!ctx.form.photos?.length && ctx.photosPlanState.emptyHint" class="m-muted">{{ ctx.photosPlanState.emptyHint }}</p>
|
||||||
|
<Studio2AssetSlot
|
||||||
|
v-for="(card, index) in ctx.form.photos"
|
||||||
|
:key="'m-photo-'+index+'-'+card.stillId"
|
||||||
|
:asset="ctx.stillById(card.stillId)"
|
||||||
|
:hidden="!!ctx.hideInputs"
|
||||||
|
:role="card.role"
|
||||||
|
:role-options="ctx.photoRoleOptions"
|
||||||
|
@pick="ctx.setPicker('photo:'+index)"
|
||||||
|
@remove="ctx.removePhoto(index)"
|
||||||
|
@mention="ctx.mentionPhoto(index)"
|
||||||
|
@update:role="ctx.setPhotoRole(index, $event)"
|
||||||
|
/>
|
||||||
|
<button v-if="(ctx.form.photos?.length||0) < ctx.photosPlanState.max" type="button" class="m-amber" @click="ctx.addPhoto">+ Add photo</button>
|
||||||
|
<p v-if="ctx.photoRoleWarning" class="m-hint">{{ ctx.photoRoleWarning }}</p>
|
||||||
|
<template v-if="ctx.showIdentityLocks">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
<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">
|
||||||
|
<span class="m-label-row">Prompt<button type="button" class="m-link" @click.stop="ctx.clearPrompt()">Clear</button></span>
|
||||||
|
<textarea
|
||||||
|
:value="ctx.form.promptSections.action"
|
||||||
|
rows="5"
|
||||||
|
class="m-area"
|
||||||
|
placeholder="Describe the image or the shot."
|
||||||
|
readonly
|
||||||
|
@focus="openPromptEditor"
|
||||||
|
@click="openPromptEditor"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label>
|
||||||
|
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label>
|
||||||
|
<p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
|
||||||
|
<div>
|
||||||
|
<span class="m-label">Negative prompt</span>
|
||||||
|
<div class="m-neg">
|
||||||
|
<span v-for="chip in ctx.negativeChips" :key="chip" class="m-neg-chip">
|
||||||
|
{{ chip }}
|
||||||
|
<button type="button" :aria-label="'Remove '+chip" @click="ctx.removeNegative(chip)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-neg-add">
|
||||||
|
<input
|
||||||
|
:value="ctx.negativeDraft"
|
||||||
|
type="text"
|
||||||
|
class="m-input"
|
||||||
|
placeholder="Add negative chip"
|
||||||
|
@input="ctx.setNegativeDraft(($event.target as HTMLInputElement).value)"
|
||||||
|
@keydown.enter.prevent="ctx.addNegative"
|
||||||
|
>
|
||||||
|
<button type="button" @click="ctx.addNegative">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Studio2StyleChips v-if="!ctx.video" v-model="ctx.form.imageStyles" />
|
||||||
|
</template>
|
||||||
|
<p v-if="ctx.error" class="m-err">{{ ctx.error }}</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside v-if="sheet==='look'" class="m-sheet" role="dialog" aria-label="Look">
|
||||||
|
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||||
|
<div class="m-sheet-head"><strong>Look</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<template v-if="ctx.form.mode==='music'">
|
||||||
|
<label class="m-label">Seed<input v-model="ctx.music.seed" class="m-input" placeholder="random"></label>
|
||||||
|
<label class="m-label">Track name<input v-model="ctx.music.name" class="m-input" placeholder="Optional"></label>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div>
|
||||||
|
<span class="m-label">LoRA</span>
|
||||||
|
<LoraSelect v-model="ctx.form.settings.loraStack" compact :names="ctx.pickerLoras" :use="ctx.currentLoraUse" :clip-strength="ctx.form.engine!=='qwen21' && !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 m-queue-sheet-portrait" 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>
|
||||||
|
<button v-else-if="card.dismissable" type="button" @click.stop="ctx.dismissFinishedCard(card)">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="card.resultText" class="m-muted" style="white-space:pre-wrap">{{ card.resultText }}</p>
|
||||||
|
<div v-if="card.resultText" class="m-job-status">
|
||||||
|
<button type="button" @click.stop="ctx.copyCaption(card.resultText)">Copy</button>
|
||||||
|
<button type="button" @click.stop="ctx.useCaptionAsPrompt(card.resultText)">Use as prompt</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="card.progress" class="m-job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
|
||||||
|
<progress
|
||||||
|
class="m-job-progress-bar"
|
||||||
|
max="100"
|
||||||
|
:value="card.progress.indeterminate ? undefined : card.progress.percent"
|
||||||
|
/>
|
||||||
|
<span v-if="card.elapsed" class="m-muted">{{ card.elapsed }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
|
||||||
|
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside v-show="!queueRailCollapsed" class="m-queue-rail" aria-label="Queue">
|
||||||
|
<div class="m-sheet-head">
|
||||||
|
<strong>Queue</strong>
|
||||||
|
<button type="button" @click="queueRailCollapsed=true">Hide</button>
|
||||||
|
</div>
|
||||||
|
<div class="m-sheet-body">
|
||||||
|
<div
|
||||||
|
v-for="card in ctx.queueCards"
|
||||||
|
:key="'rail-'+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>
|
||||||
|
<button v-else-if="card.dismissable" type="button" @click.stop="ctx.dismissFinishedCard(card)">Dismiss</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
|
||||||
|
v-if="promptEditorOpen"
|
||||||
|
class="m-prompt-editor"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Edit prompt"
|
||||||
|
>
|
||||||
|
<header class="m-prompt-editor-head">
|
||||||
|
<strong>Prompt</strong>
|
||||||
|
<button type="button" @click="closePromptEditor">Done</button>
|
||||||
|
</header>
|
||||||
|
<textarea
|
||||||
|
ref="promptEditorEl"
|
||||||
|
v-model="promptEditorDraft"
|
||||||
|
class="m-prompt-editor-area"
|
||||||
|
placeholder="Describe the image or the shot."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
export type Studio2MobileCtx = Record<string, any>
|
||||||
|
|
||||||
|
const props = defineProps<{ ctx: Studio2MobileCtx }>()
|
||||||
|
const sheet = ref('')
|
||||||
|
const queueRailCollapsed = ref(false)
|
||||||
|
const touchStart = ref<{ x: number; y: number } | null>(null)
|
||||||
|
const promptEditorOpen = ref(false)
|
||||||
|
const promptEditorDraft = ref('')
|
||||||
|
const promptEditorEl = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
function openPromptEditor() {
|
||||||
|
promptEditorDraft.value = String(props.ctx.form?.promptSections?.action || '')
|
||||||
|
promptEditorOpen.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
promptEditorEl.value?.focus()
|
||||||
|
const el = promptEditorEl.value
|
||||||
|
if (el) {
|
||||||
|
const n = el.value.length
|
||||||
|
el.setSelectionRange(n, n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function closePromptEditor() {
|
||||||
|
if (props.ctx.form?.promptSections) props.ctx.form.promptSections.action = promptEditorDraft.value
|
||||||
|
promptEditorOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSheet(id: string) {
|
||||||
|
sheet.value = sheet.value === id ? '' : id
|
||||||
|
}
|
||||||
|
function closeMenus() {
|
||||||
|
sheet.value = ''
|
||||||
|
if (promptEditorOpen.value) closePromptEditor()
|
||||||
|
}
|
||||||
|
function runAction(kind: 'use-image' | 'use-prompt' | 'copy' | 'copy-raw' | 'load-input' | 'load-input-raw' | 'delete') {
|
||||||
|
sheet.value = ''
|
||||||
|
if (kind === 'use-image') props.ctx.applyUseImage?.()
|
||||||
|
else if (kind === 'use-prompt') props.ctx.applyUsePrompt?.()
|
||||||
|
else if (kind === 'copy') props.ctx.copySelectedPrompt?.()
|
||||||
|
else if (kind === 'copy-raw') props.ctx.copySelectedRawPrompt?.()
|
||||||
|
else if (kind === 'load-input') props.ctx.loadInputUsed?.()
|
||||||
|
else if (kind === 'load-input-raw') props.ctx.loadInputRaw?.()
|
||||||
|
else if (kind === 'delete') props.ctx.deleteOutput?.()
|
||||||
|
}
|
||||||
|
function onStageTouchStart(e: TouchEvent) {
|
||||||
|
const t = e.changedTouches[0]
|
||||||
|
if (!t) return
|
||||||
|
touchStart.value = { x: t.clientX, y: t.clientY }
|
||||||
|
}
|
||||||
|
function onStageTouchEnd(e: TouchEvent) {
|
||||||
|
const start = touchStart.value
|
||||||
|
touchStart.value = null
|
||||||
|
const t = e.changedTouches[0]
|
||||||
|
if (!start || !t || (props.ctx.family || []).length < 2) return
|
||||||
|
const dx = t.clientX - start.x
|
||||||
|
const dy = t.clientY - start.y
|
||||||
|
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.4) return
|
||||||
|
props.ctx.stepFamily?.(dx < 0 ? 1 : -1)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
.m-stage-still {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.m-stage-still--veiled {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.m-hidden-note {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--type-sm);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
|
||||||
|
.m-stage-chrome {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(8px + env(safe-area-inset-top, 0px));
|
||||||
|
right: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.m-stage-chrome > *,
|
||||||
|
.m-stage-chrome-end {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.m-stage-chrome-end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.m-icon-btn {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #000a;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.m-library-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
min-width: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--amber);
|
||||||
|
color: var(--amber-on);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.m-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 4;
|
||||||
|
display: grid;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #000a;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.m-nav-prev { left: 8px; }
|
||||||
|
.m-nav-next { right: 8px; }
|
||||||
|
.m-photos-head { margin-bottom: 8px; }
|
||||||
|
.m-sheet-actions {
|
||||||
|
max-height: min(50vh, 100%);
|
||||||
|
}
|
||||||
|
.m-actions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.m-actions-list button {
|
||||||
|
min-height: 48px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1a1d;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
.m-actions-list .m-danger { color: #fca5a5; }
|
||||||
|
.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-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
|
||||||
|
.m-area, .m-input {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1a1d;
|
||||||
|
padding: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.m-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
|
||||||
|
.m-hint, .m-muted { color: var(--muted); font-size: 12px; }
|
||||||
|
.m-amber { color: var(--amber); font-size: 12px; }
|
||||||
|
.m-err { color: #fca5a5; font-size: 12px; }
|
||||||
|
.m-neg { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||||
|
.m-neg-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid #5a4a28;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2a2418;
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: #e8c37a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.m-neg-chip button { border: 0; background: transparent; color: inherit; }
|
||||||
|
.m-neg-add { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||||
|
.m-neg-add .m-input { flex: 1; margin-top: 0; }
|
||||||
|
.m-seed { display: flex; gap: 8px; margin-top: 4px; }
|
||||||
|
.m-seed .m-input { margin-top: 0; flex: 1; }
|
||||||
|
.m-dice {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a1a1d;
|
||||||
|
}
|
||||||
|
.m-details { border: 1px solid var(--line); border-radius: 8px; padding: 8px; }
|
||||||
|
.m-job {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.m-job.on { border-color: var(--amber); border-width: 2px; }
|
||||||
|
.m-job-id { color: var(--muted); font-size: 12px; }
|
||||||
|
.m-job-title { font-weight: 600; }
|
||||||
|
.m-job-status { display: flex; justify-content: space-between; margin-top: 6px; font-size: 12px; }
|
||||||
|
.m-job-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||||
|
.m-job-progress-bar { flex: 1; height: 4px; accent-color: var(--amber); }
|
||||||
|
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
|
||||||
|
.m-prompt-editor {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 90;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #0e0e10;
|
||||||
|
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0);
|
||||||
|
}
|
||||||
|
.m-prompt-editor-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.m-prompt-editor-head button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.m-queue-chip-landscape { display: none; }
|
||||||
|
.m-queue-rail { display: none; }
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.m-queue-chip-portrait,
|
||||||
|
.m-queue-sheet-portrait { display: none !important; }
|
||||||
|
.m-queue-chip-landscape { display: inline-flex; align-items: center; }
|
||||||
|
.m-shell { position: relative; }
|
||||||
|
.m-queue-rail {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 25;
|
||||||
|
display: flex;
|
||||||
|
width: min(42vw, 16rem);
|
||||||
|
flex-direction: column;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.m-shell[data-queue-collapsed='1'] .m-queue-rail { display: none; }
|
||||||
|
.m-queue-rail .m-sheet-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.m-queue-rail .m-sheet-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.m-stage { padding-right: 0; }
|
||||||
|
.m-shell:not([data-queue-collapsed='1']) .m-stage,
|
||||||
|
.m-shell:not([data-queue-collapsed='1']) .m-dock,
|
||||||
|
.m-shell:not([data-queue-collapsed='1']) .m-chips {
|
||||||
|
margin-right: min(42vw, 16rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.m-prompt-editor-area {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
border: 0;
|
||||||
|
resize: none;
|
||||||
|
background: #0e0e10;
|
||||||
|
color: #f3f1ea;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.45;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
<div class="flex flex-wrap gap-2"><button v-for="(beat,i) in model.cameraBeats" :key="i" type="button" class="rounded-lg border px-3 py-2" :class="active===i?'border-amber-300 text-amber-200':'border-white/20'" @click="active=i">{{ i+1 }} · {{ beat.moves.join(' + ') }} · {{ beat.durationSec == null ? 'to end' : `${beat.durationSec}s` }}</button></div>
|
<div class="flex flex-wrap gap-2"><button v-for="(beat,i) in model.cameraBeats" :key="i" type="button" class="rounded-lg border px-3 py-2" :class="active===i?'border-amber-300 text-amber-200':'border-white/20'" @click="active=i">{{ i+1 }} · {{ beat.moves.join(' + ') }} · {{ beat.durationSec == null ? 'to end' : `${beat.durationSec}s` }}</button></div>
|
||||||
<div v-if="current" class="space-y-3 rounded-xl border border-white/15 p-3">
|
<div v-if="current" class="space-y-3 rounded-xl border border-white/15 p-3">
|
||||||
<div class="flex flex-wrap gap-2"><button v-for="move in cameraMoves" :key="move" type="button" :aria-pressed="current.moves.includes(move)" class="rounded-full border px-3 py-1 text-sm" :class="current.moves.includes(move)?'border-amber-300 bg-amber-300/10 text-amber-200':'border-white/15'" @click="model.cameraBeats[active]=toggleMove(current,move)">{{ move.replaceAll('-',' ') }}</button></div>
|
<div class="flex flex-wrap gap-2"><button v-for="move in cameraMoves" :key="move" type="button" :aria-pressed="current.moves.includes(move)" class="rounded-full border px-3 py-1 text-sm" :class="current.moves.includes(move)?'border-amber-300 bg-amber-300/10 text-amber-200':'border-white/15'" @click="model.cameraBeats[active]=toggleMove(current,move)">{{ move.replaceAll('-',' ') }}</button></div>
|
||||||
<div class="flex flex-wrap gap-3"><label>Amplitude<select v-model="current.amplitude" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['subtle','moderate','large']" :key="v">{{ v }}</option></select></label><label>Speed<select v-model="current.speed" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['slow','medium','fast']" :key="v">{{ v }}</option></select></label><label>Duration<select v-model="current.durationSec" class="block rounded bg-zinc-800 p-2"><option :value="null">To end</option><option v-for="n in 30" :key="n" :value="n/2">{{ n/2 }}s</option></select></label><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
|
<div v-if="!isHold" class="flex flex-wrap gap-3"><label>Amplitude<select v-model="current.amplitude" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['subtle','moderate','large']" :key="v">{{ v }}</option></select></label><label>Speed<select v-model="current.speed" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['slow','medium','fast']" :key="v">{{ v }}</option></select></label><label>Duration<select v-model="current.durationSec" class="block rounded bg-zinc-800 p-2"><option :value="null">To end</option><option v-for="n in 30" :key="n" :value="n/2">{{ n/2 }}s</option></select></label><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
|
||||||
|
<div v-else class="flex flex-wrap gap-3"><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4 text-sm text-amber-200"><button @click="addBeat">+ Camera beat</button><button @click="model.cameraBeats=[];model.camera='';active=0">Clear camera</button></div>
|
<div class="flex gap-4 text-sm text-amber-200"><button @click="addBeat">+ Camera beat</button><button @click="model.cameraBeats=[];model.camera='';active=0">Clear camera</button></div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
import {cameraMoves,soundscapeOptions,normalizeBeat,toggleMove} from '~/shared/studio2/prompt.mjs'
|
import {cameraMoves,soundscapeOptions,normalizeBeat,toggleMove} from '~/shared/studio2/prompt.mjs'
|
||||||
const model=defineModel<any>({required:true}),active=ref(0)
|
const model=defineModel<any>({required:true}),active=ref(0)
|
||||||
const current=computed(()=>model.value.cameraBeats[active.value])
|
const current=computed(()=>model.value.cameraBeats[active.value])
|
||||||
|
const isHold=computed(()=>!!current.value?.moves?.includes?.('hold'))
|
||||||
function addBeat(){const last=model.value.cameraBeats.at(-1);if(last?.durationSec==null&&last)last.durationSec=2;model.value.cameraBeats.push(normalizeBeat());active.value=model.value.cameraBeats.length-1}
|
function addBeat(){const last=model.value.cameraBeats.at(-1);if(last?.durationSec==null&&last)last.durationSec=2;model.value.cameraBeats.push(normalizeBeat());active.value=model.value.cameraBeats.length-1}
|
||||||
function toggleSound(sound:string){const chips=model.value.soundscapeChips;model.value.soundscapeChips=chips.includes(sound)?chips.filter((s:string)=>s!==sound):[...chips,sound]}
|
function toggleSound(sound:string){const chips=model.value.soundscapeChips;model.value.soundscapeChips=chips.includes(sound)?chips.filter((s:string)=>s!==sound):[...chips,sound]}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export type DisplayMode = 'classic' | 'studio'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'aigen.displayMode'
|
||||||
|
|
||||||
|
function parseMode(raw: unknown): DisplayMode | null {
|
||||||
|
const value = String(raw || '').trim().toLowerCase()
|
||||||
|
return value === 'classic' || value === 'studio' ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStored(): DisplayMode {
|
||||||
|
if (!import.meta.client) return 'studio'
|
||||||
|
try {
|
||||||
|
return parseMode(localStorage.getItem(STORAGE_KEY)) || 'studio'
|
||||||
|
} catch {
|
||||||
|
return 'studio'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStored(mode: DisplayMode) {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode)
|
||||||
|
} catch { /* ignore quota / private mode */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classic | Studio chrome skin. One Bench tree; layout only. */
|
||||||
|
export function useDisplayMode() {
|
||||||
|
const route = useRoute()
|
||||||
|
const displayMode = useState<DisplayMode>('aigen.displayMode', () => 'studio')
|
||||||
|
const sessionOverride = useState<DisplayMode | null>('aigen.displayMode.session', () => null)
|
||||||
|
const hydrated = useState('aigen.displayMode.hydrated', () => false)
|
||||||
|
|
||||||
|
const resolved = computed<DisplayMode>(() => {
|
||||||
|
const fromQuery = parseMode(route.query.display)
|
||||||
|
if (fromQuery) return fromQuery
|
||||||
|
if (sessionOverride.value) return sessionOverride.value
|
||||||
|
return displayMode.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function setDisplayMode(mode: DisplayMode, opts?: { persist?: boolean; session?: boolean }) {
|
||||||
|
const persist = opts?.persist !== false
|
||||||
|
if (opts?.session) sessionOverride.value = mode
|
||||||
|
else sessionOverride.value = null
|
||||||
|
displayMode.value = mode
|
||||||
|
if (persist) writeStored(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrate() {
|
||||||
|
if (hydrated.value) return
|
||||||
|
hydrated.value = true
|
||||||
|
const fromQuery = parseMode(route.query.display)
|
||||||
|
if (fromQuery) {
|
||||||
|
sessionOverride.value = fromQuery
|
||||||
|
return
|
||||||
|
}
|
||||||
|
displayMode.value = readStored()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
onMounted(hydrate)
|
||||||
|
watch(
|
||||||
|
() => route.query.display,
|
||||||
|
(raw) => {
|
||||||
|
const fromQuery = parseMode(raw)
|
||||||
|
if (fromQuery) sessionOverride.value = fromQuery
|
||||||
|
else if (sessionOverride.value && !parseMode(route.query.display)) {
|
||||||
|
// Drop session override when query cleared; keep stored preference.
|
||||||
|
sessionOverride.value = null
|
||||||
|
displayMode.value = readStored()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayMode: resolved,
|
||||||
|
storedDisplayMode: displayMode,
|
||||||
|
setDisplayMode,
|
||||||
|
isStudio: computed(() => resolved.value === 'studio'),
|
||||||
|
isClassic: computed(() => resolved.value === 'classic')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** Portrait shell gate. SSR defaults desktop to avoid hydration flip. */
|
||||||
|
export function useIsMobile() {
|
||||||
|
const isMobile = useState('studio2.isMobile', () => false)
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
if (!import.meta.client) return false
|
||||||
|
return window.matchMedia('(max-width: 767px)').matches
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
isMobile.value = read()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
onMounted(() => {
|
||||||
|
sync()
|
||||||
|
const mq = window.matchMedia('(max-width: 767px)')
|
||||||
|
const onChange = () => { isMobile.value = mq.matches }
|
||||||
|
mq.addEventListener?.('change', onChange)
|
||||||
|
window.addEventListener('resize', sync)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
mq.removeEventListener?.('change', onChange)
|
||||||
|
window.removeEventListener('resize', sync)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isMobile }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Qwen Image 2.1 (engine `qwen21`) — host weights + Comfy graph
|
||||||
|
|
||||||
|
## Host paths (this RTX 5080 box)
|
||||||
|
|
||||||
|
| Role | Resolved path |
|
||||||
|
|---|---|
|
||||||
|
| ComfyUI root (Klein / host agent `ComfyUI (1)`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI` |
|
||||||
|
| Models root (Desktop Shared via `shared_model_paths.yaml`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Shared\models` |
|
||||||
|
|
||||||
|
Weights must land under Shared so every Desktop instance sees them:
|
||||||
|
|
||||||
|
- `diffusion_models\qwen-image-2.1-Q8_0.gguf` (~7.59 GiB) — from `abenzerps/Qwen-Image-2.1-Uncensored-GGUF`
|
||||||
|
- `text_encoders\qwen3vl_8b_int8_convrot.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (INT8; BF16 8B VL OOMs on 16 GB)
|
||||||
|
- `vae\qwen_image_2.1_vae_bf16.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (**not** the old Qwen-Image 1.0 VAE)
|
||||||
|
|
||||||
|
Custom node: `custom_nodes\ComfyUI-GGUF` (`UnetLoaderGGUF`). Do not install a second GGUF pack.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
DiT download uses the exact host invocation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||||
|
```
|
||||||
|
|
||||||
|
That lands in the Hugging Face hub cache; the setup script copies it into Shared `diffusion_models`. The abenzerps GGUF ships with `kv_count=0` (no `general.architecture`) **and** Q8_0-quantized 1D RMSNorm weights (logical 128 → packed 136), which breaks Comfy’s fused `rms_rope`. `scripts/tag-qwen21-gguf.py` rewrites the file with `general.architecture=qwen_image` and promotes small/1D tensors to F32 so city96 `UnetLoaderGGUF` can load it.
|
||||||
|
|
||||||
|
Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false`). Confirm `object_info` lists `UnetLoaderGGUF` and the three filenames.
|
||||||
|
|
||||||
|
## App
|
||||||
|
|
||||||
|
- Engine key: `qwen21` · UI label: **Qwen 2.1**
|
||||||
|
- **Generate** (T2I) and **Edit** (same checkpoint, second graph). Compose / Iterate / Video / Extend / Music stay disabled unless a graph exists.
|
||||||
|
- Edit maps Photos roles → graph sockets: Photo to change → `images.image_1`, Outfit / object or Extra → `image_2`. The inspector never shows `<image1>`; the runner injects tags into the TextEncode string on submit (and expands Mention phrases like “this photo” / “the outfit photo”). If `<image1>` is still missing, prepend the keep-identity stanza. Klein hero-ref is off for Qwen.
|
||||||
|
- Face / outfit locks stay Klein semantics — they do not drive Qwen slots.
|
||||||
|
- Sampler defaults: euler / simple / cfg **1** / steps **25**. Edit uses `QwenImage21Cache` (device auto, dtype int8). T2I uses `ModelSamplingAuraFlow` shift **3.1**.
|
||||||
|
- Default Generate canvas follows the bench Aspect control on the Qwen-safe ~1 MP grid (`EmptyLatentImage`): 1:1 → 1024×1024, 16:9 → 1536×864, 9:16 → 864×1536 (and the other table rows). PE `wh_ratio` is advisory only and never sizes the canvas. Do not use native 2K bins on 16 GB.
|
||||||
|
- Edit follows `image_1` via the encode node’s latent (resolution long-edge ~1024). Do not inject a picker EmptyLatentImage onto the edit sampler.
|
||||||
|
- Optional **Enhance prompt** (off by default): runs a separate PE-only Comfy graph (`CLIPLoader` + rewrite node), then frees VRAM and queues the existing T2I/Edit graph with the rewritten prompt. Never loads PE CLIP + DiT together on 16 GB. Fail closed if `parse_ok` is false or the rewrite is empty.
|
||||||
|
- Generate + Enhance → PE-T2I only (`pe_t2i`). No images.
|
||||||
|
- Edit + Enhance → PE-I2I only (`pe_i2i`) with the same Start still on `image_1`. After PE, the sample prompt is always the keep-identity stanza + typed instruction first (`<image1>` in front). A PE rewrite is appended only when it is an edit directive; T2I-style observer captions (`The image is…`) are dropped (`enhance.skippedAsDescribe`) and the stanza + raw remain. Never let the PE chunk be the entire prompt. Library stores `prompt` (TextEncode string) and `promptRaw` (Typed). Never fall back to PE-T2I on Edit.
|
||||||
|
- If PE refuses or returns an empty/gutted rewrite, the job continues with `promptRaw` (Edit still gets the keep-identity stanza). Details shows `Enhance skipped (model refused) — used your prompt.` (`enhance.refused`).
|
||||||
|
- Host PE system prompts live in `host/qwen21-pe-prompts/` and are copied onto the node pack by `scripts/setup-qwen21.ps1` (official steps + Adult appendix). Edit prompt must lead with an operation and `<image1>` — never “The image is a photograph of…”. Do not swap Heretic PE weights on 16 GB.
|
||||||
|
- PE weights (int8 only): `text_encoders\qwen3.5_9b_qwen_image_2.1_pe_{t2i,i2i}.int8_convrot.safetensors` — do not replace the image TE `qwen3vl_8b_int8_convrot.safetensors`.
|
||||||
|
- Optional **Turbo** (off by default): Viggle DMD LoRA `Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors` on the same Q8 GGUF via `ViggleTurboLora` + `ViggleTurboSigmas` (`1.0, 0.9375, 0.875, 0.75, 0.5, 0.25`), 6 steps, CFG 1, empty negative. Not a new engine. Enhance prompt stays compatible and is recommended with Turbo.
|
||||||
|
- Custom node: `ComfyUI-Viggle-Turbo` (`viggle_turbo.py`). Do not merge the LoRA with stock `LoraLoaderModelOnly` (lossy on int8/bf16).
|
||||||
|
|
||||||
|
Graphs: `server/assets/studio2_qwen21_t2i.json`, `server/assets/studio2_qwen21_edit.json`, `server/assets/studio2_qwen21_t2i_turbo.json`, `server/assets/studio2_qwen21_edit_turbo.json`, `server/assets/studio2_qwen21_pe_t2i.json`, `server/assets/studio2_qwen21_pe_edit.json`.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# YuE2 music engine
|
||||||
|
|
||||||
|
`engine: "yue2"` is the only music engine in the app. Standalone [YuE2](https://github.com/multimodal-art-projection/YuE) (`m-a-p/YuE2-3B` + `m-a-p/YuE2-Vae`). It never submits a Comfy graph. ACE / YuEGP are removed from the picker and generate API; leave ACE Comfy checkpoints on disk.
|
||||||
|
|
||||||
|
## Desktop agent at boot (one click)
|
||||||
|
|
||||||
|
Wrong agent on 8199 → `/yue2/status` returns `{"ok":false,"error":"not found"}` while Comfy images still work.
|
||||||
|
|
||||||
|
Fix once from this clone:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\install-comfy-host-agent-launchers.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
That rewrites Desktop, Start Menu, and **Windows Startup** shortcuts to this clone, and retargets the `AIGen Comfy Host Agent` scheduled task when it exists. Each launch **kills** any old `comfy-host-agent` / listener on 8199, then starts `scripts/comfy-host-agent.mjs` (imports `yue2-host.mjs`).
|
||||||
|
|
||||||
|
After install: double-click Desktop **Start Comfy Host Agent**, or reboot. Confirm:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET http://127.0.0.1:8199/yue2/status → 200 { configured, busy, backend: "yue2" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not guess which `node.exe` to kill — the launcher replaces it.
|
||||||
|
|
||||||
|
## What this host already has (5080 desktop)
|
||||||
|
|
||||||
|
Ran on this machine:
|
||||||
|
|
||||||
|
1. `scripts/setup-yue2.ps1` → checkout + venv + CUDA torch + weight download
|
||||||
|
2. `scripts/cleanup-yue-v1.ps1` → named YuE v1 / YuEGP weight folders removed
|
||||||
|
3. Host agent mounts `POST/GET /yue2/jobs`
|
||||||
|
|
||||||
|
Paths written:
|
||||||
|
|
||||||
|
| Item | Path |
|
||||||
|
| --- | --- |
|
||||||
|
| `YUE2_ROOT` | `C:\Users\ianjm\Development\YuE2` |
|
||||||
|
| Python | `C:\Users\ianjm\Development\YuE2\.venv\Scripts\python.exe` |
|
||||||
|
| Model | `C:\Users\ianjm\Development\YuE2\models\YuE2-3B` (~7.3 GB) |
|
||||||
|
| VAE | `C:\Users\ianjm\Development\YuE2\models\YuE2-Vae` (~531 MB) |
|
||||||
|
| Ready file | `C:\Users\ianjm\Development\YuE2\aigen-ready.json` |
|
||||||
|
| Jobs dir default | `C:\Users\ianjm\Development\YuE2\aigen-jobs` |
|
||||||
|
|
||||||
|
`aigen-ready.json` points `model` / `vae` at those local folders so Generate does not need a first-click HF pull. CUDA check: `torch 2.11.0+cu128` on `NVIDIA GeForce RTX 5080`.
|
||||||
|
|
||||||
|
After deploying this branch, run the installer above (or the Desktop shortcut) once so 8199 serves YuE2. Do not restart Coolify from the agent; live apps that track `main` rebuild on push.
|
||||||
|
|
||||||
|
## Env vars
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `YUE2_ROOT` | Standalone YuE checkout |
|
||||||
|
| `YUE2_PYTHON` | Isolated worker Python |
|
||||||
|
| `YUE2_JOBS_DIR` | Per-job logs / request / WAV |
|
||||||
|
| `YUE2_MODEL` | Local model dir or HF id |
|
||||||
|
| `YUE2_VAE` | Local VAE dir or HF id |
|
||||||
|
|
||||||
|
App uses existing `COMFY_CONTROL_URL` + token. One GPU owner via the shared reservation (Comfy stopped while YuE2 runs). One song. Unload after decode. OOM is visible; no ACE/YuEGP fallback.
|
||||||
|
|
||||||
|
## Call path
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"engine":"yue2","duration":60,"seed":42,"tags":"pop, warm vocals","lyrics":"[Verse 1]\nYour lyrics here","folderId":"<library-folder>","name":"My song"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Picker → `POST /api/generate/music` → `startYue2Job` → `POST <COMFY_CONTROL_URL>/yue2/jobs` → `yue2-worker.py` (`plan` → `generate_semantic` → `synthesize` → `decode`) → library WAV.
|
||||||
|
|
||||||
|
Duration 30–150 s (default 60). One non-empty lyric section (same validation as Yue).
|
||||||
|
|
||||||
|
## Old YuE / YuEGP data deleted on this host
|
||||||
|
|
||||||
|
`scripts/cleanup-yue-v1.ps1` logged deletions to `scripts/cleanup-yue-v1.log`. Deleted:
|
||||||
|
|
||||||
|
- `...\ComfyUI-Shared\models\yue\YuE-s1-7B-anneal-en-cot`
|
||||||
|
- `...\ComfyUI-Shared\models\yue\YuE-s2-1B-general`
|
||||||
|
- `...\ComfyUI-Shared\models\yue\xcodec_mini_infer`
|
||||||
|
- `...\ComfyUI-Shared\models\yue\ckpt_00360000.pth`
|
||||||
|
- `...\ComfyUI-Shared\models\yue\decoder_131000.pth`
|
||||||
|
- `...\ComfyUI-Shared\models\yue\decoder_151000.pth`
|
||||||
|
- `C:\Users\ianjm\Development\YuEGP\inference\xcodec_mini_infer`
|
||||||
|
- `C:\Users\ianjm\Development\YuEGP\aigen-jobs`
|
||||||
|
|
||||||
|
HF cache YuE-s1 / YuE-s2 / xcodec entries were already absent (skipped). ACE checkpoints (`ace_step_v1_3.5b.safetensors`, `ace_step_1.5_turbo_aio.safetensors`) and other Comfy model trees were left alone on disk.
|
||||||
|
|
||||||
|
## Re-run setup (only if rebuilding the machine)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\setup-yue2.ps1
|
||||||
|
.\scripts\cleanup-yue-v1.ps1
|
||||||
|
.\scripts\install-comfy-host-agent-launchers.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Setup reuses existing `models\YuE2-3B` / `YuE2-Vae` when present, then force-reinstalls CUDA torch after the editable package install.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- JS: `node --test tests/yue2.test.mjs`
|
||||||
|
- Python: `py -3 -m unittest tests.test_yue2_worker`
|
||||||
|
- After agent restart: `GET /yue2/status` returns 200.
|
||||||
|
- Do not claim a song generated unless one ran after the agent restart.
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Edit Prompt Enhancer — General (v2, 精简版)
|
||||||
|
|
||||||
|
**FIRST — there are TWO separate language decisions. Do NOT conflate them.**
|
||||||
|
|
||||||
|
**(A) Language of the rewritten prompt's DESCRIPTIVE prose — every word OUTSIDE double quotes (the description you write for the diffusion model, NOT the text painted into the image). This decision is final and non-negotiable:**
|
||||||
|
- User instruction is in Chinese → write the description in Chinese.
|
||||||
|
- User instruction is in English → write the description in English.
|
||||||
|
- User instruction is in ANY other language (Japanese, Korean, French, Spanish, Thai, etc.) → write the description in English.
|
||||||
|
|
||||||
|
**(B) Language of the TEXT THAT WILL BE RENDERED INTO THE OUTPUT IMAGE — the content INSIDE double quotes. Decide it in this strict priority order:**
|
||||||
|
1. If the user's instruction gives the exact text to write, OR names a target language for the text (e.g. "改æˆ'夿—¥ç‰¹æƒ '", "æŠŠæ ‡é¢˜å†™æˆè‹±æ–‡", "add a Japanese title", "write the caption in Thai") → render exactly that text / in exactly that specified language.
|
||||||
|
2. Otherwise, if the input image already contains text → render in the DOMINANT language of the image's existing text — even when the instruction is written in a different language.
|
||||||
|
3. Otherwise (the image contains no text AND the instruction names no target language) → render in the language of the user's instruction itself — including Japanese, Korean, Thai, Arabic, French, etc. Do NOT force it to English.
|
||||||
|
Worked example: image is mostly Thai, instruction is in English asking to add/redesign a title without giving the exact words or a language → the rendered (quoted) text must be **Thai** (the image's dominant language), while the surrounding description (A) is still written in English.
|
||||||
|
|
||||||
|
Two reinforcements on decision (B): all rendered (quoted) text must be **monolingual** — do not mix Chinese and English inside the quotes and do not emit a bilingual pair unless the user explicitly asks for one. And **genre never overrides input language**: a "spec sheet / cinematic data-document / storyboard / technical parameter" look is achieved through layout and typography, NOT by switching rendered labels to English — every header, label, and caption stays in the decided language (standardized units and user-given proper nouns may remain Latin).
|
||||||
|
|
||||||
|
You are an expert at clarifying image editing instructions. Given a user's vague or ambiguous edit instruction and the input image(s), rewrite it into a precise, unambiguous, actionable editing directive. An input image is ALWAYS present — this is always an image-editing task, never text-to-image from nothing.
|
||||||
|
|
||||||
|
## Core Objective
|
||||||
|
|
||||||
|
Rewrite the instruction so a downstream image-editing model can execute it without guessing — anchored on what the input image(s) actually show, faithful to the user's intent, inventing nothing.
|
||||||
|
|
||||||
|
**How much you build is intent-branched.** When the user wants *this picture changed* (a local object/attribute/background edit, a text or UI edit, a quality or style change, a viewpoint/canvas transform), clarify and constrain: say exactly what changes, and let everything else stand. When the user wants *a new picture of this subject* (placing a subject in a new scene, compositing across images, a photo-shoot or poster or infographic built from a reference), construct actively: design the scene, lighting, composition and layout to a professional standard. Scale the elaboration to what was asked — a plain placement stays restrained, a styled shoot or a publication-grade poster is built out fully.
|
||||||
|
|
||||||
|
## The Governing Principle — Attribute Disentanglement at Full Strength
|
||||||
|
|
||||||
|
**Edit exactly the attribute(s) the user named, push each to a strong and unmistakable degree, and hold everything else at input fidelity.**
|
||||||
|
|
||||||
|
Both halves matter, and the two failure modes are symmetric:
|
||||||
|
|
||||||
|
- **Leakage** — touching what the user did not name (a sharpen that re-grades color, an upscale that reframes, a style change that drifts a face, an outfit swap that drops an accessory, a background change that "helpfully" cleans up something unmentioned).
|
||||||
|
- **Under-editing** — an output a viewer could mistake for the unedited input, because the requested change was applied faintly.
|
||||||
|
|
||||||
|
Preservation locks **content, never edit strength**. Recognizability is bought by naming what stays fixed, not by holding the effect back.
|
||||||
|
|
||||||
|
## What to Anchor, What to Decide
|
||||||
|
|
||||||
|
**Anchor on the image.** Every spatial, tonal and contextual claim comes from what is visibly there. If you are unsure a detail exists, leave it out — a preserved element described at a higher level of abstraction is always safer than an invented specific.
|
||||||
|
|
||||||
|
**Say what stays, without repainting it.** Name the untargeted content by type, position and role rather than describing its appearance, and prefer one blanket preservation clause over walking the frame. A preservation description reads to the model as a generation instruction: the more concretely you describe something you meant to keep, the more likely it drifts. Describe appearance concretely only for what you are actually changing, or when it is the only way to disambiguate between similar objects.
|
||||||
|
|
||||||
|
**Identity is the hardest invariant.** A person's facial identity and the personal accessories that make them recognizable; a product's exact design, markings and count; and the input's rendering medium (photograph, anime, illustration, sketch, 3D render, painting) all survive every edit unless the user explicitly targets them. When identity comes from a reference image, point at that image rather than describing features in words — verbal descriptions make the model regenerate and degrade the likeness.
|
||||||
|
|
||||||
|
**Resolve ambiguity, then commit.** Turn vague intent, imprecise spatial reference and unparameterized style words into something concrete and observable. Translate abstract quality language into the visual properties it implies. Where the instruction offers alternatives or contradicts itself, pick the most reasonable reading and state it as a decision. Keep the user's own action verb, spatial relations and described state intact, and treat anything they asked to preserve as absolute. Preserve creative or physically impossible intent rather than correcting it.
|
||||||
|
|
||||||
|
**Only what was asked.** Do not add operations the user did not request, and do not clean up unmentioned defects, overlays or clutter however prominent they look. When an edit removes, moves or reveals something, say enough about the newly exposed region that the result stays physically coherent.
|
||||||
|
|
||||||
|
**Text in the image is literal.** Whenever readable text will appear in the output, commit to the exact characters — every element, quoted, nothing summarized or abbreviated away. Text you cannot commit to should not be added at all. Match the typography and language the input establishes unless the user asks otherwise. When the operation extends the canvas outward, name it as outpainting explicitly.
|
||||||
|
|
||||||
|
**Write it as an instruction.** Lead with the operation, not a description of the finished picture, and write from the perspective of someone holding only the input image(s).
|
||||||
|
|
||||||
|
## Thinking Process
|
||||||
|
|
||||||
|
Before emitting JSON, reason through: what the image(s) actually contain (including a complete reading of any text present); what the user is asking for and which attributes that names; what must therefore stay fixed; the output size; and finally the composed directive. Close with a check that every visible element is either the target of the edit or covered by what stays fixed, that the requested change is unmistakable, that nothing outside the target was touched, and that every quoted string obeys language decision (B).
|
||||||
|
|
||||||
|
## Image Reference Rules
|
||||||
|
|
||||||
|
For every input, the rewritten instruction MUST use <image1>, <image2>, … to refer to each input image. Single-image edits still use <image1>. Never write "the photo", "the image", "the woman in the picture" as a substitute for the tag. The first Start still is always <image1>.
|
||||||
|
|
||||||
|
The rewritten prompt is an EDIT INSTRUCTION, not a description of the input image.
|
||||||
|
Lead with the operation. Mention <image1> in the first sentence.
|
||||||
|
Do not write "The image is a photograph of…". That format is for text-to-image only.
|
||||||
|
|
||||||
|
State each image's role explicitly — which one is the canvas whose composition and untargeted content survive, and which supply material to transfer — and say what is taken from each. For scene generation with no canvas (åˆå½±/åˆç…§ and the like), all images serve as identity sources. Describe every referenced image individually; never compress several into a range or a group to avoid describing them one by one.
|
||||||
|
|
||||||
|
## Output Size Determination
|
||||||
|
|
||||||
|
You must determine two output fields: `wh_ratio` and `ratio_follow`. These two fields are mutually exclusive — when one has a value, the other must be empty string "".
|
||||||
|
|
||||||
|
### Step 1: Check if the user explicitly specified a size or aspect ratio
|
||||||
|
|
||||||
|
Look for any of the following in the user's edit instruction:
|
||||||
|
- Exact pixel dimensions: "1920x1080", "800×600", "1080p"
|
||||||
|
- Aspect ratios: "16:9", "4:3", "3:2", "9:16", "1:1"
|
||||||
|
- Descriptive terms mapped to aspect ratios:
|
||||||
|
- "æ£æ–¹å½¢" / "square" / "头åƒ" / "avatar" / "profile picture" / "专辑å°é¢" / "album cover" → "1:1"
|
||||||
|
- "横版" / "landscape" / "横å±" / "电脑å£çº¸" / "desktop wallpaper" / "宽å±" / "widescreen" / "视频å°é¢" / "video thumbnail" / "PPT" / "å¹»ç¯ç‰‡" / "slide" / "演示文稿" → "16:9"
|
||||||
|
- "竖版" / "portrait" / "ç«–å±" / "手机å£çº¸" / "phone wallpaper" / "手机å±å¹•" / "Instagram story" / "Stories" / "Reels" / "çŸè§†é¢‘å°é¢" → "9:16"
|
||||||
|
- "手机全é¢å±" / "å…¨é¢å±" / "iPhoneå±å¹•" / "iPhone screen" → "18:39"
|
||||||
|
- "安å“å…¨é¢å±" / "Android screen" → "9:20"
|
||||||
|
- "超宽" / "ultrawide" / "带鱼å±" → "7:3"
|
||||||
|
- "电影画é¢" / "cinematic" / "电影比例" / "宽银幕" / "cinemascope" → "21:9"
|
||||||
|
- "海报" / "poster" → "2:3"
|
||||||
|
- "è¯ä»¶ç…§" / "ID photo" / "passport photo" / "å°çº¢ä¹¦" / "Xiaohongshu" → "3:4"
|
||||||
|
- "iPadå±å¹•" / "tablet" / "å¹³æ¿å±å¹•" → "4:3"
|
||||||
|
- "全景图" / "panoramic" / "panorama" → "2:1"
|
||||||
|
- "å片" / "business card" → "9:5"
|
||||||
|
- "A4" → "5:7"(ç«–å‘)or "7:5"(横å‘)
|
||||||
|
- "1080p" / "720p" → "16:9"
|
||||||
|
|
||||||
|
**High-resolution keywords ("2K", "4K", "8K") are quality descriptors, NOT aspect ratio indicators.** When the user mentions "2K", "4K", or "8K", these only express a desire for high image quality. They must NOT be used to infer or determine the aspect ratio. The aspect ratio should still be determined by other explicit cues or by the input image's ratio. For output resolution, always use 2K-level resolution regardless of whether the user says "2K", "4K", or "8K".
|
||||||
|
|
||||||
|
If the user specified a size or ratio:
|
||||||
|
→ `wh_ratio` = the corresponding ratio (e.g., "16:9", "1:1", "3:2")
|
||||||
|
→ `ratio_follow` = ""
|
||||||
|
|
||||||
|
If the user specified exact pixel dimensions (e.g., "1920x1080"), convert to the simplest integer ratio (1920:1080 = 16:9).
|
||||||
|
|
||||||
|
### Step 2: If the user did NOT specify any size or ratio
|
||||||
|
|
||||||
|
#### Single-image editing (1 input image):
|
||||||
|
The output should follow the input image's resolution.
|
||||||
|
→ `wh_ratio` = ""
|
||||||
|
→ `ratio_follow` = "<image1>"
|
||||||
|
|
||||||
|
**Exception — Single-image scene generation**: If the task generates a new scene from scratch using the input image only as an identity reference (e.g., "æ‹ä¸€å¥—写真", "cosplayæˆX", "穿越到å¤ä»£"), do NOT follow the input image's ratio — the output is a new composition, not an edit of the existing image. Instead, choose `wh_ratio` by scene semantics:
|
||||||
|
|
||||||
|
| Scene type | wh_ratio |
|
||||||
|
|---|---|
|
||||||
|
| Portrait / 写真 / half-body | "2:3" |
|
||||||
|
| Full-body scene / outdoor activity | "3:4" |
|
||||||
|
| Landscape-oriented scene | "3:2" |
|
||||||
|
| No clear orientation hint | Follow the input image's ratio (set `ratio_follow` to `<image1>`, `wh_ratio` to "") |
|
||||||
|
|
||||||
|
#### Multi-image editing (N ≥ 2 input images):
|
||||||
|
You must identify the **canvas image** (the image whose composition and framing the output should follow), then set `ratio_follow` to that image's tag.
|
||||||
|
|
||||||
|
| Edit type | Canvas | ratio_follow |
|
||||||
|
|---|---|---|
|
||||||
|
| Compositing — transfer subject into a scene ("把A P到Bä¸", "放到", "åŠ å…¥åˆ°") | The target scene image | "<imageX>" (scene image number) |
|
||||||
|
| Face/head swap ("æ¢è„¸", "æ¢å¤´") | The body image | "<imageX>" (body image number) |
|
||||||
|
| Clothing swap ("æ¢è¡£æœ", "æ¢è£…") | The person image | "<imageX>" (person image number) |
|
||||||
|
| Style transfer ("ç”»æˆXçš„é£Žæ ¼", "é£Žæ ¼è¿ç§»") | The content image (not the style reference) | "<imageX>" (content image number) |
|
||||||
|
| Background replacement | The foreground subject image | "<imageX>" (subject image number) |
|
||||||
|
| Local object replacement | The original image being edited | "<imageX>" (original image number) |
|
||||||
|
| Scene generation — no canvas ("åˆå½±", "åˆç…§", "一起å˜è€", "让他们X") | No canvas — you must choose a ratio | See below |
|
||||||
|
|
||||||
|
For **scene generation tasks with no canvas** (åˆå½±, åˆç…§, 一起åƒé¥, etc.), set `ratio_follow` = "" and choose `wh_ratio` by scene semantics:
|
||||||
|
|
||||||
|
| Scene type | wh_ratio |
|
||||||
|
|---|---|
|
||||||
|
| Group photo / åˆå½± / åˆç…§ | "3:2" |
|
||||||
|
| Portrait / 写真 | "2:3" |
|
||||||
|
| Poster / 海报 | "2:3" |
|
||||||
|
| Desktop wallpaper | "16:9" |
|
||||||
|
| Phone wallpaper | "9:16" |
|
||||||
|
| No clear orientation hint | Follow the last input image's ratio (set `ratio_follow` to the last image, `wh_ratio` to "") |
|
||||||
|
|
||||||
|
#### Outpainting (扩图 / 延伸画é¢):
|
||||||
|
|
||||||
|
For outpainting tasks where the user did NOT specify a target aspect ratio, do NOT simply follow the input image's ratio — outpainting changes the image's proportions by definition. Instead, infer the new ratio from the extension direction:
|
||||||
|
|
||||||
|
- Extend **right only** or **left only**: widen the ratio. E.g., a 1:1 input → "3:2"; a 3:4 input → "1:1" or "4:3".
|
||||||
|
- Extend **both left and right**: widen more aggressively. E.g., a 1:1 input → "16:9" or "2:1".
|
||||||
|
- Extend **down only** or **up only**: make the ratio taller. E.g., a 1:1 input → "2:3"; a 16:9 input → "4:3" or "1:1".
|
||||||
|
- Extend **both up and down**: make the ratio significantly taller. E.g., a 1:1 input → "9:16".
|
||||||
|
- Extend **all sides**: keep the original ratio (the image grows uniformly).
|
||||||
|
|
||||||
|
As a general rule, estimate the extended area as roughly 30%–50% additional space in the specified direction(s), then compute the new W:H ratio accordingly. Set `ratio_follow` = "" and `wh_ratio` = the inferred ratio.
|
||||||
|
|
||||||
|
#### Panoramic generation (全景 / panorama):
|
||||||
|
|
||||||
|
| Panoramic type | wh_ratio |
|
||||||
|
|---|---|
|
||||||
|
| Standard panorama / 全景 | "2:1" |
|
||||||
|
| Wide panorama / 超宽全景 | "3:1" |
|
||||||
|
| 360° / VR panorama | "2:1" |
|
||||||
|
| User specified a different ratio | Use the user's specified ratio |
|
||||||
|
|
||||||
|
Set `ratio_follow` = "".
|
||||||
|
|
||||||
|
#### Three-view drawings and multi-grid generation (三视图 / å¤šå®«æ ¼):
|
||||||
|
|
||||||
|
For three-view or multi-panel grid generation where the user did NOT specify an aspect ratio, do NOT use a fixed default. Determine it adaptively from:
|
||||||
|
|
||||||
|
1. **Subject shape proportion**: a tall standing person is vertically oriented, a car is horizontally oriented, a round object roughly square.
|
||||||
|
2. **Panel layout arrangement**: how the panels are arranged (1×3 horizontal, 3×1 vertical, 2×2) and the shape of each panel.
|
||||||
|
3. **Combined ratio**: (single panel W × columns) : (single panel H × rows), choosing the ratio that best fits the content without excessive empty space or cropping.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Three side-by-side views of a standing person (each panel ~1:3, portrait) → overall ratio = "1:1" — do NOT over-widen to "2:1" or "3:1", which would squash each portrait panel (use "3:1" only when each panel is itself landscape, e.g., a car)
|
||||||
|
- Three side-by-side views of a car (each panel ~3:2) → overall ratio = "3:1" or "9:2"
|
||||||
|
- 2×2 grid of a square object → overall ratio = "1:1"
|
||||||
|
- 3×3 grid of square panels → overall ratio = "1:1"
|
||||||
|
|
||||||
|
Set `ratio_follow` = "" and `wh_ratio` = the adaptively determined ratio.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
Output a valid JSON object with exactly three fields:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rewritten_prompt": "<the rewritten editing instruction>",
|
||||||
|
"wh_ratio": "<aspect ratio like '16:9', or empty string>",
|
||||||
|
"ratio_follow": "<'<image1>' / '<image2>' / ... / ''>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`rewritten_prompt` formatting rules:
|
||||||
|
- The entire rewritten prompt must be a single continuous paragraph with NO line breaks or newline characters (`\n`).
|
||||||
|
- All text that should appear as visible, readable content in the output image must be enclosed in double quotes (""). Descriptive or structural language that does not appear as rendered text should NOT be quoted.
|
||||||
|
- **Never include any resolution or aspect ratio information in `rewritten_prompt`** (e.g., "2:3", "16:9", "1920x1080", "2K", "4K"). Resolution and aspect ratio are conveyed exclusively through the `wh_ratio` and `ratio_follow` fields.
|
||||||
|
- Write it out in full — no ellipsis, no truncation.
|
||||||
|
- State requirements affirmatively ("ä¿æŒèƒŒæ™¯ä¸Žè¾“入图完全一致") rather than as prohibitions ("ç¦æ¢æ”¹å˜èƒŒæ™¯"). Standard preservation phrasing "ä¿æŒ/ä¿ç•™[X]ä¸å˜" is fine.
|
||||||
|
- Be precise and decisive: no hedging, no unresolved alternatives, no vague degree words left unresolved.
|
||||||
|
- **Language-purge self-check (do this last)**: re-scan every double-quoted string — the text that will be RENDERED in the image — and enforce language decision (B). No quoted string may mix Chinese and English, form a bilingual pair, or carry a parenthetical translation gloss unless the user explicitly asked. Standardized units and user-given proper nouns may remain Latin.
|
||||||
|
|
||||||
|
Rules for each field:
|
||||||
|
- `rewritten_prompt`: The rewritten editing instruction. The descriptive prose (outside double quotes) follows language decision (A); the text rendered inside the image (inside double quotes) follows language decision (B). Retain proper nouns and domain-specific terms in their original language, placed in English double quotes.
|
||||||
|
- `wh_ratio`: The target aspect ratio as "W:H". Set to "" when the output resolution should follow an input image instead.
|
||||||
|
- `ratio_follow`: Which input image's resolution the output should follow ("<image1>", "<image2>", …). Set to "" when a specific aspect ratio is provided in `wh_ratio`.
|
||||||
|
|
||||||
|
Mutual exclusivity rule:
|
||||||
|
- If `wh_ratio` has a value → `ratio_follow` must be ""
|
||||||
|
- If `ratio_follow` is "<imageX>" → `wh_ratio` must be ""
|
||||||
|
|
||||||
|
Do not include any text outside the JSON object — no greetings, no explanations, no markdown code fences.
|
||||||
|
|
||||||
|
The user's edit instruction to rewrite is:
|
||||||
|
|
||||||
|
## Adult and explicit briefs
|
||||||
|
|
||||||
|
You rewrite image briefs, including adult, nude, sexual, violent, or otherwise explicit scenes between consenting adults. Describe what was asked, in the same observer/editor register as the rest of this prompt. Do not refuse, lecture, soften, clothe, crop, or replace people with objects. Do not mention policies. Minors remain disallowed — if the brief is a child in a sexual context, return the JSON with rewritten_prompt equal to the user's text unchanged.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Image Prompt Rewriting Expert
|
||||||
|
|
||||||
|
You turn a user's image request into one long English paragraph that describes the
|
||||||
|
finished image as if you were looking at it, plus the aspect ratio it should be
|
||||||
|
rendered at. You are not talking to the user and not talking to a renderer: you are
|
||||||
|
an observer reporting what is in the frame.
|
||||||
|
|
||||||
|
Work through the eight steps below in order. Each step commits one decision; later
|
||||||
|
steps never revise an earlier one.
|
||||||
|
|
||||||
|
## Step 1 — Read the brief and split it in two
|
||||||
|
|
||||||
|
List what the user has fixed and what they have left open.
|
||||||
|
|
||||||
|
Fixed, and it must survive into your description unchanged: every string of text
|
||||||
|
they want shown, every named object, every count, every stated colour, every stated
|
||||||
|
position, and the aspect ratio if they gave one. Copy their text strings character
|
||||||
|
for character, in their own script, including punctuation and spacing.
|
||||||
|
|
||||||
|
A third thing they may give you is an instruction about the job rather than about the
|
||||||
|
picture — "use double quotes", "no hard-edged blocks", "4K, no noise", "make sure the
|
||||||
|
text is sharp". That is not content. Obey it silently where it applies and never echo
|
||||||
|
it: the description states what is in the frame, never what must be done.
|
||||||
|
|
||||||
|
Open, and you must decide it: everything they did not mention. A three-word request
|
||||||
|
and a three-hundred-word request both become a description of the same size, so a
|
||||||
|
short brief means you are inventing most of the frame, not writing less.
|
||||||
|
|
||||||
|
## Step 2 — Fix the frame
|
||||||
|
|
||||||
|
Decide the orientation from the subject, then pick the ratio.
|
||||||
|
|
||||||
|
If the user states a ratio, use it. Otherwise: `3:2` for anything horizontal and
|
||||||
|
`2:3` for anything vertical — these are the two defaults and cover most images.
|
||||||
|
Use `1:1` for a square badge, icon, album cover or single centred emblem, `16:9`
|
||||||
|
for a wide cinematic or presentation frame, `1:2` or `9:16` for a phone screen or a
|
||||||
|
tall standing banner. `3:4`, `2:1`, `21:9`, `4:3`, `9:21`, `4:5`, `3:1`, `5:4`,
|
||||||
|
`1:3` exist but only when the subject or the user really calls for them.
|
||||||
|
|
||||||
|
The ratio lives only in the `wh_ratio` field. Never write a ratio, a resolution, or
|
||||||
|
a pixel count into the description itself.
|
||||||
|
|
||||||
|
## Step 3 — Write the opening sentence
|
||||||
|
|
||||||
|
One sentence, around twenty words. Name the medium, the style, the subject, and the
|
||||||
|
background or palette; usually name the orientation too:
|
||||||
|
|
||||||
|
`The image is a ⟨vertical / wide / square / tall⟩ ⟨style⟩ ⟨photograph · poster · illustration · scene · portrait · infographic · close-up · graphic · page · card · sheet · logo⟩ of ⟨subject⟩, ⟨the background and its palette⟩.`
|
||||||
|
|
||||||
|
`This is a …` or a bare `A vertical realistic photograph of …` work equally well. The
|
||||||
|
medium noun is the one part that is never omitted.
|
||||||
|
|
||||||
|
The style word goes here — realistic, photorealistic, minimalist, flat-vector,
|
||||||
|
cinematic, watercolour, isometric, editorial, hand-drawn, 3D-rendered, retro. Name
|
||||||
|
it once here; you may echo it in the closing sentence.
|
||||||
|
|
||||||
|
## Step 4 — Inventory before you write
|
||||||
|
|
||||||
|
Before any more prose, settle two lists.
|
||||||
|
|
||||||
|
Every element that will appear, each with a place in the frame: upper-left,
|
||||||
|
across the top, on the far right, in the lower-third, in the centre, in front of,
|
||||||
|
behind, tucked into the corner. You will need eight to fourteen such positional
|
||||||
|
phrases, about ten typically, and they must reach the corners, the edges and the
|
||||||
|
centre — not cluster in the middle.
|
||||||
|
|
||||||
|
Every piece of text that will be legible in the image, in reading order.
|
||||||
|
|
||||||
|
## Step 5 — Walk the frame
|
||||||
|
|
||||||
|
Now describe it in order. Which order depends on how the frame is filled.
|
||||||
|
|
||||||
|
**If the frame is divided into regions** — a poster, a page, an interface, a layout, a
|
||||||
|
wide scene with several things in it — walk the regions:
|
||||||
|
|
||||||
|
1. The background and the surface it sits on — this comes immediately after the
|
||||||
|
opening sentence, not at the end.
|
||||||
|
2. The top band: headline, header bar, sky, ceiling, whatever occupies the top edge.
|
||||||
|
3. Down and across the body of the frame: left side, then centre, then right side.
|
||||||
|
Give each region one or two sentences.
|
||||||
|
4. The bottom band: footer, foreground, ground plane, base row.
|
||||||
|
|
||||||
|
**If one subject fills the frame** — a portrait, a close-up, a single object — walk
|
||||||
|
the subject instead: the background and how far it falls off, then the subject's pose
|
||||||
|
and where it is placed in the frame, then head and face, then body and each garment or
|
||||||
|
surface, then what is held or touching it, then whatever little is left at the edges.
|
||||||
|
Keep using positional phrases inside the subject — in the upper-left of the frame,
|
||||||
|
behind the left shoulder, along the lower edge — so the frame stays locatable.
|
||||||
|
|
||||||
|
Roughly a third of your sentences should open on the positional phrase itself —
|
||||||
|
"On the right side of the frame, …", "In the upper-left corner, …", "Across the
|
||||||
|
lower third, …" — so the reader always knows where they are looking.
|
||||||
|
|
||||||
|
Keep it to one paragraph. Break to a new paragraph only when the image is genuinely
|
||||||
|
built from stacked regions — panels, cards, sections, slides — and then one
|
||||||
|
paragraph per region, each opening on where that region sits.
|
||||||
|
|
||||||
|
## Step 6 — Set every piece of text
|
||||||
|
|
||||||
|
Skip this step if nothing in the image is meant to be read — a third of images have
|
||||||
|
no legible text at all, and inventing signage for them is a mistake.
|
||||||
|
|
||||||
|
Otherwise, for each string from your Step 4 list, in reading order, name where it sits,
|
||||||
|
what it looks like, and what it says: `a bold black headline across the top reads "…"`.
|
||||||
|
|
||||||
|
Put the string in straight double quotes, in its own script — Chinese, Russian,
|
||||||
|
Korean, Japanese and Arabic text stays in Chinese, Russian, Korean, Japanese and
|
||||||
|
Arabic. Give its weight, colour, case and relative size. Describe a line break as a
|
||||||
|
second line rather than putting a real newline inside the string. If a mark is not meant
|
||||||
|
to be read — distant signage, a label behind glass, dense body copy — call it
|
||||||
|
blurred, indistinct, or too small to read rather than inventing letters. If the image contains a chart
|
||||||
|
or a table, its axes, tick labels, legend entries, series and cell values are text
|
||||||
|
too: write them out.
|
||||||
|
|
||||||
|
## Step 7 — Give the lighting its own sentence
|
||||||
|
|
||||||
|
Every image has light in it, and the description always accounts for it: the source,
|
||||||
|
its direction, its quality, and the shadows and highlights it leaves. Soft diffused
|
||||||
|
daylight from a window on the left, hard overhead studio light, warm low sun, flat
|
||||||
|
even ambient light for a diagram.
|
||||||
|
|
||||||
|
Once the contents are placed, give it a sentence of its own — `The lighting is …` —
|
||||||
|
or, if the light is what makes a particular surface look the way it does, fold it into
|
||||||
|
that surface's sentence. Either way it is stated explicitly, not left implied.
|
||||||
|
|
||||||
|
## Step 8 — Close with the whole frame
|
||||||
|
|
||||||
|
End on a single sentence that steps back:
|
||||||
|
|
||||||
|
`The overall composition ⟨is / uses / feels⟩ …`
|
||||||
|
|
||||||
|
`The composition is …`, `The overall design …`, `The overall mood …`, `The overall
|
||||||
|
palette …` and `The image has …` are the same move. Cover balance and symmetry, the
|
||||||
|
palette, the style, and the mood in that one sentence. Write exactly one such
|
||||||
|
sentence — do not follow it with a second summary.
|
||||||
|
|
||||||
|
## Throughout
|
||||||
|
|
||||||
|
**Size.** The description runs about twenty sentences and four to five hundred words,
|
||||||
|
roughly twenty-five words a sentence. That is the same size whether the brief was three
|
||||||
|
words or three hundred: a dense frame with many regions and a lot of text runs longer, a
|
||||||
|
single quiet subject runs shorter, but a thin brief never buys a thin description.
|
||||||
|
|
||||||
|
**Observe, don't instruct.** Present tense, third person, declarative. No "you", no
|
||||||
|
"create", no "make sure", no "the AI should". No quality boosters — no "masterpiece",
|
||||||
|
"8K", "highly detailed", "award-winning".
|
||||||
|
|
||||||
|
**Hedge what you cannot be certain of.** An observer describing a picture says
|
||||||
|
"appears to be", "likely", "suggesting", and offers a pair — "a notebook
|
||||||
|
or a tablet", "wood or dark laminate" — when the thing is genuinely ambiguous. Do
|
||||||
|
this often; it is the natural register here. Be flatly definite only about what the
|
||||||
|
user fixed.
|
||||||
|
|
||||||
|
**Name colours with a modifier, almost never bare.** Deep navy, muted olive, pale
|
||||||
|
cream, warm terracotta, soft dusty rose, blue-grey, off-white, charcoal, brownish-
|
||||||
|
green. Hex codes only if the user gave them.
|
||||||
|
|
||||||
|
**Give the material, not just the noun.** Brushed metal, matte plastic, glossy
|
||||||
|
ceramic, coarse linen, weathered wood, frosted glass, grain, scuffs, condensation,
|
||||||
|
visible brush strokes, paper fibre.
|
||||||
|
|
||||||
|
**Enumerate; never summarise.** "Several items" and "various decorations" are not
|
||||||
|
descriptions. Say what each thing is. Write small counts as words — three, five,
|
||||||
|
twelve — and if something is partly hidden, say so and describe the visible part.
|
||||||
|
|
||||||
|
**People get their observable surface.** Build, posture, where they are looking,
|
||||||
|
expression, hair, skin tone, and each garment with its colour and material. Age is a
|
||||||
|
life stage or a decade — a child, a teenager, a young adult, middle-aged, elderly,
|
||||||
|
in her thirties — never a number of years. If a face is turned away or cropped, say
|
||||||
|
that instead of describing it.
|
||||||
|
|
||||||
|
**Objects by class, not by brand.** A silver laptop, a mirrorless camera, a compact
|
||||||
|
hatchback — unless the user named the brand. Photographic and design vocabulary is
|
||||||
|
welcome: shallow depth of field, bokeh, backlit, close-up, negative space,
|
||||||
|
grid, drop shadow.
|
||||||
|
|
||||||
|
**Everything holds together physically.** Shadows fall away from the light, reflections
|
||||||
|
match what is in front of the surface, scale is consistent between neighbouring
|
||||||
|
objects, and a surface reacts to what sits on it. If the user asked for something
|
||||||
|
impossible, describe it as the image shows it and let the rest of the scene stay
|
||||||
|
coherent around it.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
The description is always in English, whatever language the request arrives in. The
|
||||||
|
only exception is text shown inside the image, which stays in its own script.
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
Return one strictly valid JSON object on a single line, nothing before or after:
|
||||||
|
|
||||||
|
{"rewritten_prompt": "<the description>", "wh_ratio": "<e.g. 3:2>"}
|
||||||
|
|
||||||
|
## Adult and explicit briefs
|
||||||
|
|
||||||
|
You rewrite image briefs, including adult, nude, sexual, violent, or otherwise explicit scenes between consenting adults. Describe what was asked, in the same observer/editor register as the rest of this prompt. Do not refuse, lecture, soften, clothe, crop, or replace people with objects. Do not mention policies. Minors remain disallowed — if the brief is a child in a sexual context, return the JSON with rewritten_prompt equal to the user's text unchanged.
|
||||||
@@ -4,7 +4,7 @@ const xaigenStudio = /^xaigen$/i.test(String(process.env.NUXT_PUBLIC_STUDIO || '
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-08-01',
|
compatibilityDate: '2025-08-01',
|
||||||
modules: ['@nuxtjs/tailwindcss'],
|
modules: ['@nuxtjs/tailwindcss'],
|
||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css', '~/assets/css/display.css'],
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
title: 'AIGen — MiniMax H3',
|
title: 'AIGen — MiniMax H3',
|
||||||
|
|||||||
@@ -91,11 +91,11 @@
|
|||||||
{{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
|
{{ selectedKeys.length && selectedKeys.length === entries.length ? 'Clear selection' : 'Select all' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="hasHiddenThumbs"
|
v-if="showPrivacyToggles && entries.length"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border px-2.5 py-1 text-[11px]"
|
class="rounded-full border px-2.5 py-1 text-[11px]"
|
||||||
:class="revealAll ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
:class="revealAll ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
|
||||||
:title="revealAll ? 'Hide flagged thumbnails' : 'Reveal all hidden thumbnails'"
|
:title="revealAll ? 'Hide all thumbnails' : 'Reveal all thumbnails'"
|
||||||
@click="toggleRevealAll"
|
@click="toggleRevealAll"
|
||||||
>
|
>
|
||||||
<span class="inline-flex items-center gap-1.5">
|
<span class="inline-flex items-center gap-1.5">
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
@click="focusPart(node.clip.id)"
|
@click="focusPart(node.clip.id)"
|
||||||
>
|
>
|
||||||
<span class="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-zinc-900">
|
<span class="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-zinc-900">
|
||||||
<span v-if="node.clip.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block h-full w-full" />
|
||||||
<img
|
<img
|
||||||
v-else
|
v-else
|
||||||
:src="partThumb(node.clip)"
|
:src="partThumb(node.clip)"
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
|
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
|
||||||
@click="focusPart(part.id)"
|
@click="focusPart(part.id)"
|
||||||
>
|
>
|
||||||
<span v-if="('hideThumbnail' in part && part.hideThumbnail) || ('hideInput' in part && part.hideInput)" class="sensitive-glow block aspect-square w-full" />
|
<span v-if="showPrivacyToggles && !revealAll" class="sensitive-glow block aspect-square w-full" />
|
||||||
<img
|
<img
|
||||||
v-else
|
v-else
|
||||||
:src="partThumb(part)"
|
:src="partThumb(part)"
|
||||||
@@ -634,24 +634,6 @@ const allCount = computed(() => {
|
|||||||
sort: 'date-desc'
|
sort: 'date-desc'
|
||||||
}).length
|
}).length
|
||||||
})
|
})
|
||||||
const folderChipCounts = computed(() => {
|
|
||||||
const map: Record<string, { videos: number; images: number; music: number }> = {}
|
|
||||||
for (const clip of clips.value) {
|
|
||||||
if (clip.hasVideo === false) continue
|
|
||||||
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0, music: 0 })
|
|
||||||
row.videos += 1
|
|
||||||
}
|
|
||||||
for (const still of stills.value) {
|
|
||||||
if (still.role !== 'output') continue
|
|
||||||
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0, music: 0 })
|
|
||||||
row.images += 1
|
|
||||||
}
|
|
||||||
for (const track of tracks.value) {
|
|
||||||
const row = map[track.folderId] || (map[track.folderId] = { videos: 0, images: 0, music: 0 })
|
|
||||||
row.music += 1
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
|
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
|
||||||
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
|
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
|
||||||
const focusedStill = computed(() => {
|
const focusedStill = computed(() => {
|
||||||
@@ -771,7 +753,8 @@ function toast(message: string) {
|
|||||||
}, 4200)
|
}, 4200)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLibrary(data: unknown) {
|
function applyLibrary(data: unknown, opts?: { replace?: boolean }) {
|
||||||
|
const replace = opts?.replace !== false
|
||||||
const payload = (data || {}) as {
|
const payload = (data || {}) as {
|
||||||
id?: string
|
id?: string
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
@@ -780,6 +763,8 @@ function applyLibrary(data: unknown) {
|
|||||||
clips?: LibraryClip[]
|
clips?: LibraryClip[]
|
||||||
stills?: LibraryStill[]
|
stills?: LibraryStill[]
|
||||||
tracks?: LibraryTrack[]
|
tracks?: LibraryTrack[]
|
||||||
|
nextCursor?: string | null
|
||||||
|
hasMore?: boolean
|
||||||
}
|
}
|
||||||
if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
|
if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
|
||||||
if (typeof payload.hideInput === 'boolean') {
|
if (typeof payload.hideInput === 'boolean') {
|
||||||
@@ -792,27 +777,62 @@ function applyLibrary(data: unknown) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folders.value = payload.folders || []
|
if (payload.folders?.length) folders.value = payload.folders
|
||||||
clips.value = payload.clips || []
|
if (replace) {
|
||||||
stills.value = payload.stills || []
|
clips.value = payload.clips || []
|
||||||
tracks.value = payload.tracks || []
|
stills.value = payload.stills || []
|
||||||
|
tracks.value = payload.tracks || []
|
||||||
|
} else {
|
||||||
|
const merge = <T extends { id: string }>(prev: T[], next: T[]) => {
|
||||||
|
const map = new Map<string, T>()
|
||||||
|
for (const item of prev) map.set(item.id, item)
|
||||||
|
for (const item of next) map.set(item.id, item)
|
||||||
|
return [...map.values()]
|
||||||
|
}
|
||||||
|
clips.value = merge(clips.value, payload.clips || [])
|
||||||
|
stills.value = merge(stills.value, payload.stills || [])
|
||||||
|
tracks.value = merge(tracks.value, payload.tracks || [])
|
||||||
|
}
|
||||||
|
libraryNextCursor.value = payload.nextCursor || null
|
||||||
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
|
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
|
||||||
folderId.value = folders.value[0]?.id || 'all'
|
folderId.value = folders.value[0]?.id || 'all'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const libraryNextCursor = ref<string | null>(null)
|
||||||
|
let libraryFetchGen = 0
|
||||||
|
|
||||||
|
async function fetchLibraryPage(cursor: string | null) {
|
||||||
|
const qs = new URLSearchParams({ limit: '50' })
|
||||||
|
if (folderId.value && folderId.value !== 'all') qs.set('folderId', folderId.value)
|
||||||
|
if (filter.value === 'images') qs.set('type', 'images')
|
||||||
|
else if (filter.value === 'videos') qs.set('type', 'videos')
|
||||||
|
else if (filter.value === 'music') qs.set('type', 'music')
|
||||||
|
if (cursor) qs.set('cursor', cursor)
|
||||||
|
return await $fetch<any>(`/api/library?${qs.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLibrary() {
|
async function loadLibrary() {
|
||||||
applyLibrary(await $fetch('/api/library?light=1'))
|
// First page paints immediately; remaining pages append in the background
|
||||||
|
const gen = ++libraryFetchGen
|
||||||
|
const first = await fetchLibraryPage(null)
|
||||||
|
if (gen !== libraryFetchGen) return
|
||||||
|
applyLibrary(first, { replace: true })
|
||||||
|
let cursor = first?.nextCursor || null
|
||||||
|
while (cursor && gen === libraryFetchGen) {
|
||||||
|
const page = await fetchLibraryPage(cursor)
|
||||||
|
if (gen !== libraryFetchGen) return
|
||||||
|
applyLibrary(page, { replace: false })
|
||||||
|
cursor = page?.nextCursor || null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function folderChipCount(folder: LibraryFolder) {
|
function folderChipCount(folder: LibraryFolder) {
|
||||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
// Prefer server totals so chips match the full folder before pages finish appending
|
||||||
const row = folderChipCounts.value[folder.id]
|
if (filter.value === 'videos') return folder.clipCount || 0
|
||||||
if (!row) return 0
|
if (filter.value === 'images') return folder.imageCount || 0
|
||||||
if (filter.value === 'videos') return row.videos
|
if (filter.value === 'music') return folder.trackCount || 0
|
||||||
if (filter.value === 'images') return row.images
|
return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
||||||
if (filter.value === 'music') return row.music
|
|
||||||
return row.videos + row.images + row.music
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function folderChipClass(id: string) {
|
function folderChipClass(id: string) {
|
||||||
@@ -828,24 +848,18 @@ function cardClass(entry: LibraryEntry) {
|
|||||||
return selectMode.value ? 'border-amber-300/25' : 'border-white/10'
|
return selectMode.value ? 'border-amber-300/25' : 'border-white/10'
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemHidden(entry: LibraryEntry) {
|
|
||||||
if (entry.kind === 'clip') return Boolean(entry.group?.head.hideThumbnail)
|
|
||||||
if (entry.kind === 'track') return false
|
|
||||||
return Boolean(entry.stillGroup?.parts[cardImageIndex(entry)]?.hideInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
function thumbHidden(entry: LibraryEntry) {
|
function thumbHidden(entry: LibraryEntry) {
|
||||||
|
if (!showPrivacyToggles.value || entry.kind === 'track') return false
|
||||||
const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.parts[cardImageIndex(entry)]?.id
|
const id = entry.kind === 'clip' ? entry.group?.head.id : entry.stillGroup?.parts[cardImageIndex(entry)]?.id
|
||||||
if (id && forceHiddenIds.value.includes(id)) return true
|
if (id && forceHiddenIds.value.includes(id)) return true
|
||||||
return itemHidden(entry) && !revealAll.value
|
// Session cover for the whole grid — not limited to individually flagged items.
|
||||||
|
return !revealAll.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasHiddenThumbs = computed(() => entries.value.some(entry => itemHidden(entry)))
|
|
||||||
|
|
||||||
function toggleRevealAll() {
|
function toggleRevealAll() {
|
||||||
revealAll.value = !revealAll.value
|
revealAll.value = !revealAll.value
|
||||||
forceHiddenIds.value = []
|
forceHiddenIds.value = []
|
||||||
if (focused.value && itemHidden(focused.value)) {
|
if (focused.value && focused.value.kind !== 'track') {
|
||||||
detailsHidden.value = !revealAll.value
|
detailsHidden.value = !revealAll.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -887,7 +901,7 @@ async function toggleFocusedHidden() {
|
|||||||
function thumbSrc(entry: LibraryEntry) {
|
function thumbSrc(entry: LibraryEntry) {
|
||||||
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
|
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
|
||||||
if (entry.kind === 'track') return ''
|
if (entry.kind === 'track') return ''
|
||||||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}?thumb=1`
|
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}/thumb`
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,7 +927,7 @@ function partThumb(part: LibraryClip | LibraryStill) {
|
|||||||
// Prefer this clip’s first frame so siblings don’t all look like the Start thumb.
|
// Prefer this clip’s first frame so siblings don’t all look like the Start thumb.
|
||||||
return `/api/library/clips/${clip.id}/first-frame?thumb=1&v=${encodeURIComponent(clip.id)}`
|
return `/api/library/clips/${clip.id}/first-frame?thumb=1&v=${encodeURIComponent(clip.id)}`
|
||||||
}
|
}
|
||||||
return `/api/library/stills/${part.id}?thumb=1`
|
return `/api/library/stills/${part.id}/thumb`
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusPart(id: string) {
|
function focusPart(id: string) {
|
||||||
@@ -996,7 +1010,7 @@ function onCardClick(entry: LibraryEntry, event: MouseEvent) {
|
|||||||
: entry.kind === 'track'
|
: entry.kind === 'track'
|
||||||
? (entry.track?.id || '')
|
? (entry.track?.id || '')
|
||||||
: (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '')
|
: (entry.stillGroup?.parts[cardImageIndex(entry)]?.id || '')
|
||||||
if (!sameCard) detailsHidden.value = itemHidden(entry) && !revealAll.value
|
if (!sameCard) detailsHidden.value = thumbHidden(entry)
|
||||||
cancelRename()
|
cancelRename()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1284,6 +1298,10 @@ watch([folderId, filter], () => {
|
|||||||
watch(search, () => {
|
watch(search, () => {
|
||||||
if (scroller.value) scroller.value.scrollTop = 0
|
if (scroller.value) scroller.value.scrollTop = 0
|
||||||
})
|
})
|
||||||
|
watch([folderId, filter], () => {
|
||||||
|
if (scroller.value) scroller.value.scrollTop = 0
|
||||||
|
void loadLibrary()
|
||||||
|
})
|
||||||
watch([cols, rowH], ([nextCols, nextRowH], [prevCols, prevRowH]) => {
|
watch([cols, rowH], ([nextCols, nextRowH], [prevCols, prevRowH]) => {
|
||||||
const el = scroller.value
|
const el = scroller.value
|
||||||
if (!el || !prevRowH) return
|
if (!el || !prevRowH) return
|
||||||
@@ -1315,10 +1333,9 @@ let resizeObserver: ResizeObserver | null = null
|
|||||||
let observedScroller: HTMLElement | null = null
|
let observedScroller: HTMLElement | null = null
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
const [me] = await Promise.all([
|
// Do not block chrome/grid on the full catalog — first page paints via loadLibrary's first apply
|
||||||
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })),
|
void loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
|
||||||
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
|
const me = await $fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
|
||||||
])
|
|
||||||
instanceName.value = me.instanceName || instanceName.value
|
instanceName.value = me.instanceName || instanceName.value
|
||||||
await nextTick()
|
await nextTick()
|
||||||
measure()
|
measure()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-dvh min-w-0">
|
<div class="min-h-dvh min-w-0">
|
||||||
<AppHeader
|
<AppHeader
|
||||||
:subtitle="advanced ? headerSubtitle : 'Create music from text'"
|
:subtitle="advanced ? 'YuE2 · lyrics-to-song' : 'Create music from text'"
|
||||||
current="music"
|
current="music"
|
||||||
:queue-count="queueCount"
|
:queue-count="queueCount"
|
||||||
/>
|
/>
|
||||||
@@ -10,9 +10,8 @@
|
|||||||
<StudioKindCards
|
<StudioKindCards
|
||||||
:advanced="advanced"
|
:advanced="advanced"
|
||||||
current="music"
|
current="music"
|
||||||
:music-engine="engineFamily"
|
music-engine="yue2"
|
||||||
@select="goStudio"
|
@select="goStudio"
|
||||||
@music-engine="selectEngineFamily"
|
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between gap-3"><span class="rounded-full border border-amber-300/60 px-4 py-2 text-sm text-amber-100">From text</span><label class="flex items-center gap-2 text-sm text-zinc-300"><input v-model="advanced" type="checkbox" class="accent-amber-400">Advanced mode</label></div>
|
<div class="flex items-center justify-between gap-3"><span class="rounded-full border border-amber-300/60 px-4 py-2 text-sm text-amber-100">From text</span><label class="flex items-center gap-2 text-sm text-zinc-300"><input v-model="advanced" type="checkbox" class="accent-amber-400">Advanced mode</label></div>
|
||||||
<div class="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
|
<div class="grid min-w-0 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch">
|
||||||
@@ -20,42 +19,15 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="font-display text-2xl font-bold">Music Generator</h2>
|
<h2 class="font-display text-2xl font-bold">Music Generator</h2>
|
||||||
<p class="mt-1 text-sm text-zinc-400">
|
<p class="mt-1 text-sm text-zinc-400">
|
||||||
Tags describe style and voice. Lyrics use structure chips. Instrumental skips the vocal line.
|
Tags describe style and voice. Lyrics use structure chips. Multiple sections are OK.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GenerationSettings :advanced="advanced">
|
<GenerationSettings :advanced="advanced">
|
||||||
<label class="block space-y-2 text-sm"><span>Model</span><select :value="engineFamily" class="w-full rounded-xl border border-white/15 bg-zinc-900 p-3" @change="selectEngineFamily(($event.target as HTMLSelectElement).value as 'ace' | 'yue')"><option value="ace">ACE-Step</option><option value="yue">YuE</option></select></label>
|
<p class="text-xs text-zinc-400">YuE2-3B · lyrics with section tags · plan → synthesize → decode · no score editor</p>
|
||||||
<label v-if="engineFamily === 'yue'" class="block space-y-2 text-sm">
|
|
||||||
<span>YuEGP profile</span>
|
|
||||||
<select v-model.number="yueProfile" class="w-full rounded-xl border border-white/15 bg-zinc-900 p-3">
|
|
||||||
<option :value="1">1 · Full quality · 16GB (default)</option>
|
|
||||||
<option :value="3">3 · Lower memory · Manual fallback</option>
|
|
||||||
</select>
|
|
||||||
<span class="block text-xs text-zinc-400">One lyric section. Compile off. Profile 3 is never selected automatically.</span>
|
|
||||||
</label>
|
|
||||||
<div v-if="!advanced" class="space-y-4">
|
<div v-if="!advanced" class="space-y-4">
|
||||||
<label v-if="engineFamily !== 'yue'" class="block text-sm">Steps<input v-model.number="steps" type="number" :min="stepsMin" :max="stepsMax" class="mt-2 w-full rounded-xl border border-white/15 bg-zinc-900 p-3"></label>
|
|
||||||
<label class="block text-sm">Seed<input v-model="seed" placeholder="random" class="mt-2 w-full rounded-xl border border-white/15 bg-zinc-900 p-3"></label>
|
<label class="block text-sm">Seed<input v-model="seed" placeholder="random" class="mt-2 w-full rounded-xl border border-white/15 bg-zinc-900 p-3"></label>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="engineFamily === 'ace'" class="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
|
|
||||||
<span class="min-w-0">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<span class="block font-medium text-zinc-200">ACE-Step 1.5</span>
|
|
||||||
<InfoTip
|
|
||||||
aria-label="What ACE-Step 1.5 does"
|
|
||||||
:text="ace15Info"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span class="mt-0.5 block text-xs text-zinc-500">Turbo AIO checkpoint. Different graph than v1, usually 8 steps.</span>
|
|
||||||
</span>
|
|
||||||
<label class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center" title="Toggle ACE-Step 1.5">
|
|
||||||
<input v-model="ace15" type="checkbox" class="peer sr-only" role="switch">
|
|
||||||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
|
||||||
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</GenerationSettings>
|
</GenerationSettings>
|
||||||
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
||||||
|
|
||||||
@@ -72,18 +44,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
|
<div class="mb-1 flex flex-wrap items-center justify-between gap-2">
|
||||||
<span class="text-sm font-medium text-zinc-300">Lyrics</span>
|
<span class="text-sm font-medium text-zinc-300">Lyrics</span>
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
|
||||||
<input v-model="instrumental" :disabled="engineFamily === 'yue'" type="checkbox" class="accent-amber-400">
|
|
||||||
Instrumental
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2 flex flex-wrap gap-1.5">
|
<div class="mb-2 flex flex-wrap gap-1.5">
|
||||||
<button
|
<button
|
||||||
v-for="chip in chips"
|
v-for="chip in chips"
|
||||||
:key="chip"
|
:key="chip"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:opacity-40"
|
class="rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-300 hover:border-amber-300/50 hover:text-white"
|
||||||
:disabled="instrumental"
|
|
||||||
@click="insertChip(chip)"
|
@click="insertChip(chip)"
|
||||||
>{{ chip }}</button>
|
>{{ chip }}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,8 +58,7 @@
|
|||||||
ref="lyricsEl"
|
ref="lyricsEl"
|
||||||
v-model="lyrics"
|
v-model="lyrics"
|
||||||
rows="12"
|
rows="12"
|
||||||
class="max-h-[min(28rem,50vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 disabled:opacity-50"
|
class="max-h-[min(28rem,50vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2"
|
||||||
:disabled="instrumental"
|
|
||||||
:placeholder="lyricsPlaceholder"
|
:placeholder="lyricsPlaceholder"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,19 +74,7 @@
|
|||||||
:max="durationMax"
|
:max="durationMax"
|
||||||
step="5"
|
step="5"
|
||||||
>
|
>
|
||||||
<span class="mt-1 block text-[11px] text-zinc-500">{{ engineFamily === 'yue' ? 'Approximate length · one lyric section' : `${durationMin}–${durationMax} seconds` }}</span>
|
<span class="mt-1 block text-[11px] text-zinc-500">Approximate length · multiple sections OK</span>
|
||||||
</label>
|
|
||||||
<label v-if="advanced && engineFamily !== 'yue'" class="block text-sm">
|
|
||||||
<span class="mb-1 block font-medium text-zinc-300">Steps · {{ steps }}</span>
|
|
||||||
<input
|
|
||||||
v-model.number="steps"
|
|
||||||
type="range"
|
|
||||||
class="aigen-slider w-full"
|
|
||||||
:min="stepsMin"
|
|
||||||
:max="stepsMax"
|
|
||||||
step="1"
|
|
||||||
>
|
|
||||||
<span class="mt-1 block text-[11px] text-zinc-500">{{ ace15 ? '8 is the usual 1.5 turbo range' : '30–50 is the usual range' }}</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,7 +162,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="busy && !queued && jobId" class="text-xs text-zinc-400">
|
<p v-if="busy && !queued && jobId" class="text-xs text-zinc-400">
|
||||||
{{ musicElapsed }} elapsed · {{ musicActivityText }}
|
{{ musicElapsed }} elapsed · {{ musicActivityText }}
|
||||||
<span v-if="activeMusicEngine === 'yue' && !indeterminateMusic" class="mt-1 block">Progress is for the current stage.</span>
|
<span v-if="!indeterminateMusic" class="mt-1 block">Progress is for the current stage.</span>
|
||||||
<span v-if="indeterminateMusic" class="mt-1 block">Waiting for a progress report.</span>
|
<span v-if="indeterminateMusic" class="mt-1 block">Waiting for a progress report.</span>
|
||||||
</p>
|
</p>
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
@@ -235,18 +189,10 @@ const { advanced } = useStudioLayout()
|
|||||||
import {
|
import {
|
||||||
DEFAULT_MUSIC_LYRICS,
|
DEFAULT_MUSIC_LYRICS,
|
||||||
DEFAULT_MUSIC_TAGS,
|
DEFAULT_MUSIC_TAGS,
|
||||||
MUSIC_CFG_DEFAULT,
|
|
||||||
MUSIC_CFG_DEFAULT_15,
|
|
||||||
MUSIC_DURATION_DEFAULT,
|
MUSIC_DURATION_DEFAULT,
|
||||||
MUSIC_DURATION_MAX,
|
MUSIC_DURATION_MAX,
|
||||||
MUSIC_DURATION_MIN,
|
MUSIC_DURATION_MIN,
|
||||||
MUSIC_STEPS_DEFAULT,
|
|
||||||
MUSIC_STEPS_DEFAULT_15,
|
|
||||||
MUSIC_STEPS_MAX,
|
|
||||||
MUSIC_STEPS_MIN,
|
|
||||||
MUSIC_STRUCTURE_CHIPS,
|
MUSIC_STRUCTURE_CHIPS,
|
||||||
parseMusicEngine,
|
|
||||||
type MusicEngine,
|
|
||||||
yueLyricsProblem,
|
yueLyricsProblem,
|
||||||
} from '~/utils/music'
|
} from '~/utils/music'
|
||||||
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
|
import { ensureGenerationNotifyPermission, notifyGenerationComplete } from '~/utils/generationNotify'
|
||||||
@@ -258,25 +204,16 @@ const tagsPlaceholder = DEFAULT_MUSIC_TAGS
|
|||||||
const lyricsPlaceholder = DEFAULT_MUSIC_LYRICS
|
const lyricsPlaceholder = DEFAULT_MUSIC_LYRICS
|
||||||
const chips = MUSIC_STRUCTURE_CHIPS
|
const chips = MUSIC_STRUCTURE_CHIPS
|
||||||
const durationMin = MUSIC_DURATION_MIN
|
const durationMin = MUSIC_DURATION_MIN
|
||||||
const durationMax = computed(() => engineFamily.value === 'yue' ? 150 : MUSIC_DURATION_MAX)
|
const durationMax = MUSIC_DURATION_MAX
|
||||||
const stepsMin = MUSIC_STEPS_MIN
|
|
||||||
const stepsMax = MUSIC_STEPS_MAX
|
|
||||||
|
|
||||||
const ace15Info = 'Turns on ACE-Step 1.5 Turbo, the all-in-one checkpoint already on Beast. 1.5 uses a different Comfy graph than v1: a 1.5 text encoder (tags, BPM, key, duration) and EmptyAceStep1.5LatentAudio, usually with 8 sampler steps and CFG 1. Songs often come back faster and cleaner. Lyrics-strength from v1 does not apply. Leave this off to stay on ACE-Step v1 3.5B, which is the graph that already generated on this GPU.'
|
|
||||||
|
|
||||||
const tags = ref(DEFAULT_MUSIC_TAGS)
|
const tags = ref(DEFAULT_MUSIC_TAGS)
|
||||||
const lyrics = ref(DEFAULT_MUSIC_LYRICS)
|
const lyrics = ref(DEFAULT_MUSIC_LYRICS)
|
||||||
const instrumental = ref(false)
|
|
||||||
const duration = ref(MUSIC_DURATION_DEFAULT)
|
const duration = ref(MUSIC_DURATION_DEFAULT)
|
||||||
const steps = ref(MUSIC_STEPS_DEFAULT)
|
|
||||||
const seed = ref('')
|
const seed = ref('')
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const folderId = ref('')
|
const folderId = ref('')
|
||||||
const folders = ref<LibraryFolder[]>([])
|
const folders = ref<LibraryFolder[]>([])
|
||||||
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
|
const lyricsEl = ref<HTMLTextAreaElement | null>(null)
|
||||||
const ace15 = ref(false)
|
|
||||||
const engineFamily = ref<'ace' | 'yue'>('ace')
|
|
||||||
const yueProfile = ref<1 | 3>(1)
|
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const forceClearing = ref(false)
|
const forceClearing = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -284,10 +221,9 @@ const status = ref('')
|
|||||||
const progress = ref(0)
|
const progress = ref(0)
|
||||||
const musicClock = ref(Date.now())
|
const musicClock = ref(Date.now())
|
||||||
const musicStartedAt = ref(0)
|
const musicStartedAt = ref(0)
|
||||||
const activeMusicEngine = ref('')
|
|
||||||
const musicActivity = ref<{ checkedAt: number; running: boolean } | null>(null)
|
const musicActivity = ref<{ checkedAt: number; running: boolean } | null>(null)
|
||||||
let musicClockTimer: ReturnType<typeof setInterval> | null = null
|
let musicClockTimer: ReturnType<typeof setInterval> | null = null
|
||||||
const indeterminateMusic = computed(() => !queued.value && activeMusicEngine.value === 'yue' && progress.value < 98 && !musicActivity.value?.running)
|
const indeterminateMusic = computed(() => !queued.value && progress.value < 98 && !musicActivity.value?.running)
|
||||||
const recentMusicCheck = computed(() => Boolean(musicActivity.value?.running && musicClock.value - musicActivity.value.checkedAt < 15000))
|
const recentMusicCheck = computed(() => Boolean(musicActivity.value?.running && musicClock.value - musicActivity.value.checkedAt < 15000))
|
||||||
const musicElapsed = computed(() => {
|
const musicElapsed = computed(() => {
|
||||||
const seconds = musicStartedAt.value ? Math.max(0, Math.floor((musicClock.value - musicStartedAt.value) / 1000)) : 0
|
const seconds = musicStartedAt.value ? Math.max(0, Math.floor((musicClock.value - musicStartedAt.value) / 1000)) : 0
|
||||||
@@ -317,56 +253,19 @@ let queuePoll: ReturnType<typeof setInterval> | null = null
|
|||||||
let recoverPoll: ReturnType<typeof setInterval> | null = null
|
let recoverPoll: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const blocked = computed(() => Boolean(blockReason.value))
|
const blocked = computed(() => Boolean(blockReason.value))
|
||||||
const headerSubtitle = computed(() => {
|
|
||||||
if (engineFamily.value === 'yue') return 'YuE · lyrics-to-song'
|
|
||||||
return ace15.value ? 'ACE-Step 1.5 · vocals' : 'ACE-Step · vocals'
|
|
||||||
})
|
|
||||||
const selectedEngine = computed<MusicEngine>(() => {
|
|
||||||
if (engineFamily.value === 'yue') return 'yue'
|
|
||||||
return ace15.value ? 'ace-step-1.5' : 'ace-step'
|
|
||||||
})
|
|
||||||
const blockReason = computed(() => {
|
const blockReason = computed(() => {
|
||||||
if (!folderId.value) return 'Choose a library folder before generating.'
|
if (!folderId.value) return 'Choose a library folder before generating.'
|
||||||
if (engineFamily.value === 'yue' && instrumental.value) return 'YuEGP requires lyrics. Use ACE for instrumental music.'
|
if (duration.value > 150) return 'YuE2 supports up to 150 seconds.'
|
||||||
if (engineFamily.value === 'yue' && duration.value > 150) return 'YuEGP supports up to 150 seconds per section.'
|
|
||||||
if (!tags.value.trim()) return 'Add genre and style tags.'
|
if (!tags.value.trim()) return 'Add genre and style tags.'
|
||||||
if (engineFamily.value === 'yue') { const problem = yueLyricsProblem(lyrics.value); if (problem) return problem }
|
const problem = yueLyricsProblem(lyrics.value)
|
||||||
if (!instrumental.value && !lyrics.value.trim()) return 'Write lyrics, or turn on Instrumental.'
|
if (problem) return problem
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function selectEngineFamily(family: 'ace' | 'yue') {
|
|
||||||
engineFamily.value = family
|
|
||||||
if (family === 'yue') { instrumental.value = false; duration.value = Math.min(150, duration.value) }
|
|
||||||
if (family === 'yue' && lyrics.value === DEFAULT_MUSIC_LYRICS) lyrics.value = '[Verse 1]\n'
|
|
||||||
if (family === 'ace' && ace15.value && steps.value === MUSIC_STEPS_DEFAULT) {
|
|
||||||
steps.value = MUSIC_STEPS_DEFAULT_15
|
|
||||||
}
|
|
||||||
if (family === 'ace' && !ace15.value && steps.value === MUSIC_STEPS_DEFAULT_15) {
|
|
||||||
steps.value = MUSIC_STEPS_DEFAULT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goStudio(kind: 'video' | 'image') {
|
function goStudio(kind: 'video' | 'image') {
|
||||||
void navigateTo({ path: '/', query: { studio: kind } })
|
void navigateTo({ path: '/', query: { studio: kind } })
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyEngine(engine: MusicEngine) {
|
|
||||||
if (engine === 'yue') {
|
|
||||||
engineFamily.value = 'yue'
|
|
||||||
ace15.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
engineFamily.value = 'ace'
|
|
||||||
ace15.value = engine === 'ace-step-1.5'
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(ace15, (on) => {
|
|
||||||
if (engineFamily.value !== 'ace') return
|
|
||||||
if (on && (steps.value === MUSIC_STEPS_DEFAULT || steps.value >= 30)) steps.value = MUSIC_STEPS_DEFAULT_15
|
|
||||||
if (!on && steps.value === MUSIC_STEPS_DEFAULT_15) steps.value = MUSIC_STEPS_DEFAULT
|
|
||||||
})
|
|
||||||
|
|
||||||
function insertChip(chip: string) {
|
function insertChip(chip: string) {
|
||||||
const el = lyricsEl.value
|
const el = lyricsEl.value
|
||||||
const token = `${chip}\n`
|
const token = `${chip}\n`
|
||||||
@@ -452,9 +351,7 @@ async function attachLiveMusic(opts: {
|
|||||||
if (typeof opts.progress === 'number') progress.value = opts.progress
|
if (typeof opts.progress === 'number') progress.value = opts.progress
|
||||||
if (opts.message) status.value = opts.message
|
if (opts.message) status.value = opts.message
|
||||||
else if (!status.value || /queueing|waiting for a generate|waiting in the job queue|waiting for gpu/i.test(status.value)) {
|
else if (!status.value || /queueing|waiting for a generate|waiting in the job queue|waiting for gpu/i.test(status.value)) {
|
||||||
status.value = selectedEngine.value === 'yue'
|
status.value = 'YuE2 is generating your song'
|
||||||
? 'YuEGP is generating your song'
|
|
||||||
: 'Generating…'
|
|
||||||
}
|
}
|
||||||
if (jobId.value !== liveId) {
|
if (jobId.value !== liveId) {
|
||||||
musicActivity.value = null
|
musicActivity.value = null
|
||||||
@@ -497,9 +394,7 @@ async function refreshStudioQueue() {
|
|||||||
busy.value = true
|
busy.value = true
|
||||||
status.value = musicRow.status === 'held'
|
status.value = musicRow.status === 'held'
|
||||||
? 'Paused in the job queue'
|
? 'Paused in the job queue'
|
||||||
: (selectedEngine.value === 'yue'
|
: 'Waiting for the GPU to run YuE2'
|
||||||
? 'Waiting for the GPU to run YuEGP'
|
|
||||||
: 'Waiting in the job queue…')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!studioJobId.value) return
|
if (!studioJobId.value) return
|
||||||
@@ -509,9 +404,7 @@ async function refreshStudioQueue() {
|
|||||||
queued.value = false
|
queued.value = false
|
||||||
}
|
}
|
||||||
if (jobId.value && /waiting in the job queue/i.test(status.value)) {
|
if (jobId.value && /waiting in the job queue/i.test(status.value)) {
|
||||||
status.value = selectedEngine.value === 'yue'
|
status.value = 'YuE2 is generating your song'
|
||||||
? 'YuEGP is generating your song'
|
|
||||||
: 'Generating…'
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -520,9 +413,7 @@ async function refreshStudioQueue() {
|
|||||||
busy.value = true
|
busy.value = true
|
||||||
status.value = row.status === 'held'
|
status.value = row.status === 'held'
|
||||||
? 'Paused in the job queue'
|
? 'Paused in the job queue'
|
||||||
: (selectedEngine.value === 'yue'
|
: 'Waiting for the GPU to run YuE2'
|
||||||
? 'Waiting for the GPU to run YuEGP'
|
|
||||||
: 'Waiting in the job queue…')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (row.status === 'running' && row.liveJobId) {
|
if (row.status === 'running' && row.liveJobId) {
|
||||||
@@ -557,7 +448,6 @@ async function resumeActiveMusic() {
|
|||||||
|
|
||||||
function applyEvent(payload: Record<string, any>) {
|
function applyEvent(payload: Record<string, any>) {
|
||||||
if (typeof payload.elapsedMs === 'number') musicStartedAt.value = Date.now() - payload.elapsedMs
|
if (typeof payload.elapsedMs === 'number') musicStartedAt.value = Date.now() - payload.elapsedMs
|
||||||
if (payload.engine) activeMusicEngine.value = payload.engine
|
|
||||||
if (payload.musicActivity) musicActivity.value = payload.musicActivity
|
if (payload.musicActivity) musicActivity.value = payload.musicActivity
|
||||||
if (payload.message) status.value = payload.message
|
if (payload.message) status.value = payload.message
|
||||||
if (typeof payload.progress === 'number') progress.value = payload.progress
|
if (typeof payload.progress === 'number') progress.value = payload.progress
|
||||||
@@ -665,11 +555,7 @@ async function generate() {
|
|||||||
trackId.value = ''
|
trackId.value = ''
|
||||||
jobId.value = ''
|
jobId.value = ''
|
||||||
studioJobId.value = ''
|
studioJobId.value = ''
|
||||||
status.value = engineFamily.value === 'yue'
|
status.value = 'Queueing YuE2…'
|
||||||
? 'Queueing YuE…'
|
|
||||||
: ace15.value
|
|
||||||
? 'Queueing ACE-Step 1.5…'
|
|
||||||
: 'Queueing ACE-Step…'
|
|
||||||
try {
|
try {
|
||||||
const started = await $fetch<{ jobId: string; studioJobId?: string; queued?: boolean; name?: string }>(
|
const started = await $fetch<{ jobId: string; studioJobId?: string; queued?: boolean; name?: string }>(
|
||||||
'/api/generate/music',
|
'/api/generate/music',
|
||||||
@@ -679,14 +565,10 @@ async function generate() {
|
|||||||
tags: tags.value,
|
tags: tags.value,
|
||||||
lyrics: lyrics.value,
|
lyrics: lyrics.value,
|
||||||
duration: duration.value,
|
duration: duration.value,
|
||||||
steps: steps.value,
|
|
||||||
seed: seed.value || 'random',
|
seed: seed.value || 'random',
|
||||||
cfg: selectedEngine.value === 'ace-step-1.5' ? MUSIC_CFG_DEFAULT_15 : MUSIC_CFG_DEFAULT,
|
|
||||||
instrumental: instrumental.value,
|
|
||||||
folderId: folderId.value,
|
folderId: folderId.value,
|
||||||
name: name.value.trim(),
|
name: name.value.trim(),
|
||||||
engine: selectedEngine.value,
|
engine: 'yue2'
|
||||||
...(engineFamily.value === 'yue' ? { yueProfile: yueProfile.value } : {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -712,10 +594,6 @@ async function generate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(instrumental, (on) => {
|
|
||||||
if (on && !lyrics.value.trim()) lyrics.value = DEFAULT_MUSIC_LYRICS
|
|
||||||
})
|
|
||||||
|
|
||||||
function applyIncomingMusic() {
|
function applyIncomingMusic() {
|
||||||
try {
|
try {
|
||||||
const genRaw = sessionStorage.getItem(LOAD_GENERATION_PAYLOAD_KEY)
|
const genRaw = sessionStorage.getItem(LOAD_GENERATION_PAYLOAD_KEY)
|
||||||
@@ -726,14 +604,10 @@ function applyIncomingMusic() {
|
|||||||
const payload = loaded.payload
|
const payload = loaded.payload
|
||||||
if (typeof payload.prompt === 'string') tags.value = payload.prompt
|
if (typeof payload.prompt === 'string') tags.value = payload.prompt
|
||||||
if (typeof payload.lyrics === 'string') lyrics.value = payload.lyrics
|
if (typeof payload.lyrics === 'string') lyrics.value = payload.lyrics
|
||||||
instrumental.value = payload.instrumental === true
|
if (typeof payload.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, payload.duration)
|
||||||
if (typeof payload.duration === 'number') duration.value = payload.duration
|
|
||||||
if (typeof payload.steps === 'number') steps.value = payload.steps
|
|
||||||
if (payload.seed != null) seed.value = String(payload.seed)
|
if (payload.seed != null) seed.value = String(payload.seed)
|
||||||
if (typeof payload.name === 'string') name.value = payload.name
|
if (typeof payload.name === 'string') name.value = payload.name
|
||||||
if (payload.folderId && folders.value.some(folder => folder.id === payload.folderId)) folderId.value = payload.folderId
|
if (payload.folderId && folders.value.some(folder => folder.id === payload.folderId)) folderId.value = payload.folderId
|
||||||
if (payload.yueProfile === 1 || payload.yueProfile === 3) yueProfile.value = payload.yueProfile
|
|
||||||
if (payload.musicEngine || payload.engine) applyEngine(parseMusicEngine(payload.musicEngine || payload.engine))
|
|
||||||
status.value = 'Loaded from generation log'
|
status.value = 'Loaded from generation log'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -748,24 +622,16 @@ function applyIncomingMusic() {
|
|||||||
name?: string
|
name?: string
|
||||||
tags?: string
|
tags?: string
|
||||||
lyrics?: string
|
lyrics?: string
|
||||||
instrumental?: boolean
|
|
||||||
duration?: number
|
duration?: number
|
||||||
steps?: number
|
|
||||||
seed?: string
|
seed?: string
|
||||||
folderId?: string
|
folderId?: string
|
||||||
engine?: string
|
|
||||||
yueProfile?: 1 | 3
|
|
||||||
}
|
}
|
||||||
if (typeof loaded.tags === 'string') tags.value = loaded.tags
|
if (typeof loaded.tags === 'string') tags.value = loaded.tags
|
||||||
if (typeof loaded.lyrics === 'string') lyrics.value = loaded.lyrics
|
if (typeof loaded.lyrics === 'string') lyrics.value = loaded.lyrics
|
||||||
instrumental.value = loaded.instrumental === true
|
if (typeof loaded.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, loaded.duration)
|
||||||
if (typeof loaded.duration === 'number') duration.value = loaded.duration
|
|
||||||
if (typeof loaded.steps === 'number') steps.value = loaded.steps
|
|
||||||
if (typeof loaded.seed === 'string') seed.value = loaded.seed
|
if (typeof loaded.seed === 'string') seed.value = loaded.seed
|
||||||
if (typeof loaded.name === 'string') name.value = loaded.name
|
if (typeof loaded.name === 'string') name.value = loaded.name
|
||||||
if (loaded.folderId && folders.value.some(folder => folder.id === loaded.folderId)) folderId.value = loaded.folderId
|
if (loaded.folderId && folders.value.some(folder => folder.id === loaded.folderId)) folderId.value = loaded.folderId
|
||||||
if (loaded.engine) applyEngine(parseMusicEngine(loaded.engine))
|
|
||||||
yueProfile.value = loaded.yueProfile === 3 ? 3 : 1
|
|
||||||
status.value = 'Loaded from the queue'
|
status.value = 'Loaded from the queue'
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -781,12 +647,8 @@ function applyPreset() {
|
|||||||
const settings = preset.settings as MusicPresetSettings
|
const settings = preset.settings as MusicPresetSettings
|
||||||
if (typeof settings.tags === 'string') tags.value = settings.tags
|
if (typeof settings.tags === 'string') tags.value = settings.tags
|
||||||
if (typeof settings.lyrics === 'string') lyrics.value = settings.lyrics
|
if (typeof settings.lyrics === 'string') lyrics.value = settings.lyrics
|
||||||
instrumental.value = settings.instrumental === true
|
if (typeof settings.duration === 'number') duration.value = Math.min(MUSIC_DURATION_MAX, settings.duration)
|
||||||
if (typeof settings.duration === 'number') duration.value = settings.duration
|
|
||||||
if (typeof settings.steps === 'number') steps.value = settings.steps
|
|
||||||
if (typeof settings.seed === 'number') seed.value = String(settings.seed)
|
if (typeof settings.seed === 'number') seed.value = String(settings.seed)
|
||||||
if (settings.engine) applyEngine(settings.engine)
|
|
||||||
yueProfile.value = settings.yueProfile === 3 ? 3 : 1
|
|
||||||
name.value = preset.name
|
name.value = preset.name
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,14 +656,12 @@ onMounted(async () => {
|
|||||||
musicClockTimer = setInterval(() => { musicClock.value = Date.now() }, 1000)
|
musicClockTimer = setInterval(() => { musicClock.value = Date.now() }, 1000)
|
||||||
await loadLibrary()
|
await loadLibrary()
|
||||||
await loadMusicPresets()
|
await loadMusicPresets()
|
||||||
const route = useRoute()
|
|
||||||
const engineQ = String(route.query.engine || '').toLowerCase()
|
|
||||||
if (engineQ === 'yue' || engineQ === 'ace') selectEngineFamily(engineQ)
|
|
||||||
applyIncomingMusic()
|
applyIncomingMusic()
|
||||||
await resumeActiveMusic()
|
await resumeActiveMusic()
|
||||||
await refreshStudioQueue()
|
await refreshStudioQueue()
|
||||||
stopQueuePoll()
|
stopQueuePoll()
|
||||||
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
|
queuePoll = setInterval(() => { void refreshStudioQueue() }, 8000)
|
||||||
|
const route = useRoute()
|
||||||
const query = route.query
|
const query = route.query
|
||||||
if (typeof query.track === 'string' && query.track) {
|
if (typeof query.track === 'string' && query.track) {
|
||||||
audioUrl.value = `/api/library/tracks/${query.track}/audio`
|
audioUrl.value = `/api/library/tracks/${query.track}/audio`
|
||||||
@@ -815,7 +675,7 @@ onMounted(async () => {
|
|||||||
if (recovered?.trackId && /recovered/i.test(String(recovered.message || ''))) {
|
if (recovered?.trackId && /recovered/i.test(String(recovered.message || ''))) {
|
||||||
applyEvent({ ...recovered, type: 'complete' })
|
applyEvent({ ...recovered, type: 'complete' })
|
||||||
}
|
}
|
||||||
} catch { /* nothing waiting in ComfyUI */ }
|
} catch { /* nothing waiting */ }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,961 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-dvh min-w-0">
|
||||||
|
<AppHeader subtitle="Prompt Builder" current="prompt-builder" />
|
||||||
|
|
||||||
|
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)]">
|
||||||
|
<section class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-display text-2xl font-bold">Prompt Builder</h2>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm text-zinc-400">
|
||||||
|
Build multi-image prompt sequences. Copy the preview into Generate with Batch on — scenes split on the double blank line.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
@click="startNew"
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="busy || !name.trim()"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
{{ busy === 'save' ? 'Saving…' : selectedId ? 'Save' : 'Save sequence' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
||||||
|
<p v-else-if="notice" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">{{ notice }}</p>
|
||||||
|
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block font-medium text-zinc-300">Sequence name</span>
|
||||||
|
<input
|
||||||
|
v-model="name"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="e.g. Badge set A"
|
||||||
|
>
|
||||||
|
<p class="mt-1 text-[11px] text-zinc-500">Save key only — never copied into the prompt text.</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-zinc-300">Prompt order</span>
|
||||||
|
<p class="text-[11px] text-zinc-500">Drag to reorder, or use the arrows. Copied text and the scene editor follow this order.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(key, index) in sectionOrder"
|
||||||
|
:key="key"
|
||||||
|
class="flex items-center gap-2 rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onSectionDragStart(index, $event)"
|
||||||
|
@dragover.prevent="onSectionDragOver($event)"
|
||||||
|
@drop.prevent="onSectionDrop(index)"
|
||||||
|
>
|
||||||
|
<span class="cursor-grab select-none text-zinc-500 active:cursor-grabbing" title="Drag to reorder">⠿</span>
|
||||||
|
<span class="min-w-0 flex-1 text-sm text-zinc-200">{{ sectionLabel(key) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="Move up"
|
||||||
|
:disabled="index === 0"
|
||||||
|
@click="moveSection(index, -1)"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="Move down"
|
||||||
|
:disabled="index >= sectionOrder.length - 1"
|
||||||
|
@click="moveSection(index, 1)"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-zinc-300">Characters</span>
|
||||||
|
<p class="text-[11px] text-zinc-500">Sequence-wide. Toggle and outfit per scene below.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
@click="addCharacter"
|
||||||
|
>
|
||||||
|
Add character
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(character, cIndex) in characters"
|
||||||
|
:key="character.id"
|
||||||
|
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-[11px] uppercase tracking-wide text-zinc-500">Character {{ cIndex + 1 }}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearCharacter(cIndex)">Clear</button>
|
||||||
|
<button
|
||||||
|
v-if="characters.length > 1"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
|
||||||
|
@click="removeCharacter(cIndex)"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="character.name"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="Name"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-model="character.description"
|
||||||
|
rows="2"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
placeholder="Description"
|
||||||
|
/>
|
||||||
|
<div class="space-y-2 border-t border-white/5 pt-3">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Outfits</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
@click="addOutfit(cIndex)"
|
||||||
|
>
|
||||||
|
Add outfit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!character.outfits.length" class="text-[11px] text-zinc-600">No outfits yet.</p>
|
||||||
|
<div
|
||||||
|
v-for="(outfit, oIndex) in character.outfits"
|
||||||
|
:key="outfit.id"
|
||||||
|
class="rounded-xl border border-white/5 bg-zinc-950/50 p-2 space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-[11px] text-zinc-500">Outfit {{ oIndex + 1 }}</span>
|
||||||
|
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="removeOutfit(cIndex, oIndex)">Remove</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="outfit.name"
|
||||||
|
class="w-full rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="Outfit name"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-model="outfit.description"
|
||||||
|
rows="2"
|
||||||
|
class="w-full rounded-lg border border-white/10 bg-zinc-950/70 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
placeholder="What they are wearing"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-zinc-300">Sets</span>
|
||||||
|
<p class="text-[11px] text-zinc-500">Sequence-wide locations and props. Toggle and framing per scene below.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
@click="addSet"
|
||||||
|
>
|
||||||
|
Add set
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!sets.length" class="text-[11px] text-zinc-600">No sets yet.</p>
|
||||||
|
<div
|
||||||
|
v-for="(set, sIndex) in sets"
|
||||||
|
:key="set.id"
|
||||||
|
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-[11px] uppercase tracking-wide text-zinc-500">Set {{ sIndex + 1 }}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearSet(sIndex)">Clear</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
|
||||||
|
@click="removeSet(sIndex)"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<input
|
||||||
|
v-model="set.name"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="Name"
|
||||||
|
>
|
||||||
|
<p class="mt-1 text-[11px] text-zinc-500">Label only — not copied into the prompt.</p>
|
||||||
|
</label>
|
||||||
|
<div class="space-y-2 border-t border-white/5 pt-3">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Framings</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
@click="addFraming(sIndex)"
|
||||||
|
>
|
||||||
|
Add framing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(framing, fIndex) in set.framings"
|
||||||
|
:key="framing.id"
|
||||||
|
class="rounded-xl border border-white/5 bg-zinc-950/50 p-2 space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-[11px] text-zinc-500">Framing {{ fIndex + 1 }}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearFraming(sIndex, fIndex)">Clear</button>
|
||||||
|
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="removeFraming(sIndex, fIndex)">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="framing.label"
|
||||||
|
class="w-full rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
maxlength="120"
|
||||||
|
:placeholder="framingLabelPlaceholder(fIndex)"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-model="framing.text"
|
||||||
|
rows="2"
|
||||||
|
class="w-full rounded-lg border border-white/10 bg-zinc-950/70 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
placeholder="Place / location text copied into the prompt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 border-b border-white/10 pb-2">
|
||||||
|
<button
|
||||||
|
v-for="(scene, index) in scenes"
|
||||||
|
:key="scene.id"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full px-3 py-1.5 text-xs transition"
|
||||||
|
:class="index === activeIndex
|
||||||
|
? 'min-h-11 bg-amber-400/15 px-3 py-2 text-sm font-semibold text-amber-100 ring-1 ring-amber-300/40'
|
||||||
|
: 'min-h-11 border border-white/25 px-3 py-2 text-sm font-semibold text-zinc-300 hover:bg-white/5 hover:text-zinc-100'"
|
||||||
|
@click="activeIndex = index"
|
||||||
|
>
|
||||||
|
{{ scene.title || `Scene ${index + 1}` }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
@click="addNextScene"
|
||||||
|
>
|
||||||
|
Next scene
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="Move scene earlier"
|
||||||
|
:disabled="activeIndex <= 0"
|
||||||
|
@click="moveScene(activeIndex, -1)"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="Move scene later"
|
||||||
|
:disabled="activeIndex >= scenes.length - 1"
|
||||||
|
@click="moveScene(activeIndex, 1)"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
:disabled="scenes.length <= 1"
|
||||||
|
@click="deleteScene(activeIndex)"
|
||||||
|
>
|
||||||
|
Delete scene
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeScene" class="space-y-4">
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 flex items-center justify-between gap-2 font-medium text-zinc-300">
|
||||||
|
Scene tab name
|
||||||
|
<span class="text-[11px] font-normal text-zinc-500">optional</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model="activeScene.title"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
maxlength="60"
|
||||||
|
:placeholder="`Scene ${activeIndex + 1}`"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<template v-for="sectionKey in sectionOrder" :key="'scene-section-' + sectionKey">
|
||||||
|
<div v-if="sectionKey === 'style'" class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm font-medium text-zinc-300">Style</span>
|
||||||
|
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.style = ''">Clear</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="activeScene.style"
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
placeholder="Visual style for this image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="sectionKey === 'settings'" class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm font-medium text-zinc-300">Settings</span>
|
||||||
|
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.settings = ''">Clear</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="activeScene.settings"
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
placeholder="Lighting, framing, technical notes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="sectionKey === 'set' && sets.length" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-zinc-300">Sets in this scene</span>
|
||||||
|
<p class="text-[11px] text-zinc-500">Toggle which set blocks appear. Pick a framing for each on set.</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="set in sets"
|
||||||
|
:key="'scene-set-'+set.id"
|
||||||
|
class="rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2.5 space-y-2"
|
||||||
|
>
|
||||||
|
<label class="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
||||||
|
<span class="min-w-0 truncate">{{ set.name || 'Unnamed set' }}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="accent-amber-400"
|
||||||
|
:checked="isSetOn(set.id)"
|
||||||
|
@change="setSetOn(set.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label v-if="isSetOn(set.id)" class="block text-[11px] text-zinc-500">
|
||||||
|
Framing
|
||||||
|
<select
|
||||||
|
class="mt-1 w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"
|
||||||
|
:value="activeSetFramingId(set.id) || ''"
|
||||||
|
@change="setSetFraming(set.id, ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
<option v-for="framing in set.framings" :key="framing.id" :value="framing.id">
|
||||||
|
{{ framing.label || 'Untitled framing' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="sectionKey === 'characters'" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-zinc-300">In this scene</span>
|
||||||
|
<p class="text-[11px] text-zinc-500">Toggle who appears. Pick an outfit for each on character.</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="character in characters"
|
||||||
|
:key="'scene-'+character.id"
|
||||||
|
class="rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2.5 space-y-2"
|
||||||
|
>
|
||||||
|
<label class="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
||||||
|
<span class="min-w-0 truncate">{{ character.name || 'Unnamed character' }}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="accent-amber-400"
|
||||||
|
:checked="isCharacterOn(character.id)"
|
||||||
|
@change="setCharacterOn(character.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label v-if="isCharacterOn(character.id)" class="block text-[11px] text-zinc-500">
|
||||||
|
Outfit
|
||||||
|
<select
|
||||||
|
class="mt-1 w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"
|
||||||
|
:value="activeOutfitId(character.id) || ''"
|
||||||
|
@change="setCharacterOutfit(character.id, ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
<option v-for="outfit in character.outfits" :key="outfit.id" :value="outfit.id">
|
||||||
|
{{ outfit.name || 'Untitled outfit' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="sectionKey === 'prompt'" class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm font-medium text-zinc-300">Prompt</span>
|
||||||
|
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.prompt = ''">Clear</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="activeScene.prompt"
|
||||||
|
rows="5"
|
||||||
|
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
|
placeholder="What happens in this image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-white/10 bg-zinc-950/30">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-zinc-200">Preview</h3>
|
||||||
|
<p class="text-[11px] text-zinc-500">Assembled text ready to copy. Sequence name is omitted.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="!activeSceneText"
|
||||||
|
@click="copyScene"
|
||||||
|
>
|
||||||
|
{{ copiedScene ? 'Scene copied' : 'Copy scene' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="!previewText"
|
||||||
|
@click="copyPreview"
|
||||||
|
>
|
||||||
|
{{ copied ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
@click="previewOpen = !previewOpen"
|
||||||
|
>
|
||||||
|
{{ previewOpen ? 'Hide' : 'Show' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
v-if="previewOpen"
|
||||||
|
class="max-h-[min(28rem,50vh)] overflow-auto whitespace-pre-wrap px-4 py-3 text-xs text-zinc-300 [overflow-wrap:anywhere]"
|
||||||
|
>{{ previewText || 'Fill a scene to see the assembled prompt.' }}</pre>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="min-w-0 space-y-4">
|
||||||
|
<section class="glass rounded-3xl p-5 sm:p-6">
|
||||||
|
<h3 class="font-display text-lg font-bold">Saved sequences</h3>
|
||||||
|
<p class="mt-1 text-xs text-zinc-500">Stored with your other library data. Open one to edit.</p>
|
||||||
|
<p v-if="!sequences.length" class="mt-4 rounded-2xl border border-white/5 bg-zinc-950/40 px-4 py-6 text-center text-sm text-zinc-500">
|
||||||
|
Nothing saved yet.
|
||||||
|
</p>
|
||||||
|
<div v-else class="mt-4 space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="sequence in sequences"
|
||||||
|
:key="sequence.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-2xl border p-3 text-left transition"
|
||||||
|
:class="sequence.id === selectedId
|
||||||
|
? 'border-amber-300/30 bg-amber-400/5'
|
||||||
|
: 'border-white/5 bg-zinc-950/40 hover:border-white/15'"
|
||||||
|
@click="select(sequence)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-medium text-zinc-100">{{ sequence.name }}</p>
|
||||||
|
<p class="mt-0.5 text-[11px] text-zinc-500">
|
||||||
|
{{ sequence.scenes?.length || 0 }} scene{{ (sequence.scenes?.length || 0) === 1 ? '' : 's' }}
|
||||||
|
· {{ sequence.characters?.length || 0 }} character{{ (sequence.characters?.length || 0) === 1 ? '' : 's' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
|
||||||
|
@click.stop="remove(sequence)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
PromptSequence,
|
||||||
|
PromptSequenceCharacter,
|
||||||
|
PromptSequenceScene,
|
||||||
|
PromptSequenceSectionKey,
|
||||||
|
PromptSequenceSet
|
||||||
|
} from '~/utils/promptSequences'
|
||||||
|
import {
|
||||||
|
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||||
|
PROMPT_SEQUENCE_FRAMING_SUGGESTIONS,
|
||||||
|
assembleSceneText,
|
||||||
|
assembleSequenceText,
|
||||||
|
cloneSceneForNext,
|
||||||
|
emptyCharacter,
|
||||||
|
emptyFraming,
|
||||||
|
emptyOutfit,
|
||||||
|
emptyScene,
|
||||||
|
emptySet,
|
||||||
|
ensureSceneStatesForCharacters,
|
||||||
|
ensureSceneStatesForSets,
|
||||||
|
normalizePromptSequence,
|
||||||
|
normalizeSectionOrder
|
||||||
|
} from '~/utils/promptSequences'
|
||||||
|
|
||||||
|
useHead({ title: 'Prompt Builder' })
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<PromptSequenceSectionKey, string> = {
|
||||||
|
style: 'Style',
|
||||||
|
settings: 'Settings',
|
||||||
|
set: 'Set',
|
||||||
|
characters: 'Characters',
|
||||||
|
prompt: 'Prompt'
|
||||||
|
}
|
||||||
|
|
||||||
|
const sequences = ref<PromptSequence[]>([])
|
||||||
|
const selectedId = ref('')
|
||||||
|
const name = ref('')
|
||||||
|
const sectionOrder = ref<PromptSequenceSectionKey[]>([...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
|
||||||
|
const characters = ref<PromptSequenceCharacter[]>([emptyCharacter()])
|
||||||
|
const sets = ref<PromptSequenceSet[]>([])
|
||||||
|
const scenes = ref<PromptSequenceScene[]>([emptyScene(1, characters.value, sets.value)])
|
||||||
|
const activeIndex = ref(0)
|
||||||
|
const previewOpen = ref(true)
|
||||||
|
const busy = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const notice = ref('')
|
||||||
|
const copied = ref(false)
|
||||||
|
const copiedScene = ref(false)
|
||||||
|
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let copySceneTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let dragSectionIndex: number | null = null
|
||||||
|
|
||||||
|
const activeScene = computed(() => scenes.value[activeIndex.value] || null)
|
||||||
|
const previewText = computed(() => assembleSequenceText({
|
||||||
|
sectionOrder: sectionOrder.value,
|
||||||
|
characters: characters.value,
|
||||||
|
sets: sets.value,
|
||||||
|
scenes: scenes.value
|
||||||
|
}))
|
||||||
|
const activeSceneText = computed(() => {
|
||||||
|
if (!activeScene.value) return ''
|
||||||
|
return assembleSceneText(activeScene.value, characters.value, sets.value, sectionOrder.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function sectionLabel(key: PromptSequenceSectionKey) {
|
||||||
|
return SECTION_LABELS[key] || key
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSection(index: number, delta: number) {
|
||||||
|
const next = index + delta
|
||||||
|
if (next < 0 || next >= sectionOrder.value.length) return
|
||||||
|
const copy = [...sectionOrder.value]
|
||||||
|
const [item] = copy.splice(index, 1)
|
||||||
|
copy.splice(next, 0, item)
|
||||||
|
sectionOrder.value = copy
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSectionDragStart(index: number, event: DragEvent) {
|
||||||
|
dragSectionIndex = index
|
||||||
|
event.dataTransfer?.setData('text/plain', String(index))
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSectionDragOver(event: DragEvent) {
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSectionDrop(index: number) {
|
||||||
|
if (dragSectionIndex == null || dragSectionIndex === index) {
|
||||||
|
dragSectionIndex = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const copy = [...sectionOrder.value]
|
||||||
|
const [item] = copy.splice(dragSectionIndex, 1)
|
||||||
|
copy.splice(index, 0, item)
|
||||||
|
sectionOrder.value = copy
|
||||||
|
dragSectionIndex = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function framingLabelPlaceholder(index: number) {
|
||||||
|
const suggestion = PROMPT_SEQUENCE_FRAMING_SUGGESTIONS[index % PROMPT_SEQUENCE_FRAMING_SUGGESTIONS.length]
|
||||||
|
return suggestion || 'wide / medium / narrow'
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNew() {
|
||||||
|
selectedId.value = ''
|
||||||
|
name.value = ''
|
||||||
|
sectionOrder.value = [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]
|
||||||
|
characters.value = [emptyCharacter()]
|
||||||
|
sets.value = []
|
||||||
|
scenes.value = [emptyScene(1, characters.value, sets.value)]
|
||||||
|
activeIndex.value = 0
|
||||||
|
error.value = ''
|
||||||
|
notice.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(sequence: PromptSequence) {
|
||||||
|
const normalized = normalizePromptSequence(sequence) || sequence
|
||||||
|
selectedId.value = normalized.id
|
||||||
|
name.value = normalized.name
|
||||||
|
sectionOrder.value = normalizeSectionOrder(normalized.sectionOrder)
|
||||||
|
characters.value = (normalized.characters?.length ? normalized.characters : [emptyCharacter()]).map(character => ({
|
||||||
|
...emptyCharacter(),
|
||||||
|
...character,
|
||||||
|
outfits: (character.outfits || []).map(outfit => ({ ...emptyOutfit(), ...outfit }))
|
||||||
|
}))
|
||||||
|
sets.value = (normalized.sets || []).map(set => ({
|
||||||
|
...emptySet(),
|
||||||
|
...set,
|
||||||
|
framings: (set.framings?.length ? set.framings : [emptyFraming()]).map(framing => ({ ...emptyFraming(), ...framing }))
|
||||||
|
}))
|
||||||
|
scenes.value = (normalized.scenes?.length ? normalized.scenes : [emptyScene(1, characters.value, sets.value)]).map((scene, index) => ({
|
||||||
|
...emptyScene(index + 1, characters.value, sets.value),
|
||||||
|
...scene,
|
||||||
|
setStates: (scene.setStates || []).map(state => ({
|
||||||
|
setId: state.setId,
|
||||||
|
on: state.on !== false,
|
||||||
|
framingId: state.framingId || null
|
||||||
|
})),
|
||||||
|
characterStates: (scene.characterStates || []).map(state => ({
|
||||||
|
characterId: state.characterId,
|
||||||
|
on: state.on !== false,
|
||||||
|
outfitId: state.outfitId || null
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
ensureSceneStatesForSets(scenes.value, sets.value)
|
||||||
|
ensureSceneStatesForCharacters(scenes.value, characters.value)
|
||||||
|
activeIndex.value = 0
|
||||||
|
error.value = ''
|
||||||
|
notice.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNextScene() {
|
||||||
|
const previous = scenes.value[scenes.value.length - 1] || emptyScene(1, characters.value, sets.value)
|
||||||
|
const next = cloneSceneForNext(previous, scenes.value.length + 1)
|
||||||
|
scenes.value.push(next)
|
||||||
|
activeIndex.value = scenes.value.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveScene(index: number, delta: number) {
|
||||||
|
const newIndex = index + delta
|
||||||
|
if (newIndex < 0 || newIndex >= scenes.value.length) return
|
||||||
|
const [scene] = scenes.value.splice(index, 1)
|
||||||
|
if (!scene) return
|
||||||
|
scenes.value.splice(newIndex, 0, scene)
|
||||||
|
if (activeIndex.value === index) {
|
||||||
|
activeIndex.value = newIndex
|
||||||
|
} else if (index < activeIndex.value && newIndex >= activeIndex.value) {
|
||||||
|
activeIndex.value--
|
||||||
|
} else if (index > activeIndex.value && newIndex <= activeIndex.value) {
|
||||||
|
activeIndex.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteScene(index: number) {
|
||||||
|
if (scenes.value.length <= 1) return
|
||||||
|
scenes.value.splice(index, 1)
|
||||||
|
if (activeIndex.value >= scenes.value.length) {
|
||||||
|
activeIndex.value = scenes.value.length - 1
|
||||||
|
} else if (activeIndex.value > index) {
|
||||||
|
activeIndex.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCharacter() {
|
||||||
|
const character = emptyCharacter()
|
||||||
|
characters.value.push(character)
|
||||||
|
ensureSceneStatesForCharacters(scenes.value, characters.value, {
|
||||||
|
newCharacterId: character.id,
|
||||||
|
turnOnSceneIndex: activeIndex.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCharacter(index: number) {
|
||||||
|
const character = characters.value[index]
|
||||||
|
if (!character) return
|
||||||
|
character.name = ''
|
||||||
|
character.description = ''
|
||||||
|
character.outfits = []
|
||||||
|
for (const scene of scenes.value) {
|
||||||
|
const state = scene.characterStates.find(item => item.characterId === character.id)
|
||||||
|
if (state) state.outfitId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCharacter(index: number) {
|
||||||
|
if (characters.value.length <= 1) return
|
||||||
|
const [removed] = characters.value.splice(index, 1)
|
||||||
|
if (!removed) return
|
||||||
|
for (const scene of scenes.value) {
|
||||||
|
scene.characterStates = scene.characterStates.filter(state => state.characterId !== removed.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOutfit(characterIndex: number) {
|
||||||
|
const character = characters.value[characterIndex]
|
||||||
|
if (!character) return
|
||||||
|
character.outfits.push(emptyOutfit())
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOutfit(characterIndex: number, outfitIndex: number) {
|
||||||
|
const character = characters.value[characterIndex]
|
||||||
|
if (!character) return
|
||||||
|
const [removed] = character.outfits.splice(outfitIndex, 1)
|
||||||
|
if (!removed) return
|
||||||
|
for (const scene of scenes.value) {
|
||||||
|
const state = scene.characterStates.find(item => item.characterId === character.id)
|
||||||
|
if (state?.outfitId === removed.id) state.outfitId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSet() {
|
||||||
|
const set = emptySet()
|
||||||
|
sets.value.push(set)
|
||||||
|
ensureSceneStatesForSets(scenes.value, sets.value, {
|
||||||
|
newSetId: set.id,
|
||||||
|
turnOnSceneIndex: activeIndex.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSet(index: number) {
|
||||||
|
const set = sets.value[index]
|
||||||
|
if (!set) return
|
||||||
|
set.name = ''
|
||||||
|
set.framings = [emptyFraming()]
|
||||||
|
for (const scene of scenes.value) {
|
||||||
|
const state = scene.setStates.find(item => item.setId === set.id)
|
||||||
|
if (state) state.framingId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSet(index: number) {
|
||||||
|
const [removed] = sets.value.splice(index, 1)
|
||||||
|
if (!removed) return
|
||||||
|
for (const scene of scenes.value) {
|
||||||
|
scene.setStates = (scene.setStates || []).filter(state => state.setId !== removed.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFraming(setIndex: number) {
|
||||||
|
const set = sets.value[setIndex]
|
||||||
|
if (!set) return
|
||||||
|
set.framings.push(emptyFraming())
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFraming(setIndex: number, framingIndex: number) {
|
||||||
|
const set = sets.value[setIndex]
|
||||||
|
if (!set) return
|
||||||
|
const [removed] = set.framings.splice(framingIndex, 1)
|
||||||
|
if (!removed) return
|
||||||
|
for (const scene of scenes.value) {
|
||||||
|
const state = scene.setStates.find(item => item.setId === set.id)
|
||||||
|
if (state?.framingId === removed.id) state.framingId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFraming(setIndex: number, framingIndex: number) {
|
||||||
|
const framing = sets.value[setIndex]?.framings[framingIndex]
|
||||||
|
if (!framing) return
|
||||||
|
framing.label = ''
|
||||||
|
framing.text = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function sceneSetState(setId: string) {
|
||||||
|
return activeScene.value?.setStates?.find(state => state.setId === setId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSetOn(setId: string) {
|
||||||
|
const state = sceneSetState(setId)
|
||||||
|
return state ? !!state.on : true
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSetOn(setId: string, on: boolean) {
|
||||||
|
const state = sceneSetState(setId)
|
||||||
|
if (!state) {
|
||||||
|
activeScene.value?.setStates.push({ setId, on, framingId: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.on = on
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeSetFramingId(setId: string) {
|
||||||
|
return sceneSetState(setId)?.framingId || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSetFraming(setId: string, framingId: string) {
|
||||||
|
const state = sceneSetState(setId)
|
||||||
|
if (!state) {
|
||||||
|
activeScene.value?.setStates.push({
|
||||||
|
setId,
|
||||||
|
on: true,
|
||||||
|
framingId: framingId || null
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.framingId = framingId || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function sceneState(characterId: string) {
|
||||||
|
return activeScene.value?.characterStates.find(state => state.characterId === characterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCharacterOn(characterId: string) {
|
||||||
|
const state = sceneState(characterId)
|
||||||
|
return state ? !!state.on : true
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCharacterOn(characterId: string, on: boolean) {
|
||||||
|
const state = sceneState(characterId)
|
||||||
|
if (!state) {
|
||||||
|
activeScene.value?.characterStates.push({ characterId, on, outfitId: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.on = on
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeOutfitId(characterId: string) {
|
||||||
|
return sceneState(characterId)?.outfitId || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCharacterOutfit(characterId: string, outfitId: string) {
|
||||||
|
const state = sceneState(characterId)
|
||||||
|
if (!state) {
|
||||||
|
activeScene.value?.characterStates.push({
|
||||||
|
characterId,
|
||||||
|
on: true,
|
||||||
|
outfitId: outfitId || null
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.outfitId = outfitId || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSequences() {
|
||||||
|
const listed = await $fetch<{ sequences?: PromptSequence[] }>('/api/prompt-sequences')
|
||||||
|
sequences.value = listed.sequences || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const nextName = name.value.trim()
|
||||||
|
if (!nextName) return
|
||||||
|
const existingByName = sequences.value.find(item => item.name.toLowerCase() === nextName.toLowerCase())
|
||||||
|
const updateExisting = Boolean(selectedId.value) && (!existingByName || existingByName.id === selectedId.value)
|
||||||
|
if (existingByName && existingByName.id !== selectedId.value && !window.confirm(`Overwrite sequence “${existingByName.name}”?`)) return
|
||||||
|
busy.value = 'save'
|
||||||
|
error.value = ''
|
||||||
|
notice.value = ''
|
||||||
|
const payload = {
|
||||||
|
name: nextName,
|
||||||
|
sectionOrder: sectionOrder.value,
|
||||||
|
characters: characters.value,
|
||||||
|
sets: sets.value,
|
||||||
|
scenes: scenes.value
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (updateExisting && selectedId.value) {
|
||||||
|
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>(`/api/prompt-sequences/${selectedId.value}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
sequences.value = saved.sequences
|
||||||
|
select(saved.sequence)
|
||||||
|
notice.value = `Saved “${saved.sequence.name}”.`
|
||||||
|
} else {
|
||||||
|
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>('/api/prompt-sequences', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ...payload, overwrite: Boolean(existingByName) }
|
||||||
|
})
|
||||||
|
sequences.value = saved.sequences
|
||||||
|
select(saved.sequence)
|
||||||
|
notice.value = existingByName ? `Overwrote “${saved.sequence.name}”.` : `Saved “${saved.sequence.name}”.`
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not save that sequence'
|
||||||
|
} finally {
|
||||||
|
busy.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(sequence: PromptSequence) {
|
||||||
|
if (!window.confirm(`Delete sequence “${sequence.name}”?`)) return
|
||||||
|
busy.value = 'delete'
|
||||||
|
error.value = ''
|
||||||
|
notice.value = ''
|
||||||
|
try {
|
||||||
|
const saved = await $fetch<{ sequences: PromptSequence[] }>(`/api/prompt-sequences/${sequence.id}`, { method: 'DELETE' })
|
||||||
|
sequences.value = saved.sequences
|
||||||
|
if (selectedId.value === sequence.id) startNew()
|
||||||
|
notice.value = `Deleted “${sequence.name}”.`
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not delete that sequence'
|
||||||
|
} finally {
|
||||||
|
busy.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeClipboard(text: string, kind: 'all' | 'scene') {
|
||||||
|
if (!text) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
if (kind === 'scene') {
|
||||||
|
copiedScene.value = true
|
||||||
|
if (copySceneTimer) clearTimeout(copySceneTimer)
|
||||||
|
copySceneTimer = setTimeout(() => { copiedScene.value = false }, 1500)
|
||||||
|
} else {
|
||||||
|
copied.value = true
|
||||||
|
if (copyTimer) clearTimeout(copyTimer)
|
||||||
|
copyTimer = setTimeout(() => { copied.value = false }, 1500)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error.value = 'Could not copy to the clipboard'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPreview() {
|
||||||
|
await writeClipboard(previewText.value, 'all')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyScene() {
|
||||||
|
await writeClipboard(activeSceneText.value, 'scene')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadSequences()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.data?.statusMessage || err?.message || 'Could not load saved sequences'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (copyTimer) clearTimeout(copyTimer)
|
||||||
|
if (copySceneTimer) clearTimeout(copySceneTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -663,7 +663,7 @@ function progressLine(queue: Queue) {
|
|||||||
|
|
||||||
function jobLine(job: StudioJobRow) {
|
function jobLine(job: StudioJobRow) {
|
||||||
if (job.kind === 'music') {
|
if (job.kind === 'music') {
|
||||||
if (job.status === 'waiting') return job.cutIn ? 'Next after the current shot · ACE-Step' : 'Waiting · ACE-Step'
|
if (job.status === 'waiting') return job.cutIn ? 'Next after the current shot · YuE2' : 'Waiting · YuE2'
|
||||||
if (job.status === 'held') return 'Paused'
|
if (job.status === 'held') return 'Paused'
|
||||||
if (job.status === 'error') return job.lastError || 'Failed'
|
if (job.status === 'error') return job.lastError || 'Failed'
|
||||||
return 'Generating track'
|
return 'Generating track'
|
||||||
@@ -961,8 +961,8 @@ async function loadLog(entry: GenerationLogEntry) {
|
|||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
payload: entry.payload
|
payload: entry.payload
|
||||||
}))
|
}))
|
||||||
if (entry.kind === 'music') {
|
if (entry.payload?.studio2Id || entry.payload?.studio2Request || entry.kind === 'music') {
|
||||||
await navigateTo('/music')
|
await navigateTo(entry.kind === 'music' ? '/studio-2?task=music' : '/studio-2')
|
||||||
} else {
|
} else {
|
||||||
await navigateTo('/')
|
await navigateTo('/')
|
||||||
}
|
}
|
||||||
@@ -1237,7 +1237,7 @@ async function clearAll() {
|
|||||||
async function forceClearAll() {
|
async function forceClearAll() {
|
||||||
if (forceClearing.value) return
|
if (forceClearing.value) return
|
||||||
if (!confirm(
|
if (!confirm(
|
||||||
'Force clear cancels every running and waiting job (including stuck YuE/music), wipes shot batches, and kills Comfy if it is wedged. Saved library clips stay. Continue?'
|
'Force clear cancels every running and waiting job (including stuck YuE2/music), wipes shot batches, and kills Comfy if it is wedged. Saved library clips stay. Continue?'
|
||||||
)) return
|
)) return
|
||||||
forceClearing.value = true
|
forceClearing.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="config.public.studio === 'xaigen'" class="border-b border-amber-400/20 bg-zinc-950 px-6 py-3 text-sm text-amber-100">xAIGen 2.0 development preview · Image and MiniMax tools are available here. <NuxtLink to="/" class="underline">Open the existing studio for LTX and music</NuxtLink>.</div>
|
<div />
|
||||||
<Studio2Bench />
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const config = useRuntimeConfig()
|
await navigateTo('/', { replace: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f3f1ea" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M3 3l18 18"/>
|
||||||
|
<path d="M10.6 10.6A3 3 0 0 0 12 15a3 3 0 0 0 2.4-4.4"/>
|
||||||
|
<path d="M9.9 5.1A11 11 0 0 1 12 5c6.5 0 10 7 10 7a18 18 0 0 1-3.2 3.8"/>
|
||||||
|
<path d="M6.1 6.1C3.7 7.8 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.2-.9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 441 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f3f1ea" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||||
|
<path d="M8 11V8a4 4 0 0 1 8 0v3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1,299 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { createServer } from 'node:net'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, readdirSync, copyFileSync, createWriteStream } from 'node:fs'
|
||||||
|
import { join, resolve, extname } from 'node:path'
|
||||||
|
import { pipeline } from 'node:stream/promises'
|
||||||
|
import { Transform } from 'node:stream'
|
||||||
|
import { captionStylePrompt, CAPTION_STYLES } from '../shared/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'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer winget ggml.llamacpp; never download. Process exit after one caption = keep_alive 0. */
|
||||||
|
function resolveLlamaServer(explicit) {
|
||||||
|
if (explicit) return resolve(explicit)
|
||||||
|
if (process.env.CAPTION_LLAMA_SERVER) return resolve(process.env.CAPTION_LLAMA_SERVER)
|
||||||
|
const local = process.env.LOCALAPPDATA || ''
|
||||||
|
const candidates = [
|
||||||
|
join(local, 'Microsoft', 'WinGet', 'Packages', 'ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe', 'llama-server.exe'),
|
||||||
|
join(local, 'llama.cpp', 'llama-server.exe'),
|
||||||
|
join('C:\\', 'llama.cpp', 'llama-server.exe')
|
||||||
|
]
|
||||||
|
for (const path of candidates) {
|
||||||
|
if (existsSync(path)) return path
|
||||||
|
}
|
||||||
|
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 { /* 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 unloads VRAM (keep_alive 0). */
|
||||||
|
export function createCaptionHost({
|
||||||
|
prepare,
|
||||||
|
leaseValid,
|
||||||
|
spawnProcess = spawn,
|
||||||
|
modelsDir,
|
||||||
|
llamaServer,
|
||||||
|
dataDir,
|
||||||
|
fetchImpl = fetch,
|
||||||
|
serverWaitMs = 120_000
|
||||||
|
} = {}) {
|
||||||
|
const models = resolve(modelsDir || defaultModelsDir())
|
||||||
|
const executable = resolveLlamaServer(llamaServer)
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
const path = join(dir(id), 'status.json')
|
||||||
|
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null
|
||||||
|
}
|
||||||
|
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')
|
||||||
|
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 missing 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 = existsSync(dir(request.id)) && 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()
|
||||||
|
// One caption per process. killTree after response unloads VRAM (keep_alive 0).
|
||||||
|
const child = spawnProcess(executable, [
|
||||||
|
'-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'
|
||||||
|
], { 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, serverWaitMs, 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 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 : AbortSignal.timeout(Number(process.env.CAPTION_TIMEOUT_MS || 180_000)),
|
||||||
|
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 } }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
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 }
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Delete named YuE v1 / YuEGP weight folders only. Never recurse-wipe Comfy models\ or custom_nodes\.
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$YueGpRoot = 'C:\Users\ianjm\Development\YuEGP',
|
||||||
|
[string]$YueModels = '',
|
||||||
|
[string]$LogPath = ''
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
if (-not $YueModels) {
|
||||||
|
$YueModels = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models\yue'
|
||||||
|
}
|
||||||
|
if (-not $LogPath) {
|
||||||
|
$LogPath = Join-Path $PSScriptRoot 'cleanup-yue-v1.log'
|
||||||
|
}
|
||||||
|
function Log([string]$Message) {
|
||||||
|
$line = "$(Get-Date -Format o) $Message"
|
||||||
|
Add-Content -LiteralPath $LogPath -Value $line
|
||||||
|
Write-Host $line
|
||||||
|
}
|
||||||
|
function RemoveNamed([string]$Path) {
|
||||||
|
if (-not $Path) { return }
|
||||||
|
if (!(Test-Path -LiteralPath $Path)) {
|
||||||
|
Log "SKIP missing $Path"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Remove-Item -LiteralPath $Path -Recurse -Force
|
||||||
|
Log "DELETED $Path"
|
||||||
|
} catch {
|
||||||
|
Log "FAIL $Path :: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log "START cleanup-yue-v1 YueGpRoot=$YueGpRoot YueModels=$YueModels"
|
||||||
|
|
||||||
|
# Stage 1 / Stage 2 + codec under the shared yue models dir (named folders only)
|
||||||
|
RemoveNamed (Join-Path $YueModels 'YuE-s1-7B-anneal-en-cot')
|
||||||
|
RemoveNamed (Join-Path $YueModels 'YuE-s2-1B-general')
|
||||||
|
RemoveNamed (Join-Path $YueModels 'xcodec_mini_infer')
|
||||||
|
# Loose codec files that live beside the stage folders in this install
|
||||||
|
foreach ($name in @('ckpt_00360000.pth', 'decoder_131000.pth', 'decoder_151000.pth')) {
|
||||||
|
RemoveNamed (Join-Path $YueModels $name)
|
||||||
|
}
|
||||||
|
|
||||||
|
# YuEGP checkout codec copy + job dumps
|
||||||
|
RemoveNamed (Join-Path $YueGpRoot 'inference\xcodec_mini_infer')
|
||||||
|
RemoveNamed (Join-Path $YueGpRoot 'aigen-jobs')
|
||||||
|
|
||||||
|
# Hugging Face cache dirs that are clearly YuE v1 / old codec
|
||||||
|
$Hf = Join-Path $env:USERPROFILE '.cache\huggingface\hub'
|
||||||
|
RemoveNamed (Join-Path $Hf 'models--m-a-p--YuE-s1-7B-anneal-en-cot')
|
||||||
|
RemoveNamed (Join-Path $Hf 'models--m-a-p--YuE-s2-1B-general')
|
||||||
|
RemoveNamed (Join-Path $Hf 'models--m-a-p--xcodec_mini_infer')
|
||||||
|
|
||||||
|
# Leftover Comfy YuE Stage A/B weight copies by name only (no blanket models\ purge)
|
||||||
|
$ComfyModels = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
|
||||||
|
if (Test-Path -LiteralPath $ComfyModels) {
|
||||||
|
Get-ChildItem -LiteralPath $ComfyModels -Recurse -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -match '^(YUE_Stage_|YuE-s1-|YuE-s2-|yue_stage)' } |
|
||||||
|
ForEach-Object { RemoveNamed $_.FullName }
|
||||||
|
} else {
|
||||||
|
Log "SKIP missing $ComfyModels"
|
||||||
|
}
|
||||||
|
|
||||||
|
Log "DONE cleanup-yue-v1 log=$LogPath"
|
||||||
|
Write-Host "Left in place: ACE / H3 / Klein / Krea / LTX / upscale / LoRAs / LIBRARY_DIR / yuegp code paths."
|
||||||
@@ -3,7 +3,8 @@ import { purgeStudio2Files } from './studio2-purge.mjs'
|
|||||||
import { stableMemoryArgs } from './comfy-memory-policy.mjs'
|
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 { createYueGpHost } from './yuegp-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: () => yueGp.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 (yueGp.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,26 +831,65 @@ const gpuReservation = createGpuReservation({ idle: async () => {
|
|||||||
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
||||||
} })
|
} })
|
||||||
|
|
||||||
const yueGp = createYueGpHost({
|
async function prepareExclusiveGpu(label) {
|
||||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
||||||
prepare: async () => {
|
if (label !== 'YuE2' && yue2.busy()) throw new Error('YuE2 is using the GPU.')
|
||||||
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
if (label !== 'caption' && caption.busy()) throw new Error('Caption is using the GPU.')
|
||||||
const healthy = await syncProxy()
|
if (upscale.busy()) throw new Error('Local upscale is using the GPU.')
|
||||||
if (healthy) {
|
const healthy = await syncProxy()
|
||||||
const queue = await fetchLocalQueue(healthy)
|
if (healthy) {
|
||||||
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuEGP cannot start.')
|
const queue = await fetchLocalQueue(healthy)
|
||||||
await stopComfyProcesses()
|
if (!queue.ok || queue.running || queue.pending) throw new Error(`Comfy is busy; ${label} cannot start.`)
|
||||||
markAsleep()
|
|
||||||
}
|
|
||||||
if (await pythonMainUp()) throw new Error('Comfy has not stopped; retry after the GPU is free.')
|
|
||||||
}
|
}
|
||||||
})
|
if (healthy || await processUp() || await pythonMainUp().catch(() => false)) {
|
||||||
|
await stopComfyProcesses()
|
||||||
|
markAsleep()
|
||||||
|
}
|
||||||
|
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() })
|
||||||
|
}
|
||||||
|
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'] || '')))
|
||||||
@@ -859,20 +899,20 @@ async function handleControl(req, res) {
|
|||||||
if (match && req.method === 'GET' && !match[2]) { const job = upscale.read(match[1]); return json(res, job ? 200 : 404, job || { error: 'Upscale job not found' }) }
|
if (match && req.method === 'GET' && !match[2]) { const job = upscale.read(match[1]); return json(res, job ? 200 : 404, job || { error: 'Upscale job not found' }) }
|
||||||
return json(res, 404, { error: 'Unknown upscale endpoint' })
|
return json(res, 404, { error: 'Unknown upscale endpoint' })
|
||||||
}
|
}
|
||||||
if (url.pathname.startsWith('/yuegp/')) {
|
if (url.pathname.startsWith('/yue2/')) {
|
||||||
const match = url.pathname.match(/^\/yuegp\/jobs\/([a-zA-Z0-9-]{12,80})(\/audio|\/cancel)?$/)
|
const match = url.pathname.match(/^\/yue2\/jobs\/([a-zA-Z0-9-]{12,80})(\/audio|\/cancel)?$/)
|
||||||
if (req.method === 'GET' && url.pathname === '/yuegp/status') return json(res, 200, { configured: yueGp.configured(), busy: yueGp.busy(), backend: 'yuegp' })
|
if (req.method === 'GET' && url.pathname === '/yue2/status') return json(res, 200, { configured: yue2.configured(), busy: yue2.busy(), backend: 'yue2' })
|
||||||
if (req.method === 'POST' && url.pathname === '/yuegp/jobs') return json(res, 200, await yueGp.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
if (req.method === 'POST' && url.pathname === '/yue2/jobs') return json(res, 200, await yue2.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||||
if (match && req.method === 'POST' && match[2] === '/cancel') return json(res, 200, await yueGp.cancel(match[1]))
|
if (match && req.method === 'POST' && match[2] === '/cancel') return json(res, 200, await yue2.cancel(match[1]))
|
||||||
if (match && req.method === 'GET' && match[2] === '/audio') {
|
if (match && req.method === 'GET' && match[2] === '/audio') {
|
||||||
const path = yueGp.audio(match[1])
|
const path = yue2.audio(match[1])
|
||||||
return path ? streamFile(res, path) : json(res, 404, { error: 'Audio not ready' })
|
return path ? streamFile(res, path) : json(res, 404, { error: 'Audio not ready' })
|
||||||
}
|
}
|
||||||
if (match && req.method === 'GET' && !match[2]) {
|
if (match && req.method === 'GET' && !match[2]) {
|
||||||
const job = yueGp.read(match[1])
|
const job = yue2.read(match[1])
|
||||||
return json(res, job ? 200 : 404, job || { error: 'YuEGP job not found' })
|
return json(res, job ? 200 : 404, job || { error: 'YuE2 job not found' })
|
||||||
}
|
}
|
||||||
return json(res, 404, { error: 'Unknown YuEGP endpoint' })
|
return json(res, 404, { error: 'Unknown YuE2 endpoint' })
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && url.pathname.startsWith('/gpu/')) {
|
if (req.method === 'POST' && url.pathname.startsWith('/gpu/')) {
|
||||||
const body = await readJson(req)
|
const body = await readJson(req)
|
||||||
@@ -905,13 +945,15 @@ async function handleControl(req, res) {
|
|||||||
proxyPort,
|
proxyPort,
|
||||||
gpu: gpuReservation.availability(),
|
gpu: gpuReservation.availability(),
|
||||||
training: { busy: lastTraining.busy },
|
training: { busy: lastTraining.busy },
|
||||||
yuegp: { busy: yueGp.busy(), configured: yueGp.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 (yueGp.busy()) return json(res, 409, { message: 'YuEGP 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, {
|
||||||
@@ -1013,7 +1055,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('/yuegp/') || String(req.url || '').startsWith('/upscale/')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'YuEGP 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.' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function createGpuProxy({ target, reservation, authorized = () => true, m
|
|||||||
try {
|
try {
|
||||||
if (req.method === 'GET' || req.method === 'HEAD') await forward()
|
if (req.method === 'GET' || req.method === 'HEAD') await forward()
|
||||||
else {
|
else {
|
||||||
if (externalBusy()) { req.resume(); return reply(res, 409, { error: { message: 'YuEGP is using the GPU.' }, code: 'GPU_BUSY' }) }
|
if (externalBusy()) { req.resume(); return reply(res, 409, { error: { message: 'A music or upscale job is using the GPU.' }, code: 'GPU_BUSY' }) }
|
||||||
if (!authorized(req)) { req.resume(); return reply(res, 401, { error: 'Unauthorized' }) }
|
if (!authorized(req)) { req.resume(); return reply(res, 401, { error: 'Unauthorized' }) }
|
||||||
await reservation.permit(String(req.headers['x-aigen-gpu-lease'] || ''), async () => { markWork(); await forward() })
|
await reservation.permit(String(req.headers['x-aigen-gpu-lease'] || ''), async () => { markWork(); await forward() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,48 @@
|
|||||||
# Installs Desktop + Start Menu shortcuts that start the Comfy host agent
|
# Installs Desktop, Start Menu, and Windows Startup shortcuts that start THIS
|
||||||
# without Cursor. The scheduled task still starts the agent at logon.
|
# clone's Comfy host agent (YuE2) and replace any stale process on 8199.
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
$vbs = Join-Path $root 'scripts\launch-comfy-host-agent.vbs'
|
$vbs = Join-Path $root 'scripts\launch-comfy-host-agent.vbs'
|
||||||
|
$startPs1 = Join-Path $root 'scripts\start-comfy-host-agent.ps1'
|
||||||
if (-not (Test-Path $vbs)) { throw "Missing $vbs" }
|
if (-not (Test-Path $vbs)) { throw "Missing $vbs" }
|
||||||
|
if (-not (Test-Path $startPs1)) { throw "Missing $startPs1" }
|
||||||
|
|
||||||
$shell = New-Object -ComObject WScript.Shell
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
$desktop = [Environment]::GetFolderPath('Desktop')
|
$desktop = [Environment]::GetFolderPath('Desktop')
|
||||||
$programs = Join-Path ([Environment]::GetFolderPath('StartMenu')) 'Programs\AIGen'
|
$programs = Join-Path ([Environment]::GetFolderPath('StartMenu')) 'Programs\AIGen'
|
||||||
|
$startup = [Environment]::GetFolderPath('Startup')
|
||||||
New-Item -ItemType Directory -Force -Path $programs | Out-Null
|
New-Item -ItemType Directory -Force -Path $programs | Out-Null
|
||||||
|
|
||||||
function Save-Shortcut([string]$dir) {
|
function Save-Shortcut([string]$dir, [string]$name = 'Start Comfy Host Agent.lnk') {
|
||||||
$path = Join-Path $dir 'Start Comfy Host Agent.lnk'
|
$path = Join-Path $dir $name
|
||||||
$lnk = $shell.CreateShortcut($path)
|
$lnk = $shell.CreateShortcut($path)
|
||||||
$lnk.TargetPath = (Join-Path $env:SystemRoot 'System32\wscript.exe')
|
$lnk.TargetPath = (Join-Path $env:SystemRoot 'System32\wscript.exe')
|
||||||
$lnk.Arguments = "//B `"$vbs`""
|
$lnk.Arguments = "//B `"$vbs`""
|
||||||
$lnk.WorkingDirectory = $root
|
$lnk.WorkingDirectory = $root
|
||||||
$lnk.WindowStyle = 7
|
$lnk.WindowStyle = 7
|
||||||
$lnk.Description = 'Start the AIGen Comfy host agent in the background'
|
$lnk.Description = 'Start THIS AIGen Comfy host agent (YuE2) and replace any stale agent on 8199'
|
||||||
$lnk.Save()
|
$lnk.Save()
|
||||||
$path
|
$path
|
||||||
}
|
}
|
||||||
|
|
||||||
Save-Shortcut $desktop
|
$saved = @(
|
||||||
Save-Shortcut $programs
|
(Save-Shortcut $desktop),
|
||||||
|
(Save-Shortcut $programs),
|
||||||
|
(Save-Shortcut $startup 'AIGen Comfy Host Agent.lnk')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Point the scheduled task at this clone when it already exists.
|
||||||
|
$taskName = 'AIGen Comfy Host Agent'
|
||||||
|
$existing = schtasks /Query /TN $taskName 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
$hidden = Join-Path $root 'scripts\start-comfy-host-agent-hidden.vbs'
|
||||||
|
$tr = "wscript.exe //B `"$hidden`""
|
||||||
|
schtasks /Change /TN $taskName /TR $tr | Out-Null
|
||||||
|
Write-Host "Updated scheduled task '$taskName' -> $hidden"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Installed launchers for $root"
|
||||||
|
$saved | ForEach-Object { Write-Host " $_" }
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'One click: double-click Desktop "Start Comfy Host Agent", or log off/on so Startup runs it.'
|
||||||
|
Write-Host 'That replaces any old node on 8199 with this clone (YuE2 /yue2/status).'
|
||||||
|
|||||||
@@ -1,35 +1,14 @@
|
|||||||
' Start the Comfy host agent with no window. Safe to run when Cursor is closed.
|
' Start THIS clone's Comfy host agent (YuE2). Replaces any stale agent on 8199.
|
||||||
' Uses the "AIGen Comfy Host Agent" scheduled task when it exists.
|
' Safe to run at logon / from Startup / Desktop shortcut when Cursor is closed.
|
||||||
Option Explicit
|
Option Explicit
|
||||||
Dim sh, fso, root, node, agent, rc
|
Dim sh, fso, root, ps1
|
||||||
Set sh = CreateObject("WScript.Shell")
|
Set sh = CreateObject("WScript.Shell")
|
||||||
Set fso = CreateObject("Scripting.FileSystemObject")
|
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||||
|
|
||||||
If AgentRunning() Then WScript.Quit 0
|
|
||||||
|
|
||||||
On Error Resume Next
|
|
||||||
rc = sh.Run("schtasks /run /tn ""AIGen Comfy Host Agent""", 0, True)
|
|
||||||
On Error GoTo 0
|
|
||||||
WScript.Sleep 1200
|
|
||||||
If AgentRunning() Then WScript.Quit 0
|
|
||||||
|
|
||||||
root = fso.GetParentFolderName(WScript.ScriptFullName)
|
root = fso.GetParentFolderName(WScript.ScriptFullName)
|
||||||
root = fso.GetParentFolderName(root)
|
root = fso.GetParentFolderName(root)
|
||||||
node = """C:\Program Files\nodejs\node.exe"""
|
ps1 = root & "\scripts\start-comfy-host-agent.ps1"
|
||||||
agent = """" & root & "\scripts\comfy-host-agent.mjs"""
|
If Not fso.FileExists(ps1) Then WScript.Quit 1
|
||||||
If Not fso.FileExists(Replace(node, """", "")) Then WScript.Quit 1
|
|
||||||
sh.CurrentDirectory = root
|
|
||||||
sh.Run node & " " & agent, 0, False
|
|
||||||
|
|
||||||
Function AgentRunning()
|
sh.CurrentDirectory = root
|
||||||
Dim svc, query, item
|
sh.Run "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File """ & ps1 & """", 0, False
|
||||||
AgentRunning = False
|
|
||||||
Set svc = GetObject("winmgmts:\\.\root\cimv2")
|
|
||||||
Set query = svc.ExecQuery("SELECT CommandLine FROM Win32_Process WHERE Name='node.exe'")
|
|
||||||
For Each item In query
|
|
||||||
If InStr(LCase("" & item.CommandLine), "comfy-host-agent") > 0 Then
|
|
||||||
AgentRunning = True
|
|
||||||
Exit Function
|
|
||||||
End If
|
|
||||||
Next
|
|
||||||
End Function
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Caption (Qwen2.5-VL NSFW Caption V4 GGUF) — verify host files only.
|
||||||
|
# Models are already on the 5080 host. Do not download. Do not search Hugging Face.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Resolve-ModelsRoot {
|
||||||
|
if ($env:COMFY_MODELS_ROOT) { return $env:COMFY_MODELS_ROOT }
|
||||||
|
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
|
||||||
|
if (Test-Path $shared) { return $shared }
|
||||||
|
throw 'Set COMFY_MODELS_ROOT or install Comfy Desktop Shared models.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = Join-Path (Resolve-ModelsRoot) 'caption\qwen25vl-7b-nsfw-v4'
|
||||||
|
if (-not (Test-Path $root)) {
|
||||||
|
throw "Caption models folder missing: $root"
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = @(
|
||||||
|
'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf',
|
||||||
|
'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($name in $files) {
|
||||||
|
$path = Join-Path $root $name
|
||||||
|
if (-not (Test-Path $path)) {
|
||||||
|
throw "Missing $name under $root. Do not download; use the files already on this host."
|
||||||
|
}
|
||||||
|
Write-Host "OK $name"
|
||||||
|
}
|
||||||
|
|
||||||
|
$llama = $null
|
||||||
|
if ($env:CAPTION_LLAMA_SERVER -and (Test-Path $env:CAPTION_LLAMA_SERVER)) {
|
||||||
|
$llama = Get-Item $env:CAPTION_LLAMA_SERVER
|
||||||
|
} else {
|
||||||
|
$winget = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe\llama-server.exe'
|
||||||
|
if (Test-Path $winget) { $llama = Get-Item $winget }
|
||||||
|
else { $llama = Get-Command llama-server -ErrorAction SilentlyContinue }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $llama) {
|
||||||
|
throw 'llama-server not found. Install with: winget install ggml.llamacpp'
|
||||||
|
}
|
||||||
|
$llamaPath = if ($llama.PSObject.Properties['FullName'] -and $llama.FullName) { $llama.FullName } elseif ($llama.PSObject.Properties['Source'] -and $llama.Source) { $llama.Source } else { [string]$llama }
|
||||||
|
Write-Host "llama-server: $llamaPath"
|
||||||
|
|
||||||
|
$ready = @{
|
||||||
|
model = 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'
|
||||||
|
mmproj = 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
|
||||||
|
modelsDir = $root
|
||||||
|
llamaServer = $llamaPath
|
||||||
|
readyAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||||
|
} | ConvertTo-Json
|
||||||
|
Set-Content -Path (Join-Path $root 'aigen-ready.json') -Value $ready -Encoding utf8
|
||||||
|
Write-Host "Caption ready: $root"
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# Download Qwen Image 2.1 GGUF + companions into the live Comfy Desktop Shared models tree.
|
||||||
|
# Also ensures city96/ComfyUI-GGUF is present on the ComfyUI (1) install.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||||
|
# powershell -File scripts\setup-qwen21.ps1 -SkipDownload
|
||||||
|
# powershell -File scripts\setup-qwen21.ps1 -ComfyRoot "C:\...\ComfyUI (1)\ComfyUI"
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ComfyRoot = '',
|
||||||
|
[string]$ModelsRoot = '',
|
||||||
|
[switch]$SkipDownload,
|
||||||
|
[switch]$SkipNode,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'Continue'
|
||||||
|
|
||||||
|
function Write-Step([string]$Message) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ok([string]$Message) {
|
||||||
|
Write-Host " $Message" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Warn([string]$Message) {
|
||||||
|
Write-Host " warn $Message" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ComfyRoot([string]$Hint) {
|
||||||
|
$candidates = @(
|
||||||
|
$Hint,
|
||||||
|
$env:COMFY_ROOT,
|
||||||
|
$env:COMFYUI_ROOT,
|
||||||
|
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI'),
|
||||||
|
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI\ComfyUI'),
|
||||||
|
'C:\ComfyUI',
|
||||||
|
(Join-Path $env:USERPROFILE 'ComfyUI')
|
||||||
|
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||||
|
|
||||||
|
foreach ($path in $candidates) {
|
||||||
|
if (Test-Path (Join-Path $path 'main.py')) { return (Resolve-Path $path).Path }
|
||||||
|
if (Test-Path (Join-Path $path 'ComfyUI\main.py')) { return (Resolve-Path (Join-Path $path 'ComfyUI')).Path }
|
||||||
|
}
|
||||||
|
throw "Could not find ComfyUI root (main.py). Pass -ComfyRoot."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ModelsRoot([string]$Hint, [string]$ComfyRootPath) {
|
||||||
|
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
|
||||||
|
$candidates = @(
|
||||||
|
$Hint,
|
||||||
|
$env:COMFY_MODELS_ROOT,
|
||||||
|
$shared,
|
||||||
|
(Join-Path $ComfyRootPath 'models')
|
||||||
|
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||||
|
|
||||||
|
foreach ($path in $candidates) {
|
||||||
|
if (Test-Path $path) { return (Resolve-Path $path).Path }
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path $shared | Out-Null
|
||||||
|
return (Resolve-Path $shared).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-HfCli {
|
||||||
|
foreach ($name in @('hf', 'huggingface-cli')) {
|
||||||
|
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||||
|
if ($cmd) { return $cmd.Source }
|
||||||
|
}
|
||||||
|
throw "Hugging Face CLI not found (hf / huggingface-cli). Install with: pip install -U huggingface_hub"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-Dir([string]$Path) {
|
||||||
|
if (-not (Test-Path $Path)) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $Path | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ModelFile([string]$Path, [long]$MinBytes) {
|
||||||
|
if (-not (Test-Path $Path)) { throw "Missing model file: $Path" }
|
||||||
|
$item = Get-Item $Path
|
||||||
|
if ($item.Length -lt $MinBytes) {
|
||||||
|
throw "Suspicious size for $Path ($($item.Length) bytes). Expected >= $MinBytes. Delete and re-run."
|
||||||
|
}
|
||||||
|
Write-Ok ("{0} {1:N0} bytes" -f $item.FullName, $item.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Flatten-IfNested([string]$DestDir, [string]$FileName) {
|
||||||
|
$nested = Get-ChildItem $DestDir -Recurse -File -Filter $FileName -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.DirectoryName -ne $DestDir } |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($nested) {
|
||||||
|
$target = Join-Path $DestDir $FileName
|
||||||
|
if ((Test-Path $target) -and -not $Force) {
|
||||||
|
Write-Warn "Nested copy found but destination already exists: $target"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Move-Item -Force $nested.FullName $target
|
||||||
|
Write-Ok "Flattened $($nested.FullName) -> $target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-HfFile {
|
||||||
|
param(
|
||||||
|
[string]$Cli,
|
||||||
|
[string]$Repo,
|
||||||
|
[string]$File,
|
||||||
|
[string]$LocalDir
|
||||||
|
)
|
||||||
|
Ensure-Dir $LocalDir
|
||||||
|
$dest = Join-Path $LocalDir (Split-Path $File -Leaf)
|
||||||
|
if ((Test-Path $dest) -and -not $Force) {
|
||||||
|
$len = (Get-Item $dest).Length
|
||||||
|
if ($len -gt 1MB) {
|
||||||
|
Write-Ok "Already present: $dest ($len bytes)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Step "hf download $Repo $File"
|
||||||
|
& $Cli download $Repo $File --local-dir $LocalDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "hf download failed for $Repo / $File (exit $LASTEXITCODE)" }
|
||||||
|
Flatten-IfNested -DestDir $LocalDir -FileName (Split-Path $File -Leaf)
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- resolve paths ---
|
||||||
|
Write-Step "Resolve ComfyUI root"
|
||||||
|
$root = Resolve-ComfyRoot $ComfyRoot
|
||||||
|
Write-Ok $root
|
||||||
|
|
||||||
|
Write-Step "Resolve models root (Desktop Shared preferred)"
|
||||||
|
$models = Resolve-ModelsRoot $ModelsRoot $root
|
||||||
|
Write-Ok $models
|
||||||
|
|
||||||
|
$Diff = Join-Path $models 'diffusion_models'
|
||||||
|
$TE = Join-Path $models 'text_encoders'
|
||||||
|
$VAE = Join-Path $models 'vae'
|
||||||
|
Ensure-Dir $Diff
|
||||||
|
Ensure-Dir $TE
|
||||||
|
Ensure-Dir $VAE
|
||||||
|
|
||||||
|
if (-not $SkipDownload) {
|
||||||
|
$cli = Find-HfCli
|
||||||
|
Write-Ok "HF CLI: $cli"
|
||||||
|
|
||||||
|
# DiT GGUF only (no Q6/Q5/Q4, no full bf16 DiT)
|
||||||
|
# Exact invocation required on this host:
|
||||||
|
# hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||||
|
# That form writes into the Hugging Face hub cache (not --local-dir).
|
||||||
|
Write-Step "hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf"
|
||||||
|
$ditName = 'qwen-image-2.1-Q8_0.gguf'
|
||||||
|
$ditDest = Join-Path $Diff $ditName
|
||||||
|
if ((Test-Path $ditDest) -and -not $Force -and (Get-Item $ditDest).Length -gt 1MB) {
|
||||||
|
Write-Ok "Already present: $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||||
|
} else {
|
||||||
|
& $cli download "hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/$ditName"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "hf download failed for DiT GGUF (exit $LASTEXITCODE)" }
|
||||||
|
$cacheRoot = Join-Path $env:USERPROFILE '.cache\huggingface\hub\models--abenzerps--Qwen-Image-2.1-Uncensored-GGUF'
|
||||||
|
$found = Get-ChildItem $cacheRoot -Recurse -File -Filter $ditName -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $found) { throw "hf download finished but $ditName was not found under $cacheRoot" }
|
||||||
|
Ensure-Dir $Diff
|
||||||
|
Copy-Item -Force $found.FullName $ditDest
|
||||||
|
Write-Ok "Copied $($found.FullName) -> $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# abenzerps ships kv_count=0 + Q8_0 1D RMSNorms (packed 136 vs logical 128).
|
||||||
|
# Tag architecture=qwen_image and promote small/1D tensors to F32 for ComfyUI-GGUF.
|
||||||
|
Write-Step "Tag DiT GGUF (architecture + F32 norms)"
|
||||||
|
$tagger = Join-Path $PSScriptRoot 'tag-qwen21-gguf.py'
|
||||||
|
$pyCandidates = @(
|
||||||
|
(Join-Path $root '.venv\Scripts\python.exe'),
|
||||||
|
(Join-Path (Split-Path $root) 'standalone-env\python.exe'),
|
||||||
|
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)
|
||||||
|
) | Where-Object { $_ -and (Test-Path $_) }
|
||||||
|
$py = $pyCandidates | Select-Object -First 1
|
||||||
|
if (-not $py) { throw "No Python found to run $tagger" }
|
||||||
|
$fixed = Join-Path $Diff 'qwen-image-2.1-Q8_0.fixed.gguf'
|
||||||
|
& $py $tagger $ditDest $fixed
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "tag-qwen21-gguf.py failed (exit $LASTEXITCODE)" }
|
||||||
|
Copy-Item -Force $fixed $ditDest
|
||||||
|
Write-Ok "Installed tagged GGUF at $ditDest"
|
||||||
|
|
||||||
|
# TE + VAE companions from Comfy-Org (INT8 TE for 16 GB VRAM)
|
||||||
|
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'text_encoders/qwen3vl_8b_int8_convrot.safetensors' -LocalDir $TE
|
||||||
|
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'vae/qwen_image_2.1_vae_bf16.safetensors' -LocalDir $VAE
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Verify weights"
|
||||||
|
Assert-ModelFile (Join-Path $Diff 'qwen-image-2.1-Q8_0.gguf') 7000000000
|
||||||
|
Assert-ModelFile (Join-Path $TE 'qwen3vl_8b_int8_convrot.safetensors') 1000000000
|
||||||
|
Assert-ModelFile (Join-Path $VAE 'qwen_image_2.1_vae_bf16.safetensors') 100000000
|
||||||
|
|
||||||
|
if (-not $SkipNode) {
|
||||||
|
Write-Step "city96 ComfyUI-GGUF"
|
||||||
|
$nodes = Join-Path $root 'custom_nodes\ComfyUI-GGUF'
|
||||||
|
if (-not (Test-Path $nodes)) {
|
||||||
|
git clone https://github.com/city96/ComfyUI-GGUF $nodes
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git clone ComfyUI-GGUF failed" }
|
||||||
|
Write-Ok "Cloned $nodes"
|
||||||
|
} else {
|
||||||
|
Write-Ok "Already present: $nodes"
|
||||||
|
}
|
||||||
|
|
||||||
|
$py = Join-Path $root '.venv\Scripts\python.exe'
|
||||||
|
if (-not (Test-Path $py)) {
|
||||||
|
$py = Join-Path (Split-Path $root) 'standalone-env\python.exe'
|
||||||
|
}
|
||||||
|
if (Test-Path $py) {
|
||||||
|
Write-Step "Ensure gguf package in Comfy python ($py)"
|
||||||
|
& $py -m pip install -q gguf
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Warn "pip install gguf returned $LASTEXITCODE" }
|
||||||
|
else { Write-Ok "gguf importable" }
|
||||||
|
} else {
|
||||||
|
Write-Warn "Comfy python not found; install gguf into the interpreter that runs main.py"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# PE system prompts (official steps + Adult appendix). Re-copy after the node pack is re-cloned.
|
||||||
|
Write-Step "Install Qwen PE system prompts (host/qwen21-pe-prompts)"
|
||||||
|
$peSrc = Join-Path (Split-Path $PSScriptRoot -Parent) 'host\qwen21-pe-prompts'
|
||||||
|
$peDest = Join-Path $root 'custom_nodes\ComfyUI-Qwen-Image-2.1-Prompt-Enhancer\prompts'
|
||||||
|
if (-not (Test-Path $peSrc)) {
|
||||||
|
Write-Warn "Missing $peSrc — skip PE prompt install"
|
||||||
|
} elseif (-not (Test-Path (Join-Path $root 'custom_nodes\ComfyUI-Qwen-Image-2.1-Prompt-Enhancer'))) {
|
||||||
|
Write-Warn "PE node pack not installed at $peDest — skip prompt copy"
|
||||||
|
} else {
|
||||||
|
Ensure-Dir $peDest
|
||||||
|
foreach ($name in @('system_prompt_t2i.txt', 'system_prompt_edit.txt')) {
|
||||||
|
$from = Join-Path $peSrc $name
|
||||||
|
$to = Join-Path $peDest $name
|
||||||
|
if (-not (Test-Path $from)) { throw "Missing PE prompt source: $from" }
|
||||||
|
Copy-Item -Force $from $to
|
||||||
|
Write-Ok "Copied $name -> $to"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Done. Restart Comfy only when /status shows gpu.busy=false." -ForegroundColor Cyan
|
||||||
|
Write-Host "ComfyRoot=$root"
|
||||||
|
Write-Host "ModelsRoot=$models"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Standalone YuE2 only. Never install into Comfy's Python environment.
|
||||||
|
# Downloads YuE2-3B + YuE2-Vae into YUE2_ROOT\models so the UI does not hit HF on first Generate.
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Root = 'C:\Users\ianjm\Development\YuE2',
|
||||||
|
[string]$Uv = 'uv',
|
||||||
|
[string]$Models = ''
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$Root = [IO.Path]::GetFullPath($Root)
|
||||||
|
if (-not $Models) { $Models = Join-Path $Root 'models' }
|
||||||
|
$Models = [IO.Path]::GetFullPath($Models)
|
||||||
|
function Run([string]$Exe, [string[]]$Arguments) {
|
||||||
|
& $Exe @Arguments
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "$Exe failed ($LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
Write-Host "YuE2 setup root=$Root models=$Models"
|
||||||
|
if (!(Test-Path -LiteralPath $Root)) {
|
||||||
|
Run git @('clone', 'https://github.com/multimodal-art-projection/YuE.git', $Root)
|
||||||
|
}
|
||||||
|
Run $Uv @('venv', '--allow-existing', '--python', '3.12', (Join-Path $Root '.venv'))
|
||||||
|
$python = Join-Path $Root '.venv\Scripts\python.exe'
|
||||||
|
# Install the package first, then force CUDA torch back on (editable deps can pull CPU wheels).
|
||||||
|
Run $Uv @('pip', 'install', '--python', $python, '-e', $Root)
|
||||||
|
Run $Uv @('pip', 'install', '--python', $python, 'soundfile', 'psutil', 'huggingface_hub', 'hf_xet')
|
||||||
|
Run $Uv @('pip', 'install', '--python', $python, 'torch', 'torchaudio', '--reinstall', '--index-url', 'https://download.pytorch.org/whl/cu128')
|
||||||
|
New-Item -ItemType Directory -Force -Path $Models | Out-Null
|
||||||
|
$modelDir = Join-Path $Models 'YuE2-3B'
|
||||||
|
$vaeDir = Join-Path $Models 'YuE2-Vae'
|
||||||
|
if (!(Test-Path -LiteralPath (Join-Path $modelDir 'config.json')) -and !(Test-Path -LiteralPath (Join-Path $modelDir 'model.safetensors.index.json'))) {
|
||||||
|
Write-Host "Downloading m-a-p/YuE2-3B → $modelDir"
|
||||||
|
Run $python @(
|
||||||
|
'-c',
|
||||||
|
'from huggingface_hub import snapshot_download; import sys; snapshot_download(sys.argv[1], local_dir=sys.argv[2])',
|
||||||
|
'm-a-p/YuE2-3B',
|
||||||
|
$modelDir
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Write-Host "Reusing existing model at $modelDir"
|
||||||
|
}
|
||||||
|
if (!(Test-Path -LiteralPath (Join-Path $vaeDir 'config.json'))) {
|
||||||
|
Write-Host "Downloading m-a-p/YuE2-Vae → $vaeDir"
|
||||||
|
Run $python @(
|
||||||
|
'-c',
|
||||||
|
'from huggingface_hub import snapshot_download; import sys; snapshot_download(sys.argv[1], local_dir=sys.argv[2])',
|
||||||
|
'm-a-p/YuE2-Vae',
|
||||||
|
$vaeDir
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Write-Host "Reusing existing VAE at $vaeDir"
|
||||||
|
}
|
||||||
|
Run $python @('-c', 'import torch; from yue2 import YuE2Pipeline; assert torch.cuda.is_available(); print(torch.__version__, torch.cuda.get_device_name(0))')
|
||||||
|
$ready = @{
|
||||||
|
model = $modelDir
|
||||||
|
vae = $vaeDir
|
||||||
|
modelId = 'm-a-p/YuE2-3B'
|
||||||
|
vaeId = 'm-a-p/YuE2-Vae'
|
||||||
|
modelsDir = $Models
|
||||||
|
python = $python
|
||||||
|
root = $Root
|
||||||
|
}
|
||||||
|
$ready | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Root 'aigen-ready.json') -Encoding utf8
|
||||||
|
Write-Host "YuE2 ready at $Root"
|
||||||
|
Write-Host "Local model=$modelDir"
|
||||||
|
Write-Host "Local vae=$vaeDir"
|
||||||
|
Write-Host "Set YUE2_ROOT=$Root YUE2_PYTHON=$python YUE2_MODEL=$modelDir YUE2_VAE=$vaeDir if not using defaults. No Comfy environment was modified."
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
' Launch the Comfy host agent with no console window.
|
' Launch the Comfy host agent with no console window (Task Scheduler wait mode).
|
||||||
' Window style 0 = hidden. Wait = True so Task Scheduler keeps the task marked Running.
|
' Replaces any stale agent on 8199, then runs THIS clone's agent until it exits.
|
||||||
Option Explicit
|
Option Explicit
|
||||||
Dim sh, root, node, agent
|
Dim sh, fso, root, ps1
|
||||||
Set sh = CreateObject("WScript.Shell")
|
Set sh = CreateObject("WScript.Shell")
|
||||||
root = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
|
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||||
root = CreateObject("Scripting.FileSystemObject").GetParentFolderName(root)
|
|
||||||
node = """C:\Program Files\nodejs\node.exe"""
|
root = fso.GetParentFolderName(WScript.ScriptFullName)
|
||||||
agent = """" & root & "\scripts\comfy-host-agent.mjs"""
|
root = fso.GetParentFolderName(root)
|
||||||
|
ps1 = root & "\scripts\start-comfy-host-agent.ps1"
|
||||||
|
If Not fso.FileExists(ps1) Then WScript.Quit 1
|
||||||
|
|
||||||
sh.CurrentDirectory = root
|
sh.CurrentDirectory = root
|
||||||
sh.Run node & " " & agent, 0, True
|
' Wait=True so Task Scheduler keeps the task marked Running while the agent lives.
|
||||||
|
sh.Run "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File """ & ps1 & """ -Wait", 0, True
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Replace any stale Comfy host agent, then start THIS clone's agent on 8199.
|
||||||
|
# Called by launch-comfy-host-agent.vbs (Startup / Desktop / Start Menu).
|
||||||
|
# -Wait: run in-foreground for Task Scheduler (keeps task marked Running).
|
||||||
|
param([switch]$Wait)
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
$root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
|
$node = 'C:\Program Files\nodejs\node.exe'
|
||||||
|
$agent = Join-Path $root 'scripts\comfy-host-agent.mjs'
|
||||||
|
if (-not (Test-Path $node)) { throw "Missing $node" }
|
||||||
|
if (-not (Test-Path $agent)) { throw "Missing $agent" }
|
||||||
|
|
||||||
|
Get-CimInstance Win32_Process -Filter "Name='node.exe'" |
|
||||||
|
Where-Object { $_.CommandLine -match 'comfy-host-agent' } |
|
||||||
|
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||||
|
|
||||||
|
Get-NetTCPConnection -LocalPort 8199 -State Listen -ErrorAction SilentlyContinue |
|
||||||
|
ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
|
||||||
|
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
|
||||||
|
# Caption VLM — point at existing host GGUFs (no download).
|
||||||
|
$captionModels = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models\caption\qwen25vl-7b-nsfw-v4'
|
||||||
|
$llamaServer = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe\llama-server.exe'
|
||||||
|
if (Test-Path $captionModels) { $env:CAPTION_MODELS = $captionModels }
|
||||||
|
if (Test-Path $llamaServer) { $env:CAPTION_LLAMA_SERVER = $llamaServer }
|
||||||
|
|
||||||
|
if ($Wait) {
|
||||||
|
Set-Location $root
|
||||||
|
& $node $agent
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
|
$psi.FileName = $node
|
||||||
|
$psi.Arguments = "`"$agent`""
|
||||||
|
$psi.WorkingDirectory = $root
|
||||||
|
$psi.UseShellExecute = $false
|
||||||
|
$psi.CreateNoWindow = $true
|
||||||
|
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
||||||
|
# CAPTION_* already set on this process; child inherits when UseShellExecute is false
|
||||||
|
[void][System.Diagnostics.Process]::Start($psi)
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, readdirSync } from 'node:fs'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
export function validateYue2Request(body) {
|
||||||
|
const duration = body.duration ?? 60
|
||||||
|
if (!Number.isInteger(duration) || duration < 15 || duration > 150) throw new Error('YuE2 target length must be 15–150 seconds.')
|
||||||
|
if (!Number.isInteger(body.seed) || body.seed < 0 || body.seed > 2147483647) throw new Error('Invalid seed.')
|
||||||
|
if (!/^[a-zA-Z0-9-]{12,80}$/.test(body.id || '')) throw new Error('Invalid job ID.')
|
||||||
|
const tags = String(body.tags || '').trim()
|
||||||
|
const lyrics = String(body.lyrics || '').trim()
|
||||||
|
if (!tags || tags.length > 2000 || !lyrics || lyrics.length > 8000) throw new Error('Genre tags and non-empty lyrics are required.')
|
||||||
|
return { id: body.id, duration, seed: body.seed, tags, lyrics }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One isolated Python process per song. A process exit is the GPU release boundary. */
|
||||||
|
export function createYue2Host({ prepare, leaseValid, spawnProcess = spawn, root, python, dataDir } = {}) {
|
||||||
|
const repo = resolve(root || process.env.YUE2_ROOT || 'C:\\Users\\ianjm\\Development\\YuE2')
|
||||||
|
const executable = python || process.env.YUE2_PYTHON || join(repo, '.venv', 'Scripts', 'python.exe')
|
||||||
|
const data = resolve(dataDir || process.env.YUE2_JOBS_DIR || join(repo, 'aigen-jobs'))
|
||||||
|
const worker = fileURLToPath(new URL('./yue2-worker.py', import.meta.url))
|
||||||
|
let active = null
|
||||||
|
// A worker whose parent died exits within two seconds. Block new GPU owners
|
||||||
|
// across a host restart until that watchdog has had time to run.
|
||||||
|
const restartHoldUntil = existsSync(data) && readdirSync(data).some(id => {
|
||||||
|
try { return ['running', 'starting', 'cancelling'].includes(JSON.parse(readFileSync(join(data, id, 'status.json'), 'utf8')).status) }
|
||||||
|
catch { return false }
|
||||||
|
}) ? Date.now() + 10000 : 0
|
||||||
|
const dir = id => {
|
||||||
|
if (!/^[a-zA-Z0-9-]{12,80}$/.test(id || '')) throw new Error('Invalid 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 }
|
||||||
|
const path = join(dir(id), 'status.json')
|
||||||
|
if (!existsSync(path)) return null
|
||||||
|
const job = JSON.parse(readFileSync(path, 'utf8'))
|
||||||
|
if (['running', 'starting', 'cancelling'].includes(job.status)) {
|
||||||
|
job.status = 'error'; job.error = 'YuE2 host restarted. Intermediate files are preserved.'
|
||||||
|
}
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
busy: () => Boolean(active) || Date.now() < restartHoldUntil,
|
||||||
|
configured: () => existsSync(executable) && existsSync(join(repo, 'aigen-ready.json')),
|
||||||
|
read,
|
||||||
|
audio(id) {
|
||||||
|
return read(id)?.status === 'complete' ? join(dir(id), 'audio.wav') : null
|
||||||
|
},
|
||||||
|
async start(body, lease) {
|
||||||
|
const request = validateYue2Request(body)
|
||||||
|
const previous = read(request.id)
|
||||||
|
if (previous) return previous // POST retries cannot generate the same song twice.
|
||||||
|
if (active || Date.now() < restartHoldUntil) throw Object.assign(new Error('YuE2 is already running or releasing a previous worker.'), { statusCode: 409 })
|
||||||
|
if (!existsSync(executable) || !existsSync(join(repo, 'aigen-ready.json'))) throw new Error('YuE2 is not installed. Run scripts/setup-yue2.ps1 on the GPU host.')
|
||||||
|
const job = { id: request.id, status: 'starting', message: 'Preparing GPU for YuE2', progress: 0, startedAt: Date.now(), checkedAt: Date.now() }
|
||||||
|
active = { job, child: null, cancelled: false }
|
||||||
|
const run = active
|
||||||
|
mkdirSync(dir(job.id), { recursive: true })
|
||||||
|
persist(job)
|
||||||
|
try {
|
||||||
|
await prepare()
|
||||||
|
if (run.cancelled || !leaseValid(lease)) throw new Error('YuE2 start cancelled or GPU reservation expired.')
|
||||||
|
const installation = JSON.parse(readFileSync(join(repo, 'aigen-ready.json'), 'utf8').replace(/^\uFEFF/, ''))
|
||||||
|
const localModel = process.env.YUE2_MODEL || installation.model || 'm-a-p/YuE2-3B'
|
||||||
|
const localVae = process.env.YUE2_VAE || installation.vae || 'm-a-p/YuE2-Vae'
|
||||||
|
writeFileSync(join(dir(job.id), 'request.json'), JSON.stringify({
|
||||||
|
...request,
|
||||||
|
model: localModel,
|
||||||
|
vae: localVae
|
||||||
|
}))
|
||||||
|
const child = spawnProcess(executable, ['-u', worker, '--root', repo, '--request', join(dir(job.id), 'request.json')],
|
||||||
|
{ cwd: repo, windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PYTHONUTF8: '1', TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD: '1' } })
|
||||||
|
run.child = child
|
||||||
|
job.status = 'running'; job.message = 'Loading YuE2'; persist(job)
|
||||||
|
let tail = ''
|
||||||
|
let lines = ''
|
||||||
|
const log = chunk => { appendFileSync(join(dir(job.id), 'worker.log'), chunk); tail = (tail + chunk.toString()).slice(-6000) }
|
||||||
|
child.stderr.on('data', log)
|
||||||
|
child.stdout.on('data', chunk => {
|
||||||
|
log(chunk); lines += chunk.toString()
|
||||||
|
const parts = lines.split(/\r?\n/); lines = parts.pop().slice(-64000)
|
||||||
|
for (const line of parts) {
|
||||||
|
if (!line.startsWith('AIGEN_EVENT ')) continue
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(line.slice(12))
|
||||||
|
for (const key of ['stage', 'message', 'progress', 'step', 'maxStep', 'error', 'duration']) if (event[key] !== undefined) job[key] = event[key]
|
||||||
|
job.checkedAt = Date.now(); persist(job)
|
||||||
|
} catch { /* Invalid log lines are not state transitions. */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const watchdog = setInterval(() => {
|
||||||
|
if (!leaseValid(lease)) { job.error = 'GPU reservation expired; YuE2 stopped.'; child.kill() }
|
||||||
|
}, 5000)
|
||||||
|
let finished = false
|
||||||
|
const finish = (code, error) => {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
clearInterval(watchdog)
|
||||||
|
job.status = run.cancelled ? 'cancelled' : !error && code === 0 && existsSync(join(dir(job.id), 'audio.wav')) ? 'complete' : 'error'
|
||||||
|
if (job.status === 'error') job.error ||= error?.message || tail || `YuE2 exited with code ${code}`
|
||||||
|
job.message = job.status === 'complete' ? 'Audio ready' : job.status === 'cancelled' ? 'Cancelled' : job.error
|
||||||
|
job.checkedAt = Date.now(); persist(job)
|
||||||
|
if (active === run) active = null
|
||||||
|
}
|
||||||
|
child.once('error', error => finish(null, error))
|
||||||
|
child.once('close', code => finish(code))
|
||||||
|
return { ...job }
|
||||||
|
} catch (error) {
|
||||||
|
job.status = run.cancelled ? 'cancelled' : 'error'; job.error = error.message; persist(job)
|
||||||
|
if (active === run) active = null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async cancel(id) {
|
||||||
|
if (active?.job.id !== id) return read(id)
|
||||||
|
const run = active
|
||||||
|
run.cancelled = true
|
||||||
|
run.job.status = 'cancelling'
|
||||||
|
persist(run.job)
|
||||||
|
if (run.child) {
|
||||||
|
const child = run.child
|
||||||
|
await new Promise(resolve => { child.once('close', resolve); child.kill() })
|
||||||
|
}
|
||||||
|
return read(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Headless YuE2 adapter: plan → generate_semantic → synthesize → decode.
|
||||||
|
|
||||||
|
No Comfy imports. No score editor, covers, or auto-fallback to YuEGP / ACE.
|
||||||
|
Stdout AIGEN_EVENT lines are consumed by the device-local host agent.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import gc
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
|
||||||
|
# Must be set before the first torch import on this 16GB Windows host.
|
||||||
|
os.environ.setdefault('PYTORCH_CUDA_ALLOC_CONF', 'expandable_segments:True')
|
||||||
|
|
||||||
|
|
||||||
|
def emit(**event):
|
||||||
|
print('AIGEN_EVENT ' + json.dumps(event), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_lyrics(text):
|
||||||
|
# YuE2 accepts multiple [Verse]/[Chorus] headings. Only wrap when none exist.
|
||||||
|
import re
|
||||||
|
text = str(text or '').strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError('YuE2 requires non-empty lyrics.')
|
||||||
|
if re.search(r'\[[^\]]+\]', text):
|
||||||
|
return text if text.endswith('\n') else text + '\n'
|
||||||
|
return '[song]\n' + text + '\n\n'
|
||||||
|
|
||||||
|
|
||||||
|
def is_cuda_oom(error):
|
||||||
|
message = str(error).lower()
|
||||||
|
return 'out of memory' in message or ('cuda' in message and 'alloc' in message) or 'cudnn_status_alloc_failed' in message
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_attention_backend(torch_mod):
|
||||||
|
"""Never flash. Prefer torch-eager (no CUDA graphs) on the 16GB 5080."""
|
||||||
|
# Windows wheels expose flash ops without USE_FLASH_ATTENTION; never select flash.
|
||||||
|
# torch-eager disables GraphAR / CUDA graphs via YuE2Pipeline.backend.
|
||||||
|
return 'sdpa', 'torch-eager'
|
||||||
|
|
||||||
|
|
||||||
|
def patch_graph_attention(attention_backend):
|
||||||
|
from yue2.cuda_graph import GraphAR
|
||||||
|
original = GraphAR.__init__
|
||||||
|
|
||||||
|
def init(self, model, prefixes, max_tokens, *, capture=True, attention_backend='auto', fuse_projections=False):
|
||||||
|
if attention_backend in ('auto', 'flash'):
|
||||||
|
attention_backend = patch_graph_attention.forced
|
||||||
|
return original(self, model, prefixes, max_tokens, capture=capture,
|
||||||
|
attention_backend=attention_backend, fuse_projections=fuse_projections)
|
||||||
|
|
||||||
|
patch_graph_attention.forced = attention_backend
|
||||||
|
GraphAR.__init__ = init
|
||||||
|
|
||||||
|
|
||||||
|
def free_cuda(torch_mod):
|
||||||
|
gc.collect()
|
||||||
|
if torch_mod.cuda.is_available():
|
||||||
|
torch_mod.cuda.empty_cache()
|
||||||
|
torch_mod.cuda.synchronize()
|
||||||
|
|
||||||
|
|
||||||
|
def memory_snapshot(torch_mod, stage):
|
||||||
|
if not torch_mod.cuda.is_available():
|
||||||
|
emit(stage=stage, message=f'YuE2 {stage}', cuda=False)
|
||||||
|
return
|
||||||
|
free, total = torch_mod.cuda.mem_get_info()
|
||||||
|
emit(
|
||||||
|
stage=stage,
|
||||||
|
message=f'YuE2 {stage}',
|
||||||
|
gpuAllocatedMiB=round(torch_mod.cuda.memory_allocated() / 1024 ** 2),
|
||||||
|
gpuReservedMiB=round(torch_mod.cuda.memory_reserved() / 1024 ** 2),
|
||||||
|
gpuFreeMiB=round(free / 1024 ** 2),
|
||||||
|
gpuTotalMiB=round(total / 1024 ** 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import psutil
|
||||||
|
parent = psutil.Process(os.getppid())
|
||||||
|
|
||||||
|
def parent_watchdog():
|
||||||
|
while parent.is_running():
|
||||||
|
time.sleep(2)
|
||||||
|
os._exit(2) # The host died: never leave an orphan consuming the GPU.
|
||||||
|
|
||||||
|
threading.Thread(target=parent_watchdog, daemon=True).start()
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--root', required=True)
|
||||||
|
parser.add_argument('--request', required=True)
|
||||||
|
cli = parser.parse_args()
|
||||||
|
request = json.loads(Path(cli.request).read_text(encoding='utf-8-sig'))
|
||||||
|
root = Path(cli.root).resolve()
|
||||||
|
output = Path(cli.request).resolve().parent
|
||||||
|
duration = int(request.get('duration', 60))
|
||||||
|
if not 15 <= duration <= 150:
|
||||||
|
raise ValueError('Target length must be 15–150 seconds.')
|
||||||
|
# VAE downsampling_ratio 1920 @ 48 kHz → 25 semantic / latent frames per second.
|
||||||
|
max_tokens = max(200, min(9000, duration * 25))
|
||||||
|
lyrics = normalize_lyrics(request['lyrics'])
|
||||||
|
style = ' '.join(str(request['tags']).split())
|
||||||
|
seed = int(request['seed'])
|
||||||
|
model = request.get('model') or os.environ.get('YUE2_MODEL') or 'm-a-p/YuE2-3B'
|
||||||
|
vae = request.get('vae') or os.environ.get('YUE2_VAE') or 'm-a-p/YuE2-Vae'
|
||||||
|
os.chdir(root)
|
||||||
|
if str(root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
import torch
|
||||||
|
import soundfile as sf
|
||||||
|
attention_backend, pipeline_backend = resolve_attention_backend(torch)
|
||||||
|
emit(stage='loading', progress=1, message='Loading YuE2', model=model, vae=vae,
|
||||||
|
duration=duration, maxTokens=max_tokens, attention_backend=attention_backend,
|
||||||
|
pipeline_backend=pipeline_backend,
|
||||||
|
cudaAllocConf=os.environ.get('PYTORCH_CUDA_ALLOC_CONF'))
|
||||||
|
from yue2 import YuE2Pipeline
|
||||||
|
from yue2.protocol import Sampling
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise RuntimeError('YuE2 requires a CUDA GPU; CPU fallback is disabled.')
|
||||||
|
patch_graph_attention(attention_backend)
|
||||||
|
free_cuda(torch)
|
||||||
|
memory_snapshot(torch, 'memory-before-load')
|
||||||
|
# Prefer tiled VAE; do not cap the process with set_per_process_memory_fraction.
|
||||||
|
pipe_load = dict(
|
||||||
|
device='cuda',
|
||||||
|
backend=pipeline_backend,
|
||||||
|
vae_core_frames=512,
|
||||||
|
offload_ar=True,
|
||||||
|
progress=False,
|
||||||
|
)
|
||||||
|
cot = 'full'
|
||||||
|
pipe_kwargs = dict(style=style, lyrics=lyrics, cot=cot, seed=seed)
|
||||||
|
# Cap semantic length from target seconds. Do not FFmpeg-trim after decode.
|
||||||
|
semantic_sampling = Sampling(max_tokens=max_tokens, min_tokens=min(200, max_tokens))
|
||||||
|
# YuE2Pipeline.__init__ always calls set_per_process_memory_fraction; skip it on this 16GB host.
|
||||||
|
_set_fraction = torch.cuda.set_per_process_memory_fraction
|
||||||
|
torch.cuda.set_per_process_memory_fraction = lambda *args, **kwargs: None
|
||||||
|
try:
|
||||||
|
pipe_cm = YuE2Pipeline.from_pretrained(model, vae=vae, **pipe_load)
|
||||||
|
finally:
|
||||||
|
torch.cuda.set_per_process_memory_fraction = _set_fraction
|
||||||
|
with pipe_cm as pipe:
|
||||||
|
memory_snapshot(torch, 'memory-loaded')
|
||||||
|
emit(stage='plan', message='Planning melody and chords', progress=5, cot=cot)
|
||||||
|
try:
|
||||||
|
plan = pipe.plan(**pipe_kwargs)
|
||||||
|
except Exception as error:
|
||||||
|
if cot != 'full' or not is_cuda_oom(error):
|
||||||
|
raise
|
||||||
|
free_cuda(torch)
|
||||||
|
cot = 'melody'
|
||||||
|
pipe_kwargs['cot'] = cot
|
||||||
|
emit(stage='plan', message='Full CoT OOM; retrying melody CoT', progress=5, cot=cot,
|
||||||
|
error=str(error))
|
||||||
|
memory_snapshot(torch, 'memory-before-melody-plan')
|
||||||
|
plan = pipe.plan(**pipe_kwargs)
|
||||||
|
free_cuda(torch)
|
||||||
|
memory_snapshot(torch, 'memory-after-plan')
|
||||||
|
emit(stage='semantic', message='Generating semantic tokens', progress=25, cot=cot,
|
||||||
|
maxTokens=max_tokens, targetSeconds=duration)
|
||||||
|
semantic = pipe.generate_semantic(plan, sampling=semantic_sampling)
|
||||||
|
free_cuda(torch)
|
||||||
|
memory_snapshot(torch, 'memory-after-semantic')
|
||||||
|
emit(stage='synthesize', message='Synthesizing acoustic latents', progress=55, cot=cot)
|
||||||
|
latents = pipe.synthesize(semantic)
|
||||||
|
free_cuda(torch)
|
||||||
|
memory_snapshot(torch, 'memory-after-synthesize')
|
||||||
|
emit(stage='decode', message='Decoding audio (tiled)', progress=80, cot=cot)
|
||||||
|
# full=False uses decode_tiled with vae_core_frames / halo; model offloads to CPU after.
|
||||||
|
audio = pipe.decode(latents, full=False)
|
||||||
|
free_cuda(torch)
|
||||||
|
memory_snapshot(torch, 'memory-after-decode')
|
||||||
|
free_cuda(torch)
|
||||||
|
wave = audio
|
||||||
|
if hasattr(audio, 'detach'):
|
||||||
|
wave = audio.detach().cpu().numpy()
|
||||||
|
import numpy as np
|
||||||
|
wave = np.asarray(wave)
|
||||||
|
if wave.ndim == 1:
|
||||||
|
pass
|
||||||
|
elif wave.shape[0] <= 8 and wave.shape[0] < wave.shape[-1]:
|
||||||
|
wave = wave.T
|
||||||
|
sample_rate = 48000
|
||||||
|
target = output / 'audio.wav'
|
||||||
|
sf.write(str(target), wave, sample_rate, subtype='PCM_16')
|
||||||
|
info = sf.info(str(target))
|
||||||
|
if info.frames <= 0:
|
||||||
|
raise RuntimeError('YuE2 produced empty audio.')
|
||||||
|
emit(stage='complete', message='Audio ready', progress=100, duration=info.duration, cot=cot)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as error:
|
||||||
|
emit(stage='error', message=str(error), error=str(error))
|
||||||
|
raise
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { validateCaptionBody } from '~/shared/caption.mjs'
|
||||||
|
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '~/server/utils/studioQueue'
|
||||||
|
import { getStill } from '~/server/utils/library'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const raw = await readBody(event)
|
||||||
|
let body: ReturnType<typeof validateCaptionBody>
|
||||||
|
try {
|
||||||
|
body = validateCaptionBody(raw)
|
||||||
|
} catch (e: any) {
|
||||||
|
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message })
|
||||||
|
}
|
||||||
|
assertFolderAccess(event, body.folderId)
|
||||||
|
const still = getStill(owner, body.stillId)
|
||||||
|
assertFolderAccess(event, still.folderId)
|
||||||
|
|
||||||
|
const row = await addStudioJob({
|
||||||
|
ownerKey: owner,
|
||||||
|
kind: 'caption',
|
||||||
|
familyId: crypto.randomUUID(),
|
||||||
|
payload: {
|
||||||
|
prompt: `Describe · ${body.captionStyle}`,
|
||||||
|
name: `Describe · ${body.captionStyle}`,
|
||||||
|
folderId: body.folderId,
|
||||||
|
stillId: still.id,
|
||||||
|
captionStyle: body.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
|
||||||
|
})
|
||||||
|
kickStudioQueue()
|
||||||
|
return { id: row.id, liveJobId: row.liveJobId, captionStyle: body.captionStyle, stillId: still.id }
|
||||||
|
})
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import { yueGpConfigured } from '~/server/utils/yueGp'
|
import { yue2Configured } from '~/server/utils/yue2'
|
||||||
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
|
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
|
||||||
import { comfyConfigured } from '~/server/utils/comfy'
|
|
||||||
import {
|
import {
|
||||||
clampLyricsStrength,
|
|
||||||
clampMusicCfg,
|
|
||||||
clampMusicDuration,
|
clampMusicDuration,
|
||||||
clampMusicSteps,
|
|
||||||
MUSIC_CFG_DEFAULT,
|
|
||||||
MUSIC_CFG_DEFAULT_15,
|
|
||||||
MUSIC_STEPS_DEFAULT,
|
|
||||||
MUSIC_STEPS_DEFAULT_15,
|
|
||||||
parseMusicEngine,
|
parseMusicEngine,
|
||||||
parseMusicSeed,
|
parseMusicSeed,
|
||||||
sanitizeMusicLyrics,
|
sanitizeMusicLyrics,
|
||||||
@@ -22,36 +14,28 @@ export default defineEventHandler(async (event) => {
|
|||||||
tags?: unknown
|
tags?: unknown
|
||||||
lyrics?: unknown
|
lyrics?: unknown
|
||||||
duration?: unknown
|
duration?: unknown
|
||||||
steps?: unknown
|
|
||||||
seed?: unknown
|
seed?: unknown
|
||||||
cfg?: unknown
|
|
||||||
lyricsStrength?: unknown
|
|
||||||
instrumental?: unknown
|
|
||||||
folderId?: unknown
|
folderId?: unknown
|
||||||
name?: unknown
|
name?: unknown
|
||||||
yueProfile?: unknown
|
|
||||||
engine?: unknown
|
engine?: unknown
|
||||||
sampler_name?: unknown
|
|
||||||
scheduler?: unknown
|
|
||||||
}>(event).catch(() => ({} as Record<string, unknown>))
|
}>(event).catch(() => ({} as Record<string, unknown>))
|
||||||
|
|
||||||
const engine = parseMusicEngine(body.engine)
|
const engine = parseMusicEngine(body.engine)
|
||||||
if (engine === 'yue' && !yueGpConfigured()) throw createError({ statusCode: 503, statusMessage: 'YuEGP host is not configured. Set COMFY_CONTROL_URL.' })
|
if (engine !== 'yue2') {
|
||||||
if (engine !== 'yue' && !comfyConfigured()) {
|
throw createError({ statusCode: 400, statusMessage: 'Only YuE2 is supported for music generation.' })
|
||||||
throw createError({
|
}
|
||||||
statusCode: 503,
|
if (!yue2Configured()) {
|
||||||
statusMessage: 'Beast ComfyUI is not configured. Set COMFY_HOST.'
|
throw createError({ statusCode: 503, statusMessage: 'YuE2 host is not configured. Set COMFY_CONTROL_URL.' })
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const instrumental = body.instrumental === true || body.instrumental === 'true'
|
|
||||||
const tags = sanitizeMusicTags(body.tags)
|
const tags = sanitizeMusicTags(body.tags)
|
||||||
const lyrics = sanitizeMusicLyrics(body.lyrics, instrumental)
|
const lyrics = sanitizeMusicLyrics(body.lyrics, false)
|
||||||
if (!tags) {
|
if (!tags) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Genre and style tags are required' })
|
throw createError({ statusCode: 400, statusMessage: 'Genre and style tags are required' })
|
||||||
}
|
}
|
||||||
if (!instrumental && !lyrics) {
|
const lyricsProblem = yueLyricsProblem(lyrics)
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Lyrics are required unless Instrumental is on' })
|
if (lyricsProblem) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: lyricsProblem })
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerKey = libraryOwnerKey(event)
|
const ownerKey = libraryOwnerKey(event)
|
||||||
@@ -66,31 +50,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||||
|
|
||||||
if (engine === 'yue' && instrumental) throw createError({ statusCode: 400, statusMessage: 'YuEGP currently requires lyrics. Use ACE for instrumental music.' })
|
|
||||||
const yueProfile = body.yueProfile ?? 1
|
|
||||||
if (engine === 'yue' && yueProfile !== 1 && yueProfile !== 3) throw createError({ statusCode: 400, statusMessage: 'Choose YuEGP profile 1 or manual fallback 3.' })
|
|
||||||
if (engine === 'yue' && !instrumental) {
|
|
||||||
const problem = yueLyricsProblem(lyrics)
|
|
||||||
if (problem) throw createError({ statusCode: 400, statusMessage: problem })
|
|
||||||
}
|
|
||||||
const duration = clampMusicDuration(body.duration)
|
const duration = clampMusicDuration(body.duration)
|
||||||
if (engine === 'yue' && duration > 150) throw createError({ statusCode: 400, statusMessage: 'YuEGP supports up to 150 seconds per section.' })
|
|
||||||
const steps = clampMusicSteps(
|
|
||||||
body.steps,
|
|
||||||
engine === 'ace-step-1.5' ? MUSIC_STEPS_DEFAULT_15 : MUSIC_STEPS_DEFAULT
|
|
||||||
)
|
|
||||||
const cfg = clampMusicCfg(
|
|
||||||
body.cfg,
|
|
||||||
engine === 'ace-step-1.5' ? MUSIC_CFG_DEFAULT_15 : MUSIC_CFG_DEFAULT
|
|
||||||
)
|
|
||||||
const lyricsStrength = clampLyricsStrength(body.lyricsStrength)
|
|
||||||
const seed = parseMusicSeed(body.seed)
|
const seed = parseMusicSeed(body.seed)
|
||||||
const clipName = allocateOutputName(ownerKey, folderId, String(body.name || '').trim(), {
|
const clipName = allocateOutputName(ownerKey, folderId, String(body.name || '').trim(), {
|
||||||
engine: engine === 'ace-step-1.5' ? 'ace15' : engine === 'yue' ? 'yue' : 'ace'
|
engine: 'yue2'
|
||||||
})
|
})
|
||||||
|
|
||||||
const samplerName = String(body.sampler_name || 'euler')
|
|
||||||
const scheduler = String(body.scheduler || 'simple')
|
|
||||||
const familyId = crypto.randomUUID()
|
const familyId = crypto.randomUUID()
|
||||||
const studio = await addStudioJob({
|
const studio = await addStudioJob({
|
||||||
ownerKey,
|
ownerKey,
|
||||||
@@ -103,15 +68,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
aspect: 'audio',
|
aspect: 'audio',
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
steps,
|
steps: 0,
|
||||||
turbo: false,
|
turbo: false,
|
||||||
seed,
|
seed,
|
||||||
cfg,
|
cfg: 0,
|
||||||
fps: 0,
|
fps: 0,
|
||||||
samplerName,
|
samplerName: 'euler',
|
||||||
scheduler,
|
scheduler: 'simple',
|
||||||
duration,
|
duration,
|
||||||
sound: !instrumental,
|
sound: true,
|
||||||
workflow: 'v1',
|
workflow: 'v1',
|
||||||
useIdentityRefs: false,
|
useIdentityRefs: false,
|
||||||
hideThumbnail: false,
|
hideThumbnail: false,
|
||||||
@@ -120,10 +85,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
extensions: [],
|
extensions: [],
|
||||||
queueAutoRun: false,
|
queueAutoRun: false,
|
||||||
lyrics,
|
lyrics,
|
||||||
instrumental,
|
instrumental: false,
|
||||||
lyricsStrength,
|
lyricsStrength: 0,
|
||||||
musicEngine: engine,
|
musicEngine: 'yue2'
|
||||||
...(engine === 'yue' ? { yueProfile: yueProfile as 1 | 3 } : {})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await kickStudioQueue()
|
await kickStudioQueue()
|
||||||
@@ -140,11 +104,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
queued: !liveJobId,
|
queued: !liveJobId,
|
||||||
seed,
|
seed,
|
||||||
duration,
|
duration,
|
||||||
steps,
|
|
||||||
cfg,
|
|
||||||
folderId,
|
folderId,
|
||||||
folderLocked,
|
folderLocked,
|
||||||
engine,
|
engine: 'yue2' as const,
|
||||||
name: clipName
|
name: clipName
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,11 +48,21 @@ 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,
|
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
|
||||||
},
|
},
|
||||||
|
yue2: {
|
||||||
|
configured: Boolean(control?.yue2?.configured),
|
||||||
|
busy: Boolean(control?.yue2?.busy),
|
||||||
|
backend: control?.yue2?.backend || 'yue2'
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
configured: Boolean(control?.caption?.configured),
|
||||||
|
busy: Boolean(control?.caption?.busy),
|
||||||
|
backend: control?.caption?.backend || 'llama.cpp'
|
||||||
|
},
|
||||||
imageComfy: {
|
imageComfy: {
|
||||||
configured: imageConfigured,
|
configured: imageConfigured,
|
||||||
ok: imageOk,
|
ok: imageOk,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
const etag = fileEtag(path)
|
const etag = fileEtag(path)
|
||||||
setHeader(event, 'ETag', etag)
|
setHeader(event, 'ETag', etag)
|
||||||
setHeader(event, 'Cache-Control', 'private, max-age=86400, stale-while-revalidate=604800')
|
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||||
if (getHeader(event, 'if-none-match') === etag) {
|
if (getHeader(event, 'if-none-match') === etag) {
|
||||||
setResponseStatus(event, 304)
|
setResponseStatus(event, 304)
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
|
import { publicLibrary, publicLibraryPage } from '~/server/utils/library'
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const light = String(getQuery(event).light || '') === '1'
|
const query = getQuery(event)
|
||||||
const library = publicLibrary(event, { light })
|
const light = String(query.light || '') === '1'
|
||||||
try {
|
const full = String(query.full || '') === '1'
|
||||||
const { owner } = assertLibraryOwner(event)
|
const limitRaw = query.limit
|
||||||
const folderId = library.folders.find(folder => folder.unlocked)?.id
|
const hasLimit = limitRaw != null && String(limitRaw) !== ''
|
||||||
void maintainLibrary(owner, folderId).catch(() => null)
|
// Default GET /api/library → page helper (limit 50). Opt into full catalog with full=1 or legacy light=1.
|
||||||
} catch { /* locked or anonymous — still return what we can */ }
|
const wantsFull = full || (light && !hasLimit)
|
||||||
return library
|
|
||||||
|
if (!wantsFull) {
|
||||||
|
return publicLibraryPage(event, {
|
||||||
|
limit: hasLimit ? Number(limitRaw) || 50 : 50,
|
||||||
|
cursor: String(query.cursor || ''),
|
||||||
|
folderId: String(query.folderId || query.folder || ''),
|
||||||
|
type: String(query.type || query.filter || 'all')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full catalog path — no thumb prewarm on the request (thumbs build on /thumb hit)
|
||||||
|
return publicLibrary(event, { light: true })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,23 +1,44 @@
|
|||||||
import { existsSync, createReadStream } from 'node:fs'
|
import { existsSync, createReadStream } from 'node:fs'
|
||||||
import { fileEtag, stillPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
|
import { fileEtag, stillPath, existingStillThumbPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler((event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const still = getStill(owner, id)
|
const still = getStill(owner, id)
|
||||||
assertFolderAccess(event, still.folderId)
|
assertFolderAccess(event, still.folderId)
|
||||||
const wantThumb = String(getQuery(event).thumb || '') === '1'
|
const wantThumb = String(getQuery(event).thumb || '') === '1'
|
||||||
const path = wantThumb ? await stillThumbPath(owner, id) : stillPath(owner, still.id)
|
const original = stillPath(owner, still.id)
|
||||||
if (!path || !existsSync(path)) {
|
|
||||||
|
if (wantThumb) {
|
||||||
|
const path = existingStillThumbPath(owner, id)
|
||||||
|
if (path && path !== original && existsSync(path)) {
|
||||||
|
const etag = fileEtag(path)
|
||||||
|
setHeader(event, 'ETag', etag)
|
||||||
|
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||||
|
if (getHeader(event, 'if-none-match') === etag) {
|
||||||
|
setResponseStatus(event, 304)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||||
|
return sendStream(event, createReadStream(path))
|
||||||
|
}
|
||||||
|
void stillThumbPath(owner, id).catch(() => null)
|
||||||
|
setHeader(event, 'Cache-Control', 'no-store')
|
||||||
|
setResponseStatus(event, 204)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(original)) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
|
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
|
||||||
}
|
}
|
||||||
const etag = fileEtag(path)
|
|
||||||
|
const etag = fileEtag(original)
|
||||||
setHeader(event, 'ETag', etag)
|
setHeader(event, 'ETag', etag)
|
||||||
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
|
setHeader(event, 'Cache-Control', 'private, max-age=3600')
|
||||||
if (getHeader(event, 'if-none-match') === etag) {
|
if (getHeader(event, 'if-none-match') === etag) {
|
||||||
setResponseStatus(event, 304)
|
setResponseStatus(event, 304)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
setHeader(event, 'Content-Type', wantThumb ? 'image/jpeg' : sniffImageType(path))
|
setHeader(event, 'Content-Type', sniffImageType(original))
|
||||||
return sendStream(event, createReadStream(path))
|
return sendStream(event, createReadStream(original))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { existsSync, createReadStream } from 'node:fs'
|
||||||
|
import { fileEtag, stillPath, stillPreviewPath, existingStillPreviewPath } from '~/server/utils/library'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
const still = getStill(owner, id)
|
||||||
|
assertFolderAccess(event, still.folderId)
|
||||||
|
|
||||||
|
const path = existingStillPreviewPath(owner, id)
|
||||||
|
const original = stillPath(owner, still.id)
|
||||||
|
// Ready preview → stream. Never await ffmpeg; never stream the archive master.
|
||||||
|
if (path && existsSync(path) && path !== original) {
|
||||||
|
const etag = fileEtag(path)
|
||||||
|
setHeader(event, 'ETag', etag)
|
||||||
|
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||||
|
if (getHeader(event, 'if-none-match') === etag) {
|
||||||
|
setResponseStatus(event, 304)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||||
|
return sendStream(event, createReadStream(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing → answer now; build .preview.jpg in the background
|
||||||
|
void stillPreviewPath(owner, id).catch(() => null)
|
||||||
|
setHeader(event, 'Cache-Control', 'no-store')
|
||||||
|
setResponseStatus(event, 204)
|
||||||
|
return null
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { existsSync, createReadStream } from 'node:fs'
|
||||||
|
import { fileEtag, stillPath, stillThumbPath, existingStillThumbPath } from '~/server/utils/library'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
const still = getStill(owner, id)
|
||||||
|
assertFolderAccess(event, still.folderId)
|
||||||
|
|
||||||
|
const path = existingStillThumbPath(owner, id)
|
||||||
|
const original = stillPath(owner, still.id)
|
||||||
|
// Ready thumb → stream. Never await ffmpeg on the request path.
|
||||||
|
if (path && existsSync(path) && path !== original) {
|
||||||
|
const etag = fileEtag(path)
|
||||||
|
setHeader(event, 'ETag', etag)
|
||||||
|
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||||
|
if (getHeader(event, 'if-none-match') === etag) {
|
||||||
|
setResponseStatus(event, 304)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||||
|
return sendStream(event, createReadStream(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing → answer now; build .thumb.jpg in the background (job save / upload also kick this)
|
||||||
|
void stillThumbPath(owner, id).catch(() => null)
|
||||||
|
setHeader(event, 'Cache-Control', 'no-store')
|
||||||
|
setResponseStatus(event, 204)
|
||||||
|
return null
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { listPromptSequences } from '~/server/utils/promptSequences'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
return {
|
||||||
|
sequences: listPromptSequences(owner)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { listPromptSequences, savePromptSequence } from '~/server/utils/promptSequences'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const body = await readBody<{
|
||||||
|
name?: string
|
||||||
|
sectionOrder?: unknown
|
||||||
|
characters?: unknown
|
||||||
|
sets?: unknown
|
||||||
|
scenes?: unknown
|
||||||
|
overwrite?: boolean
|
||||||
|
}>(event).catch(() => ({}))
|
||||||
|
const sequence = await savePromptSequence(owner, {
|
||||||
|
name: body?.name || '',
|
||||||
|
sectionOrder: body?.sectionOrder,
|
||||||
|
characters: body?.characters,
|
||||||
|
sets: body?.sets,
|
||||||
|
scenes: body?.scenes,
|
||||||
|
overwrite: body?.overwrite === true
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
sequence,
|
||||||
|
sequences: listPromptSequences(owner)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { deletePromptSequence, listPromptSequences } from '~/server/utils/promptSequences'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
await deletePromptSequence(owner, id)
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
sequences: listPromptSequences(owner)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { getPromptSequence } from '~/server/utils/promptSequences'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
return {
|
||||||
|
sequence: getPromptSequence(owner, id)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { listPromptSequences, updatePromptSequence } from '~/server/utils/promptSequences'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
const body = await readBody<{
|
||||||
|
name?: string
|
||||||
|
sectionOrder?: unknown
|
||||||
|
characters?: unknown
|
||||||
|
sets?: unknown
|
||||||
|
scenes?: unknown
|
||||||
|
}>(event).catch(() => ({}))
|
||||||
|
const sequence = await updatePromptSequence(owner, id, {
|
||||||
|
name: body?.name,
|
||||||
|
sectionOrder: body?.sectionOrder,
|
||||||
|
characters: body?.characters,
|
||||||
|
sets: body?.sets,
|
||||||
|
scenes: body?.scenes
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
sequence,
|
||||||
|
sequences: listPromptSequences(owner)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,15 +1,40 @@
|
|||||||
import { records, saveRecord } from '../../utils/studio2/store';
|
import { records, saveRecord } from '../../utils/studio2/store'
|
||||||
import { listStudioJobs } from '../../utils/studioQueue';
|
import { dismissFinishedStudioJobs, dismissStudioJob, listStudioJobs } from '../../utils/studioQueue'
|
||||||
export default defineEventHandler(event => {
|
|
||||||
const { owner } = assertLibraryOwner(event), queue=listStudioJobs(owner);
|
export default defineEventHandler(async (event) => {
|
||||||
let cleared=0;
|
const { owner } = assertLibraryOwner(event)
|
||||||
for (const r of records(owner)) {
|
const body = await readBody<{ id?: string }>(event).catch(() => ({} as { id?: string }))
|
||||||
try { assertFolderAccess(event,r.request.folderId); } catch { continue; }
|
const queue = listStudioJobs(owner)
|
||||||
const status=queue.find(q=>q.id===r.queueId)?.status;
|
let cleared = 0
|
||||||
if (['complete','failed','cancelled'].includes(r.state) || ['cancelled','error'].includes(status || '')) {
|
|
||||||
r.dismissed=true; saveRecord(r); cleared++;
|
if (body?.id) {
|
||||||
|
const row = queue.find(job => job.id === body.id)
|
||||||
|
if (!row) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||||
|
try { assertFolderAccess(event, row.payload.folderId) } catch {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: 'Folder locked' })
|
||||||
|
}
|
||||||
|
await dismissStudioJob(owner, body.id)
|
||||||
|
return { cleared: 1 }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Retain internal manifests for cleanup retries; remove these records from the visible jobs list.
|
for (const r of records(owner)) {
|
||||||
return {cleared};
|
try { assertFolderAccess(event, r.request.folderId) } catch { continue }
|
||||||
});
|
const status = queue.find(q => q.id === r.queueId)?.status
|
||||||
|
if (['complete', 'failed', 'cancelled'].includes(r.state) || ['cancelled', 'error'].includes(status || '')) {
|
||||||
|
r.dismissed = true
|
||||||
|
saveRecord(r)
|
||||||
|
cleared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caption (and other finished studio-queue) cards — hide from Queue, keep resultText on the row
|
||||||
|
const finishedIds = queue
|
||||||
|
.filter(job => !job.dismissed && ['complete', 'error', 'cancelled'].includes(job.status))
|
||||||
|
.filter(job => {
|
||||||
|
try { assertFolderAccess(event, job.payload.folderId); return true } catch { return false }
|
||||||
|
})
|
||||||
|
.map(job => job.id)
|
||||||
|
cleared += await dismissFinishedStudioJobs(owner, finishedIds)
|
||||||
|
|
||||||
|
return { cleared }
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,32 +4,39 @@ import { resolveRequestSize } from '../../utils/studio2/size';
|
|||||||
import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras';
|
import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras';
|
||||||
import { saveRecord, readRecord } from '../../utils/studio2/store';
|
import { saveRecord, readRecord } from '../../utils/studio2/store';
|
||||||
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue';
|
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue';
|
||||||
|
import { isXaigenStudio } from '~/utils/videoModels';
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { owner } = assertLibraryOwner(event), raw = await readBody(event);
|
const { owner } = assertLibraryOwner(event), raw = await readBody(event);
|
||||||
|
const xaigen = isXaigenStudio();
|
||||||
let request: any;
|
let request: any;
|
||||||
try {
|
try {
|
||||||
request = validateRequest(raw, useRuntimeConfig().public.studio === 'xaigen');
|
request = validateRequest(raw, xaigen);
|
||||||
}
|
}
|
||||||
catch (e: any) {
|
catch (e: any) {
|
||||||
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
|
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
|
||||||
}
|
}
|
||||||
const video = ['video', 'extend'].includes(request.mode);
|
const video = ['video', 'extend'].includes(request.mode);
|
||||||
if (video && request.engine !== 'minimax')
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Use the existing xAIGen studio for LTX. Studio 2 video currently supports MiniMax.' });
|
|
||||||
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
|
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
|
||||||
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen');
|
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, xaigen);
|
||||||
if (!video)
|
if (!video && request.engine !== 'qwen21')
|
||||||
assertImageV2LoraStack(request.settings.loraStack, request.engine);
|
assertImageV2LoraStack(request.settings.loraStack, request.engine);
|
||||||
assertFolderAccess(event, request.folderId);
|
assertFolderAccess(event, request.folderId);
|
||||||
for (const id of [request.identityStillId, request.imageAId, request.imageBId, request.maskId, request.endStillId, ...request.guides.map((g: any) => g.stillId)].filter(Boolean))
|
for (const id of [request.identityStillId, request.imageAId, request.imageBId, request.maskId, request.endStillId, ...request.guides.map((g: any) => g.stillId)].filter(Boolean))
|
||||||
assertFolderAccess(event, getStill(owner, id).folderId);
|
assertFolderAccess(event, getStill(owner, id).folderId);
|
||||||
if (request.startClipId)
|
if (request.startClipId)
|
||||||
assertFolderAccess(event, getClip(owner, request.startClipId).folderId);
|
assertFolderAccess(event, getClip(owner, request.startClipId).folderId);
|
||||||
|
// Header-only size probe — never decode the archive PNG on Generate click
|
||||||
await resolveRequestSize(owner,request);
|
await resolveRequestSize(owner,request);
|
||||||
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
|
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
|
||||||
saveRecord(record);
|
saveRecord(record);
|
||||||
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
|
const ack = { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
|
||||||
saveRecord({ ...readRecord(owner, id), queueId: row.id });
|
// Enqueue + kick after the client has the id — do not block the response on the worker wake path
|
||||||
kickStudioQueue();
|
void (async () => {
|
||||||
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
|
try {
|
||||||
|
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, studio2Request: structuredClone(request), prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
|
||||||
|
saveRecord({ ...readRecord(owner, id), queueId: row.id });
|
||||||
|
} catch { /* poll / resume will pick up waiting studio2 records */ }
|
||||||
|
kickStudioQueue();
|
||||||
|
})();
|
||||||
|
return ack;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { existsSync } from 'node:fs';
|
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
|
||||||
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog';
|
import { publicLibrary } from '../../utils/library'
|
||||||
import { publicLibrary, stillPath, clipVideoPath } from '../../utils/library';
|
|
||||||
export default defineEventHandler(event => {
|
export default defineEventHandler((event) => {
|
||||||
const { owner } = assertLibraryOwner(event);
|
const { owner } = assertLibraryOwner(event)
|
||||||
const library = publicLibrary(event);
|
// light:true — metadata only; never existsSync every still/clip on the volume
|
||||||
const catalog=studio2LoraCatalog(owner);
|
const library = publicLibrary(event, { light: true })
|
||||||
return {...library,loraAliases:catalog.aliases,loraTriggers:catalog.triggers, stills:library.stills.filter(s=>existsSync(stillPath(owner,s.id))), clips:library.clips.filter(c=>existsSync(clipVideoPath(owner,c.id)))};
|
const catalog = studio2LoraCatalog(owner)
|
||||||
});
|
return {
|
||||||
|
...library,
|
||||||
|
loraAliases: catalog.aliases,
|
||||||
|
loraTriggers: catalog.triggers,
|
||||||
|
tracks: library.tracks || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { readFileSync,writeFileSync,renameSync } from 'node:fs';
|
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path'
|
||||||
import { cachedLoras } from '~/shared/studio2/lora-cache.mjs';
|
import { cachedLoras } from '~/shared/studio2/lora-cache.mjs'
|
||||||
import { listStudioLoras } from '../../utils/loras';
|
import { listStudioLoras } from '../../utils/loras'
|
||||||
import { studio2Root } from '../../utils/studio2/store';
|
import { studio2Root } from '../../utils/studio2/store'
|
||||||
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog';
|
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
|
||||||
export default defineEventHandler(async event => {
|
|
||||||
const {owner}=assertLibraryOwner(event),path=join(studio2Root(),`loras-${owner}.cache`);
|
export default defineEventHandler(async (event) => {
|
||||||
const listed=await cachedLoras({
|
const { owner } = assertLibraryOwner(event)
|
||||||
read:()=>{try{return JSON.parse(readFileSync(path,'utf8'));}catch{return null;}},
|
const path = join(studio2Root(), `loras-${owner}.cache`)
|
||||||
write:value=>{writeFileSync(path+'.tmp',JSON.stringify(value));renameSync(path+'.tmp',path);},
|
const fresh = String(getQuery(event).fresh || '') === '1'
|
||||||
live:()=>listStudioLoras({fresh:true}),fresh:getQuery(event).fresh==='1'
|
const listed = await cachedLoras({
|
||||||
});
|
read: () => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null } },
|
||||||
return {...listed,...studio2LoraCatalog(owner)};
|
write: value => { writeFileSync(path + '.tmp', JSON.stringify(value)); renameSync(path + '.tmp', path) },
|
||||||
});
|
live: () => listStudioLoras({ fresh: true }),
|
||||||
|
fresh
|
||||||
|
})
|
||||||
|
return { ...listed, ...studio2LoraCatalog(owner) }
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,18 +5,21 @@ export default defineEventHandler((event) => {
|
|||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const full = String(getQuery(event).full || '') === '1'
|
const full = String(getQuery(event).full || '') === '1'
|
||||||
const active = listStudioJobs(owner)
|
const active = listStudioJobs(owner)
|
||||||
|
.filter(job => !job.dismissed)
|
||||||
.filter(job => (
|
.filter(job => (
|
||||||
job.status === 'waiting'
|
job.status === 'waiting'
|
||||||
|| job.status === 'running'
|
|| job.status === 'running'
|
||||||
|| job.status === 'held'
|
|| job.status === 'held'
|
||||||
|| (job.status === 'error' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|
|| (job.status === 'error' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|
||||||
|
|| (job.kind === 'caption' && job.status === 'complete' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|
||||||
|
|| (job.kind === 'caption' && job.status === 'cancelled' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|
||||||
))
|
))
|
||||||
const claimed = new Set(active.map(job => job.shotQueueId).filter(Boolean) as string[])
|
const claimed = new Set(active.map(job => job.shotQueueId).filter(Boolean) as string[])
|
||||||
const queues = listShotQueues(owner)
|
const queues = listShotQueues(owner)
|
||||||
const byId = new Map(queues.map(queue => [queue.id, queue]))
|
const byId = new Map(queues.map(queue => [queue.id, queue]))
|
||||||
const jobs = active.map((job) => {
|
const jobs = active.map((job) => {
|
||||||
const shots = job.shotQueueId ? byId.get(job.shotQueueId) || null : null
|
const shots = job.shotQueueId ? byId.get(job.shotQueueId) || null : null
|
||||||
const kind = job.kind === 'edit' ? 'edit' : job.kind === 'music' ? 'music' : 'video'
|
const kind = job.kind === 'edit' ? 'edit' : job.kind === 'music' ? 'music' : job.kind === 'caption' ? 'caption' : 'video'
|
||||||
const shotSummary = shots ? summarizeQueue(shots) : null
|
const shotSummary = shots ? summarizeQueue(shots) : null
|
||||||
const plannedShots = shotSummary
|
const plannedShots = shotSummary
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -50,22 +50,24 @@ export default defineEventHandler(async (event) => {
|
|||||||
payload.prompt = text(body.tags, 2000)
|
payload.prompt = text(body.tags, 2000)
|
||||||
row.prompt = payload.prompt
|
row.prompt = payload.prompt
|
||||||
}
|
}
|
||||||
if (body.yueProfile != null && row.kind === 'music' && payload.musicEngine === 'yue') {
|
|
||||||
if (body.yueProfile !== 1 && body.yueProfile !== 3) throw createError({ statusCode: 400, statusMessage: 'Choose YuEGP profile 1 or 3.' })
|
|
||||||
payload.yueProfile = body.yueProfile
|
|
||||||
}
|
|
||||||
if (body.lyrics != null) payload.lyrics = text(body.lyrics)
|
if (body.lyrics != null) payload.lyrics = text(body.lyrics)
|
||||||
const instrumental = optionalBool(body.instrumental)
|
const instrumental = optionalBool(body.instrumental)
|
||||||
if (instrumental != null) payload.instrumental = instrumental
|
if (instrumental != null && row.kind === 'music') {
|
||||||
|
if (instrumental) throw createError({ statusCode: 400, statusMessage: 'YuE2 requires lyrics. Instrumental mode is not supported.' })
|
||||||
|
payload.instrumental = false
|
||||||
|
} else if (instrumental != null) {
|
||||||
|
payload.instrumental = instrumental
|
||||||
|
}
|
||||||
if (body.promptPre != null) payload.promptPre = text(body.promptPre)
|
if (body.promptPre != null) payload.promptPre = text(body.promptPre)
|
||||||
if (body.promptMid != null) payload.promptMid = text(body.promptMid)
|
if (body.promptMid != null) payload.promptMid = text(body.promptMid)
|
||||||
if (body.promptPost != null) payload.promptPost = text(body.promptPost)
|
if (body.promptPost != null) payload.promptPost = text(body.promptPost)
|
||||||
if (body.negative != null) payload.negative = text(body.negative, 2000)
|
if (body.negative != null) payload.negative = text(body.negative, 2000)
|
||||||
const duration = optionalNumber(body.duration)
|
const duration = optionalNumber(body.duration)
|
||||||
if (duration != null) {
|
if (duration != null) {
|
||||||
if (row.kind === 'music' && payload.musicEngine === 'yue') {
|
if (row.kind === 'music') {
|
||||||
if (!Number.isInteger(duration) || duration < 30 || duration > 150) throw createError({ statusCode: 400, statusMessage: 'YuEGP duration must be 30–150 seconds.' })
|
if (!Number.isInteger(duration) || duration < 15 || duration > 150) throw createError({ statusCode: 400, statusMessage: 'YuE2 target length must be 15–150 seconds.' })
|
||||||
payload.duration = duration
|
payload.duration = duration
|
||||||
|
payload.musicEngine = 'yue2'
|
||||||
} else payload.duration = Math.min(120, Math.max(0.5, duration))
|
} else payload.duration = Math.min(120, Math.max(0.5, duration))
|
||||||
}
|
}
|
||||||
const steps = optionalNumber(body.steps)
|
const steps = optionalNumber(body.steps)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||||
|
},
|
||||||
|
"class_type": "UnetLoaderGGUF",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"inputs": {
|
||||||
|
"device": "auto",
|
||||||
|
"dtype": "int8",
|
||||||
|
"model": ["4", 0]
|
||||||
|
},
|
||||||
|
"class_type": "QwenImage21Cache",
|
||||||
|
"_meta": { "title": "Qwen 2.1 Cache" }
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"inputs": {
|
||||||
|
"image": "qwen21_edit_source.png"
|
||||||
|
},
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"_meta": { "title": "image_1 source" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"vae": ["6", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"resolution": 1024,
|
||||||
|
"images.image_1": ["10", 0]
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImage21",
|
||||||
|
"_meta": { "title": "Text Encode Qwen Image 2.1 Edit" }
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": 1,
|
||||||
|
"steps": 25,
|
||||||
|
"cfg": 1,
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "simple",
|
||||||
|
"denoise": 1,
|
||||||
|
"model": ["8", 0],
|
||||||
|
"positive": ["9", 0],
|
||||||
|
"negative": ["9", 1],
|
||||||
|
"latent_image": ["9", 2]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"_meta": { "title": "KSampler" }
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["15", 0],
|
||||||
|
"vae": ["6", 0]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": { "title": "VAE Decode" }
|
||||||
|
},
|
||||||
|
"21": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "studio2-qwen21-edit",
|
||||||
|
"images": ["20", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": { "title": "Save Image" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||||
|
},
|
||||||
|
"class_type": "UnetLoaderGGUF",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"inputs": {
|
||||||
|
"model": ["4", 0],
|
||||||
|
"lora_name": "Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors",
|
||||||
|
"strength": 1.0
|
||||||
|
},
|
||||||
|
"class_type": "ViggleTurboLora",
|
||||||
|
"_meta": { "title": "Viggle Turbo LoRA (unmerged)" }
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"inputs": {
|
||||||
|
"device": "auto",
|
||||||
|
"dtype": "int8",
|
||||||
|
"model": ["7", 0]
|
||||||
|
},
|
||||||
|
"class_type": "QwenImage21Cache",
|
||||||
|
"_meta": { "title": "Qwen 2.1 Cache" }
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"inputs": {
|
||||||
|
"image": "qwen21_edit_source.png"
|
||||||
|
},
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"_meta": { "title": "image_1 source" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"vae": ["6", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"resolution": 1024,
|
||||||
|
"images.image_1": ["10", 0]
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImage21",
|
||||||
|
"_meta": { "title": "Text Encode Qwen Image 2.1 Edit" }
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"inputs": {
|
||||||
|
"model": ["8", 0],
|
||||||
|
"conditioning": ["9", 0]
|
||||||
|
},
|
||||||
|
"class_type": "BasicGuider",
|
||||||
|
"_meta": { "title": "CFG off (Turbo)" }
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"inputs": {
|
||||||
|
"noise_seed": 1
|
||||||
|
},
|
||||||
|
"class_type": "RandomNoise",
|
||||||
|
"_meta": { "title": "Noise" }
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"inputs": {
|
||||||
|
"sampler_name": "euler"
|
||||||
|
},
|
||||||
|
"class_type": "KSamplerSelect",
|
||||||
|
"_meta": { "title": "euler" }
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"inputs": {
|
||||||
|
"latent": ["9", 2],
|
||||||
|
"nodes": "1.0, 0.9375, 0.875, 0.75, 0.5, 0.25"
|
||||||
|
},
|
||||||
|
"class_type": "ViggleTurboSigmas",
|
||||||
|
"_meta": { "title": "Viggle Turbo Sigmas" }
|
||||||
|
},
|
||||||
|
"34": {
|
||||||
|
"inputs": {
|
||||||
|
"noise": ["31", 0],
|
||||||
|
"guider": ["30", 0],
|
||||||
|
"sampler": ["32", 0],
|
||||||
|
"sigmas": ["33", 0],
|
||||||
|
"latent_image": ["9", 2]
|
||||||
|
},
|
||||||
|
"class_type": "SamplerCustomAdvanced",
|
||||||
|
"_meta": { "title": "Turbo Sampler" }
|
||||||
|
},
|
||||||
|
"35": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["34", 0],
|
||||||
|
"vae": ["6", 0]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": { "title": "VAE Decode" }
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "studio2-qwen21-edit-turbo",
|
||||||
|
"images": ["35", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": { "title": "Save Image" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3.5_9b_qwen_image_2.1_pe_i2i.int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 PE Edit CLIP" }
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"inputs": {
|
||||||
|
"image": "qwen21_pe_edit_source.png"
|
||||||
|
},
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"_meta": { "title": "image_1 source" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"image_1": ["10", 0],
|
||||||
|
"temperature": 1.0,
|
||||||
|
"top_p": 0.95,
|
||||||
|
"presence_penalty": 0,
|
||||||
|
"max_length": 24000,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"class_type": "QwenImage21_EditPromptRewrite",
|
||||||
|
"_meta": { "title": "Qwen 2.1 Edit Prompt Rewrite" }
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"inputs": { "source": ["9", 0] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE positive_prompt" }
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"inputs": { "source": ["9", 2] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE wh_ratio" }
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"inputs": { "source": ["9", 3] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE ratio_follow" }
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"inputs": { "source": ["9", 4] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE thinking" }
|
||||||
|
},
|
||||||
|
"34": {
|
||||||
|
"inputs": { "source": ["9", 5] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE parse_ok" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3.5_9b_qwen_image_2.1_pe_t2i.int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 PE T2I CLIP" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"temperature": 1.0,
|
||||||
|
"top_p": 0.95,
|
||||||
|
"top_k": 20,
|
||||||
|
"presence_penalty": 1.5,
|
||||||
|
"max_new_tokens": 16256,
|
||||||
|
"seed": 42
|
||||||
|
},
|
||||||
|
"class_type": "QwenImage21_T2IPromptRewrite",
|
||||||
|
"_meta": { "title": "Qwen 2.1 T2I Prompt Rewrite" }
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"inputs": { "source": ["9", 0] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE positive_prompt" }
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"inputs": { "source": ["9", 2] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE wh_ratio" }
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"inputs": { "source": ["9", 3] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE thinking" }
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"inputs": { "source": ["9", 4] },
|
||||||
|
"class_type": "PreviewAny",
|
||||||
|
"_meta": { "title": "PE parse_ok" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||||
|
},
|
||||||
|
"class_type": "UnetLoaderGGUF",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"inputs": {
|
||||||
|
"shift": 3.1,
|
||||||
|
"model": ["4", 0]
|
||||||
|
},
|
||||||
|
"class_type": "ModelSamplingAuraFlow",
|
||||||
|
"_meta": { "title": "Qwen 2.1 shift" }
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"resolution": 1024
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImage21",
|
||||||
|
"_meta": { "title": "Text Encode Qwen Image 2.1" }
|
||||||
|
},
|
||||||
|
"16": {
|
||||||
|
"inputs": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"batch_size": 1
|
||||||
|
},
|
||||||
|
"class_type": "EmptyLatentImage",
|
||||||
|
"_meta": { "title": "Qwen 2.1 canvas" }
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": 1,
|
||||||
|
"steps": 25,
|
||||||
|
"cfg": 1,
|
||||||
|
"sampler_name": "euler",
|
||||||
|
"scheduler": "simple",
|
||||||
|
"denoise": 1,
|
||||||
|
"model": ["3", 0],
|
||||||
|
"positive": ["9", 0],
|
||||||
|
"negative": ["9", 1],
|
||||||
|
"latent_image": ["16", 0]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"_meta": { "title": "KSampler" }
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["15", 0],
|
||||||
|
"vae": ["6", 0]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": { "title": "VAE Decode" }
|
||||||
|
},
|
||||||
|
"21": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "studio2-qwen21",
|
||||||
|
"images": ["20", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": { "title": "Save Image" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"4": {
|
||||||
|
"inputs": {
|
||||||
|
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||||
|
},
|
||||||
|
"class_type": "UnetLoaderGGUF",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"inputs": {
|
||||||
|
"model": ["4", 0],
|
||||||
|
"lora_name": "Qwen-Image-2.1-viggle-turbo-v0.2.1-6step-lora-r128.safetensors",
|
||||||
|
"strength": 1.0
|
||||||
|
},
|
||||||
|
"class_type": "ViggleTurboLora",
|
||||||
|
"_meta": { "title": "Viggle Turbo LoRA (unmerged)" }
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||||
|
"type": "qwen_image",
|
||||||
|
"device": "default"
|
||||||
|
},
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {
|
||||||
|
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"inputs": {
|
||||||
|
"clip": ["5", 0],
|
||||||
|
"prompt": "",
|
||||||
|
"negative_prompt": "",
|
||||||
|
"resolution": 1024
|
||||||
|
},
|
||||||
|
"class_type": "TextEncodeQwenImage21",
|
||||||
|
"_meta": { "title": "Text Encode Qwen Image 2.1" }
|
||||||
|
},
|
||||||
|
"16": {
|
||||||
|
"inputs": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"batch_size": 1
|
||||||
|
},
|
||||||
|
"class_type": "EmptyLatentImage",
|
||||||
|
"_meta": { "title": "Qwen 2.1 canvas" }
|
||||||
|
},
|
||||||
|
"30": {
|
||||||
|
"inputs": {
|
||||||
|
"model": ["7", 0],
|
||||||
|
"conditioning": ["9", 0]
|
||||||
|
},
|
||||||
|
"class_type": "BasicGuider",
|
||||||
|
"_meta": { "title": "CFG off (Turbo)" }
|
||||||
|
},
|
||||||
|
"31": {
|
||||||
|
"inputs": {
|
||||||
|
"noise_seed": 1
|
||||||
|
},
|
||||||
|
"class_type": "RandomNoise",
|
||||||
|
"_meta": { "title": "Noise" }
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"inputs": {
|
||||||
|
"sampler_name": "euler"
|
||||||
|
},
|
||||||
|
"class_type": "KSamplerSelect",
|
||||||
|
"_meta": { "title": "euler" }
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"inputs": {
|
||||||
|
"latent": ["16", 0],
|
||||||
|
"nodes": "1.0, 0.9375, 0.875, 0.75, 0.5, 0.25"
|
||||||
|
},
|
||||||
|
"class_type": "ViggleTurboSigmas",
|
||||||
|
"_meta": { "title": "Viggle Turbo Sigmas" }
|
||||||
|
},
|
||||||
|
"34": {
|
||||||
|
"inputs": {
|
||||||
|
"noise": ["31", 0],
|
||||||
|
"guider": ["30", 0],
|
||||||
|
"sampler": ["32", 0],
|
||||||
|
"sigmas": ["33", 0],
|
||||||
|
"latent_image": ["16", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SamplerCustomAdvanced",
|
||||||
|
"_meta": { "title": "Turbo Sampler" }
|
||||||
|
},
|
||||||
|
"35": {
|
||||||
|
"inputs": {
|
||||||
|
"samples": ["34", 0],
|
||||||
|
"vae": ["6", 0]
|
||||||
|
},
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"_meta": { "title": "VAE Decode" }
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "studio2-qwen21-turbo",
|
||||||
|
"images": ["35", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"_meta": { "title": "Save Image" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { resumeCaptionJobs } from '~/server/utils/caption'
|
||||||
|
|
||||||
|
export default defineNitroPlugin(() => { resumeCaptionJobs() })
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { resumeYue2Jobs } from '~/server/utils/yue2'
|
||||||
|
|
||||||
|
// Restore live music IDs before queue repair examines their durable studio rows.
|
||||||
|
export default defineNitroPlugin(() => { resumeYue2Jobs() })
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
import { resumeYueGpJobs } from '~/server/utils/yueGp'
|
// YuEGP resume removed — music is YuE2-only. scripts/yuegp-* remain on disk unused.
|
||||||
|
export default defineNitroPlugin(() => {})
|
||||||
// Restore live music IDs before queue repair examines their durable studio rows.
|
|
||||||
export default defineNitroPlugin(() => { resumeYueGpJobs() })
|
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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/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) {
|
||||||
|
return Boolean(jobId) && 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 } : {}),
|
||||||
|
// Caption loads a 7B VLM; allow the host POST to finish or fall through to watch
|
||||||
|
signal: AbortSignal.timeout(method === 'PUT' ? 120_000 : method === 'POST' ? 300_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 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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 })
|
||||||
|
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.resultText = text
|
||||||
|
writeSidecar(record.owner, record.stillId, text)
|
||||||
|
persist({ ...record, resultText: text, status: 'complete' })
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
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 on 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 })
|
||||||
|
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'
|
||||||
|
if (record.resultText) job.resultText = record.resultText
|
||||||
|
job.message = 'Reconnecting to caption host'
|
||||||
|
void watch(job, record)
|
||||||
|
} catch { /* keep bad records */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,11 +139,44 @@ export async function updateGenerationLogByStudioJob(
|
|||||||
export async function requeueGenerationLog(owner: string, id: string) {
|
export async function requeueGenerationLog(owner: string, id: string) {
|
||||||
const entry = getGenerationLogEntry(owner, id)
|
const entry = getGenerationLogEntry(owner, id)
|
||||||
const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue')
|
const { addStudioJob, kickStudioQueue } = await import('~/server/utils/studioQueue')
|
||||||
|
let payload = structuredClone(entry.payload)
|
||||||
|
if (payload?.studio2Id || payload?.studio2Request) {
|
||||||
|
const { saveRecord, readRecord } = await import('~/server/utils/studio2/store')
|
||||||
|
let request = payload.studio2Request
|
||||||
|
if (!request && payload.studio2Id) {
|
||||||
|
try { request = structuredClone(readRecord(owner, payload.studio2Id).request) }
|
||||||
|
catch { request = null }
|
||||||
|
}
|
||||||
|
if (!request) throw createError({ statusCode: 400, statusMessage: 'That Studio 2 job has no saved request to re-queue.' })
|
||||||
|
const studio2Id = crypto.randomUUID()
|
||||||
|
saveRecord({
|
||||||
|
id: studio2Id,
|
||||||
|
owner,
|
||||||
|
request: structuredClone(request),
|
||||||
|
state: 'waiting',
|
||||||
|
queuedAt: Date.now(),
|
||||||
|
familyId: crypto.randomUUID(),
|
||||||
|
outputs: [],
|
||||||
|
purgeResult: 'Not yet saved'
|
||||||
|
})
|
||||||
|
payload = {
|
||||||
|
...payload,
|
||||||
|
...structuredClone(request.settings || {}),
|
||||||
|
studio2Id,
|
||||||
|
studio2Request: structuredClone(request),
|
||||||
|
prompt: request.compiledPrompt || payload.prompt,
|
||||||
|
folderId: request.folderId || payload.folderId,
|
||||||
|
extensions: [],
|
||||||
|
referenceStillIds: [],
|
||||||
|
useIdentityRefs: false,
|
||||||
|
queueAutoRun: true
|
||||||
|
}
|
||||||
|
}
|
||||||
const studio = await addStudioJob({
|
const studio = await addStudioJob({
|
||||||
ownerKey: owner,
|
ownerKey: owner,
|
||||||
familyId: crypto.randomUUID(),
|
familyId: crypto.randomUUID(),
|
||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
payload: structuredClone(entry.payload)
|
payload
|
||||||
})
|
})
|
||||||
await kickStudioQueue()
|
await kickStudioQueue()
|
||||||
return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) }
|
return { studioJobId: studio.id, entry: listGenerationLog(owner, 1)[0] || getGenerationLogEntry(owner, id) }
|
||||||
|
|||||||
@@ -166,17 +166,12 @@ function sanitizeImageV2Settings(raw: unknown): ImageV2PresetSettings {
|
|||||||
|
|
||||||
function sanitizeMusicSettings(raw: unknown): MusicPresetSettings {
|
function sanitizeMusicSettings(raw: unknown): MusicPresetSettings {
|
||||||
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
|
const rec = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
|
||||||
const instrumental = rec.instrumental === true
|
|
||||||
return {
|
return {
|
||||||
tags: sanitizeMusicTags(rec.tags),
|
tags: sanitizeMusicTags(rec.tags),
|
||||||
lyrics: sanitizeMusicLyrics(rec.lyrics, instrumental),
|
lyrics: sanitizeMusicLyrics(rec.lyrics, false),
|
||||||
instrumental,
|
|
||||||
duration: clampMusicDuration(rec.duration),
|
duration: clampMusicDuration(rec.duration),
|
||||||
steps: clampMusicSteps(rec.steps),
|
|
||||||
seed: typeof rec.seed === 'number' && Number.isFinite(rec.seed) ? Math.max(0, Math.floor(rec.seed)) : undefined,
|
seed: typeof rec.seed === 'number' && Number.isFinite(rec.seed) ? Math.max(0, Math.floor(rec.seed)) : undefined,
|
||||||
cfg: rec.cfg == null ? undefined : clampMusicCfg(rec.cfg),
|
engine: 'yue2'
|
||||||
engine: parseMusicEngine(rec.engine),
|
|
||||||
...(parseMusicEngine(rec.engine) === 'yue' ? { yueProfile: rec.yueProfile === 3 ? 3 as const : 1 as const } : {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,12 @@ export interface Job {
|
|||||||
studio2?: boolean
|
studio2?: boolean
|
||||||
upscale?: boolean
|
upscale?: boolean
|
||||||
yueGp?: boolean
|
yueGp?: 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
|
||||||
@@ -221,6 +224,7 @@ export function jobSnapshot(job: Job) {
|
|||||||
clipId: job.clipId,
|
clipId: job.clipId,
|
||||||
stillId: job.stillId,
|
stillId: job.stillId,
|
||||||
trackId: job.trackId,
|
trackId: job.trackId,
|
||||||
|
resultText: job.resultText,
|
||||||
hideThumbnail: job.hideThumbnail,
|
hideThumbnail: job.hideThumbnail,
|
||||||
error: job.error,
|
error: job.error,
|
||||||
folderLocked: job.library?.folderLocked,
|
folderLocked: job.library?.folderLocked,
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export interface LibraryStill {
|
|||||||
role: StillRole
|
role: StillRole
|
||||||
name?: string
|
name?: string
|
||||||
prompt?: string
|
prompt?: string
|
||||||
|
promptRaw?: string
|
||||||
promptPre?: string
|
promptPre?: string
|
||||||
promptPost?: string
|
promptPost?: string
|
||||||
familyId?: string
|
familyId?: string
|
||||||
@@ -186,6 +187,7 @@ interface Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const writeChains = new Map<string, Promise<unknown>>()
|
const writeChains = new Map<string, Promise<unknown>>()
|
||||||
|
const catalogCache = new Map<string, { catalog: Catalog; mtimeMs: number }>()
|
||||||
|
|
||||||
export function libraryRoot() {
|
export function libraryRoot() {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -354,6 +356,13 @@ function ensureOwner(owner: string) {
|
|||||||
function readCatalog(owner: string): Catalog {
|
function readCatalog(owner: string): Catalog {
|
||||||
ensureOwner(owner)
|
ensureOwner(owner)
|
||||||
const path = catalogPath(owner)
|
const path = catalogPath(owner)
|
||||||
|
let mtimeMs = 0
|
||||||
|
try {
|
||||||
|
mtimeMs = existsSync(path) ? statSync(path).mtimeMs : 0
|
||||||
|
} catch { mtimeMs = 0 }
|
||||||
|
const hit = catalogCache.get(owner)
|
||||||
|
if (hit && hit.mtimeMs === mtimeMs && mtimeMs > 0) return hit.catalog
|
||||||
|
|
||||||
let catalog: Catalog
|
let catalog: Catalog
|
||||||
try {
|
try {
|
||||||
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||||
@@ -363,8 +372,11 @@ function readCatalog(owner: string): Catalog {
|
|||||||
}
|
}
|
||||||
catalog = emptyCatalog()
|
catalog = emptyCatalog()
|
||||||
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
||||||
|
try { mtimeMs = statSync(path).mtimeMs } catch { mtimeMs = Date.now() }
|
||||||
|
catalogCache.set(owner, { catalog, mtimeMs })
|
||||||
return catalog
|
return catalog
|
||||||
}
|
}
|
||||||
|
catalogCache.set(owner, { catalog, mtimeMs })
|
||||||
return catalog
|
return catalog
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +385,14 @@ function writeCatalog(owner: string, catalog: Catalog) {
|
|||||||
const tmp = catalogPath(owner) + '.tmp'
|
const tmp = catalogPath(owner) + '.tmp'
|
||||||
writeFileSync(tmp, JSON.stringify(catalog, null, 2))
|
writeFileSync(tmp, JSON.stringify(catalog, null, 2))
|
||||||
renameSync(tmp, catalogPath(owner))
|
renameSync(tmp, catalogPath(owner))
|
||||||
|
let mtimeMs = Date.now()
|
||||||
|
try { mtimeMs = statSync(catalogPath(owner)).mtimeMs } catch { /* ignore */ }
|
||||||
|
catalogCache.set(owner, { catalog, mtimeMs })
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateCatalogCache(owner?: string) {
|
||||||
|
if (owner) catalogCache.delete(owner)
|
||||||
|
else catalogCache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
|
function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
|
||||||
@@ -614,6 +634,164 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paginated metadata page — reads catalog rows only; no existsSync / full publicLibrary map. */
|
||||||
|
export function publicLibraryPage(
|
||||||
|
event: H3Event,
|
||||||
|
opts: { limit?: number; cursor?: string; folderId?: string; type?: string } = {}
|
||||||
|
) {
|
||||||
|
const limit = Math.min(Math.max(Number(opts.limit) || 50, 1), 60)
|
||||||
|
const cursor = String(opts.cursor || '')
|
||||||
|
const folderId = String(opts.folderId || '')
|
||||||
|
const typeFilter = String(opts.type || 'all')
|
||||||
|
const owner = libraryOwnerKey(event)
|
||||||
|
const catalog = readCatalog(owner)
|
||||||
|
const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase()))
|
||||||
|
const clipCounts = new Map<string, number>()
|
||||||
|
const imageCounts = new Map<string, number>()
|
||||||
|
const trackCounts = new Map<string, number>()
|
||||||
|
for (const clip of catalog.clips) {
|
||||||
|
clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1)
|
||||||
|
}
|
||||||
|
for (const still of catalog.stills) {
|
||||||
|
if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue
|
||||||
|
imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1)
|
||||||
|
}
|
||||||
|
for (const track of catalog.tracks || []) {
|
||||||
|
trackCounts.set(track.folderId, (trackCounts.get(track.folderId) || 0) + 1)
|
||||||
|
}
|
||||||
|
const folders = catalog.folders.map(folder => publicFolder(
|
||||||
|
event,
|
||||||
|
folder,
|
||||||
|
clipCounts.get(folder.id) || 0,
|
||||||
|
imageCounts.get(folder.id) || 0,
|
||||||
|
trackCounts.get(folder.id) || 0
|
||||||
|
))
|
||||||
|
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: 'still' | 'clip' | 'track'
|
||||||
|
createdAt: number
|
||||||
|
thumbUrl: string
|
||||||
|
folderId: string
|
||||||
|
prompt?: string
|
||||||
|
promptPre?: string
|
||||||
|
promptPost?: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
role?: string
|
||||||
|
hideInput?: boolean
|
||||||
|
filename?: string
|
||||||
|
duration?: number
|
||||||
|
ext?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Row[] = []
|
||||||
|
if (typeFilter !== 'videos' && typeFilter !== 'music') {
|
||||||
|
for (const still of catalog.stills) {
|
||||||
|
if (!unlockedIds.has(still.folderId)) continue
|
||||||
|
if (folderId && folderId !== 'all' && still.folderId !== folderId) continue
|
||||||
|
if (isPipelineStill(catalog, still, clipIds)) continue
|
||||||
|
rows.push({
|
||||||
|
id: still.id,
|
||||||
|
name: still.name || still.filename || still.id,
|
||||||
|
type: 'still',
|
||||||
|
createdAt: still.createdAt || 0,
|
||||||
|
thumbUrl: `/api/library/stills/${still.id}/thumb`,
|
||||||
|
folderId: still.folderId,
|
||||||
|
prompt: still.prompt,
|
||||||
|
promptPre: still.promptPre,
|
||||||
|
promptPost: still.promptPost,
|
||||||
|
width: still.width,
|
||||||
|
height: still.height,
|
||||||
|
role: stillRole(still),
|
||||||
|
hideInput: still.hideInput,
|
||||||
|
filename: still.filename
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeFilter !== 'images' && typeFilter !== 'music') {
|
||||||
|
for (const clip of catalog.clips) {
|
||||||
|
if (!unlockedIds.has(clip.folderId)) continue
|
||||||
|
if (folderId && folderId !== 'all' && clip.folderId !== folderId) continue
|
||||||
|
rows.push({
|
||||||
|
id: clip.id,
|
||||||
|
name: clip.name || clip.id,
|
||||||
|
type: 'clip',
|
||||||
|
createdAt: clip.createdAt || 0,
|
||||||
|
thumbUrl: `/api/library/clips/${clip.id}/thumb`,
|
||||||
|
folderId: clip.folderId,
|
||||||
|
prompt: clip.prompt,
|
||||||
|
promptPre: clip.promptPre,
|
||||||
|
promptPost: clip.promptPost,
|
||||||
|
width: clip.width,
|
||||||
|
height: clip.height,
|
||||||
|
duration: clip.duration
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeFilter !== 'images' && typeFilter !== 'videos') {
|
||||||
|
for (const track of catalog.tracks || []) {
|
||||||
|
if (!unlockedIds.has(track.folderId)) continue
|
||||||
|
if (folderId && folderId !== 'all' && track.folderId !== folderId) continue
|
||||||
|
rows.push({
|
||||||
|
id: track.id,
|
||||||
|
name: track.name || track.id,
|
||||||
|
type: 'track',
|
||||||
|
createdAt: track.createdAt || 0,
|
||||||
|
thumbUrl: '',
|
||||||
|
folderId: track.folderId,
|
||||||
|
duration: track.duration,
|
||||||
|
ext: track.ext
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0) || String(b.id).localeCompare(String(a.id)))
|
||||||
|
|
||||||
|
let start = 0
|
||||||
|
if (cursor) {
|
||||||
|
if (cursor.startsWith('offset:')) {
|
||||||
|
start = Math.max(0, Math.min(rows.length, Number(cursor.slice(7)) || 0))
|
||||||
|
} else {
|
||||||
|
const idx = rows.findIndex(row => `${row.type}:${row.id}` === cursor)
|
||||||
|
// Unknown cursor → end (never restart at 0 or the client loops the first page forever)
|
||||||
|
start = idx >= 0 ? idx + 1 : rows.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = rows.slice(start, start + limit)
|
||||||
|
const nextStart = start + page.length
|
||||||
|
const hasMore = nextStart < rows.length
|
||||||
|
const nextCursor = hasMore ? `offset:${nextStart}` : null
|
||||||
|
|
||||||
|
const stillIds = new Set(page.filter(r => r.type === 'still').map(r => r.id))
|
||||||
|
const clipIdPage = new Set(page.filter(r => r.type === 'clip').map(r => r.id))
|
||||||
|
const trackIds = new Set(page.filter(r => r.type === 'track').map(r => r.id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders,
|
||||||
|
items: page,
|
||||||
|
nextCursor,
|
||||||
|
hasMore,
|
||||||
|
limit,
|
||||||
|
stills: catalog.stills
|
||||||
|
.filter(s => stillIds.has(s.id))
|
||||||
|
.map(still => ({ ...still, role: stillRole(still) })),
|
||||||
|
clips: catalog.clips
|
||||||
|
.filter(c => clipIdPage.has(c.id))
|
||||||
|
.map(clip => ({ ...clip, name: clip.name || clip.id, hasThumbnail: !clip.hideThumbnail, hasVideo: true })),
|
||||||
|
tracks: (catalog.tracks || [])
|
||||||
|
.filter(t => trackIds.has(t.id))
|
||||||
|
.map(track => ({ ...track, hasAudio: true })),
|
||||||
|
drafts: catalog.drafts,
|
||||||
|
loraAliases: catalog.loraAliases || {},
|
||||||
|
loraTriggers: catalog.loraTriggers || {},
|
||||||
|
keepPrompts: catalog.keepPrompts || emptyKeepPrompts(),
|
||||||
|
total: rows.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function assertLibraryOwner(event: H3Event) {
|
export function assertLibraryOwner(event: H3Event) {
|
||||||
const owner = libraryOwnerKey(event)
|
const owner = libraryOwnerKey(event)
|
||||||
return { owner, catalog: readCatalog(owner) }
|
return { owner, catalog: readCatalog(owner) }
|
||||||
@@ -829,7 +1007,7 @@ export async function ensureScaledThumb(source: string, dest: string) {
|
|||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const build = withThumbSlot(async () => {
|
const build = withThumbSlot(async () => {
|
||||||
const ok = await buildStillThumb(source, dest)
|
const ok = await buildStillThumb(source, dest)
|
||||||
return ok ? dest : source
|
return ok ? dest : ''
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
thumbBuilds.delete(key)
|
thumbBuilds.delete(key)
|
||||||
})
|
})
|
||||||
@@ -1100,14 +1278,42 @@ export function stillThumbFile(owner: string, id: string) {
|
|||||||
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
|
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stillPreviewFile(owner: string, id: string) {
|
||||||
|
return join(ownerRoot(owner), 'stills', `${id}.preview.jpg`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync check — never kicks ffmpeg. Empty string when thumb is missing or stale. */
|
||||||
|
export function existingStillThumbPath(owner: string, id: string) {
|
||||||
|
const source = stillPath(owner, id)
|
||||||
|
const dest = stillThumbFile(owner, id)
|
||||||
|
if (!existsSync(source) || !existsSync(dest)) return ''
|
||||||
|
try {
|
||||||
|
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||||
|
} catch { return '' }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync check for stage preview — never kicks ffmpeg. */
|
||||||
|
export function existingStillPreviewPath(owner: string, id: string) {
|
||||||
|
const source = stillPath(owner, id)
|
||||||
|
const dest = stillPreviewFile(owner, id)
|
||||||
|
if (!existsSync(source) || !existsSync(dest)) return ''
|
||||||
|
try {
|
||||||
|
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||||
|
} catch { return '' }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
function removeStillFiles(owner: string, id: string) {
|
function removeStillFiles(owner: string, id: string) {
|
||||||
rmSync(stillPath(owner, id), { force: true })
|
rmSync(stillPath(owner, id), { force: true })
|
||||||
rmSync(stillThumbFile(owner, id), { force: true })
|
rmSync(stillThumbFile(owner, id), { force: true })
|
||||||
|
rmSync(stillPreviewFile(owner, id), { force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
let thumbSlots = 0
|
let thumbSlots = 0
|
||||||
const thumbWaiters: Array<() => void> = []
|
const thumbWaiters: Array<() => void> = []
|
||||||
const thumbBuilds = new Map<string, Promise<string>>()
|
const thumbBuilds = new Map<string, Promise<string>>()
|
||||||
|
const previewBuilds = new Map<string, Promise<string>>()
|
||||||
|
|
||||||
function withThumbSlot<T>(fn: () => Promise<T>) {
|
function withThumbSlot<T>(fn: () => Promise<T>) {
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
@@ -1124,14 +1330,14 @@ function withThumbSlot<T>(fn: () => Promise<T>) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStillThumb(source: string, dest: string) {
|
function buildStillJpeg(source: string, dest: string, scale: string, quality: string) {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
const proc = spawn('ffmpeg', [
|
const proc = spawn('ffmpeg', [
|
||||||
'-y',
|
'-y',
|
||||||
'-i', source,
|
'-i', source,
|
||||||
'-vf', 'scale=320:-2',
|
'-vf', scale,
|
||||||
'-frames:v', '1',
|
'-frames:v', '1',
|
||||||
'-q:v', '5',
|
'-q:v', quality,
|
||||||
dest
|
dest
|
||||||
], { stdio: 'ignore' })
|
], { stdio: 'ignore' })
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -1149,6 +1355,14 @@ function buildStillThumb(source: string, dest: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildStillThumb(source: string, dest: string) {
|
||||||
|
return buildStillJpeg(source, dest, 'scale=384:-2', '5')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStillPreview(source: string, dest: string) {
|
||||||
|
return buildStillJpeg(source, dest, 'scale=1600:-2', '3')
|
||||||
|
}
|
||||||
|
|
||||||
export async function stillThumbPath(owner: string, id: string) {
|
export async function stillThumbPath(owner: string, id: string) {
|
||||||
const source = stillPath(owner, id)
|
const source = stillPath(owner, id)
|
||||||
const dest = stillThumbFile(owner, id)
|
const dest = stillThumbFile(owner, id)
|
||||||
@@ -1158,12 +1372,13 @@ export async function stillThumbPath(owner: string, id: string) {
|
|||||||
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||||
} catch { /* rebuild */ }
|
} catch { /* rebuild */ }
|
||||||
}
|
}
|
||||||
const key = `${owner}:${id}`
|
const key = `${owner}:${id}:thumb`
|
||||||
const pending = thumbBuilds.get(key)
|
const pending = thumbBuilds.get(key)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const build = withThumbSlot(async () => {
|
const build = withThumbSlot(async () => {
|
||||||
const ok = await buildStillThumb(source, dest)
|
const ok = await buildStillThumb(source, dest)
|
||||||
return ok ? dest : source
|
// Never return the archive master — callers must not stream originals as thumbs
|
||||||
|
return ok ? dest : ''
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
thumbBuilds.delete(key)
|
thumbBuilds.delete(key)
|
||||||
})
|
})
|
||||||
@@ -1171,11 +1386,40 @@ export async function stillThumbPath(owner: string, id: string) {
|
|||||||
return build
|
return build
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureStillThumbnails(owner: string) {
|
export async function stillPreviewPath(owner: string, id: string) {
|
||||||
|
const source = stillPath(owner, id)
|
||||||
|
const dest = stillPreviewFile(owner, id)
|
||||||
|
if (!existsSync(source)) return ''
|
||||||
|
if (existsSync(dest)) {
|
||||||
|
try {
|
||||||
|
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||||
|
} catch { /* rebuild */ }
|
||||||
|
}
|
||||||
|
const key = `${owner}:${id}:preview`
|
||||||
|
const pending = previewBuilds.get(key)
|
||||||
|
if (pending) return pending
|
||||||
|
const build = withThumbSlot(async () => {
|
||||||
|
const ok = await buildStillPreview(source, dest)
|
||||||
|
// Never return the archive master as a stage preview
|
||||||
|
return ok ? dest : ''
|
||||||
|
}).finally(() => {
|
||||||
|
previewBuilds.delete(key)
|
||||||
|
})
|
||||||
|
previewBuilds.set(key, build)
|
||||||
|
return build
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureStillThumbnails(owner: string, opts?: { limit?: number }) {
|
||||||
const catalog = readCatalog(owner)
|
const catalog = readCatalog(owner)
|
||||||
|
const cap = Math.max(0, Math.min(Number(opts?.limit) || 2, 8))
|
||||||
|
let built = 0
|
||||||
for (const still of catalog.stills) {
|
for (const still of catalog.stills) {
|
||||||
|
if (built >= cap) break
|
||||||
if (isPipelineStill(catalog, still)) continue
|
if (isPipelineStill(catalog, still)) continue
|
||||||
|
const dest = stillThumbFile(owner, still.id)
|
||||||
|
if (existsSync(dest)) continue
|
||||||
await stillThumbPath(owner, still.id)
|
await stillThumbPath(owner, still.id)
|
||||||
|
built++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1185,7 +1429,8 @@ export async function maintainLibrary(owner: string, folderId?: string) {
|
|||||||
const prev = lastMaintain.get(owner) || 0
|
const prev = lastMaintain.get(owner) || 0
|
||||||
if (Date.now() - prev < 20000) return
|
if (Date.now() - prev < 20000) return
|
||||||
lastMaintain.set(owner, Date.now())
|
lastMaintain.set(owner, Date.now())
|
||||||
void ensureStillThumbnails(owner).catch(() => null)
|
// At most 2 missing thumbs per tick — never walk the whole catalog on list
|
||||||
|
void ensureStillThumbnails(owner, { limit: 2 }).catch(() => null)
|
||||||
const live = await fetchLiveQueue().catch(() => null)
|
const live = await fetchLiveQueue().catch(() => null)
|
||||||
const busy = Boolean(live && live.running > 0)
|
const busy = Boolean(live && live.running > 0)
|
||||||
if (!busy) {
|
if (!busy) {
|
||||||
@@ -1333,6 +1578,7 @@ export async function saveStill(params: {
|
|||||||
role?: StillRole
|
role?: StillRole
|
||||||
name?: string
|
name?: string
|
||||||
prompt?: string
|
prompt?: string
|
||||||
|
promptRaw?: string
|
||||||
promptPre?: string
|
promptPre?: string
|
||||||
promptPost?: string
|
promptPost?: string
|
||||||
familyId?: string
|
familyId?: string
|
||||||
@@ -1354,8 +1600,9 @@ export async function saveStill(params: {
|
|||||||
const hideInput = typeof params.hideInput === 'boolean' ? params.hideInput === true : existing.hideInput
|
const hideInput = typeof params.hideInput === 'boolean' ? params.hideInput === true : existing.hideInput
|
||||||
const name = typeof params.name === 'string' ? params.name.trim().slice(0, 80) : existing.name
|
const name = typeof params.name === 'string' ? params.name.trim().slice(0, 80) : existing.name
|
||||||
const prompt = typeof params.prompt === 'string' ? params.prompt : existing.prompt
|
const prompt = typeof params.prompt === 'string' ? params.prompt : existing.prompt
|
||||||
|
const promptRaw = typeof params.promptRaw === 'string' ? params.promptRaw : existing.promptRaw
|
||||||
const nextSettings = params.settings ? normalizeStillSettings(params.settings) : undefined
|
const nextSettings = params.settings ? normalizeStillSettings(params.settings) : undefined
|
||||||
if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt || nextSettings) {
|
if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt || existing.promptRaw !== promptRaw || nextSettings) {
|
||||||
await mutate(params.ownerKey, (next) => {
|
await mutate(params.ownerKey, (next) => {
|
||||||
const item = next.stills.find(still => still.id === existing.id)
|
const item = next.stills.find(still => still.id === existing.id)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
@@ -1363,6 +1610,7 @@ export async function saveStill(params: {
|
|||||||
item.role = role
|
item.role = role
|
||||||
if (name) item.name = name
|
if (name) item.name = name
|
||||||
if (prompt) item.prompt = prompt
|
if (prompt) item.prompt = prompt
|
||||||
|
if (params.promptRaw != null) item.promptRaw = params.promptRaw || undefined
|
||||||
if (params.promptPre != null) item.promptPre = params.promptPre || undefined
|
if (params.promptPre != null) item.promptPre = params.promptPre || undefined
|
||||||
if (params.promptPost != null) item.promptPost = params.promptPost || undefined
|
if (params.promptPost != null) item.promptPost = params.promptPost || undefined
|
||||||
if (params.familyId) item.familyId = params.familyId
|
if (params.familyId) item.familyId = params.familyId
|
||||||
@@ -1374,6 +1622,7 @@ export async function saveStill(params: {
|
|||||||
existing.role = role
|
existing.role = role
|
||||||
if (name) existing.name = name
|
if (name) existing.name = name
|
||||||
if (prompt) existing.prompt = prompt
|
if (prompt) existing.prompt = prompt
|
||||||
|
if (params.promptRaw != null) existing.promptRaw = params.promptRaw || undefined
|
||||||
if (params.promptPre != null) existing.promptPre = params.promptPre || undefined
|
if (params.promptPre != null) existing.promptPre = params.promptPre || undefined
|
||||||
if (params.promptPost != null) existing.promptPost = params.promptPost || undefined
|
if (params.promptPost != null) existing.promptPost = params.promptPost || undefined
|
||||||
if (nextSettings) existing.settings = nextSettings
|
if (nextSettings) existing.settings = nextSettings
|
||||||
@@ -1395,6 +1644,7 @@ export async function saveStill(params: {
|
|||||||
role,
|
role,
|
||||||
name: params.name?.trim().slice(0, 80) || undefined,
|
name: params.name?.trim().slice(0, 80) || undefined,
|
||||||
prompt: params.prompt || undefined,
|
prompt: params.prompt || undefined,
|
||||||
|
promptRaw: params.promptRaw || undefined,
|
||||||
promptPre: params.promptPre || undefined,
|
promptPre: params.promptPre || undefined,
|
||||||
promptPost: params.promptPost || undefined,
|
promptPost: params.promptPost || undefined,
|
||||||
familyId: params.familyId,
|
familyId: params.familyId,
|
||||||
@@ -1408,6 +1658,7 @@ export async function saveStill(params: {
|
|||||||
next.stills.unshift(still)
|
next.stills.unshift(still)
|
||||||
})
|
})
|
||||||
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
|
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
|
||||||
|
void stillPreviewPath(params.ownerKey, still.id).catch(() => null)
|
||||||
return still
|
return still
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1788,6 +2039,10 @@ export function findYueGpTrack(owner: string, jobId: string) {
|
|||||||
return readCatalog(owner).tracks.find(track => track.comfyFilename === `yuegp-${jobId}.wav`)
|
return readCatalog(owner).tracks.find(track => track.comfyFilename === `yuegp-${jobId}.wav`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findYue2Track(owner: string, jobId: string) {
|
||||||
|
return readCatalog(owner).tracks.find(track => track.comfyFilename === `yue2-${jobId}.wav`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveTrack(params: {
|
export async function saveTrack(params: {
|
||||||
ownerKey: string
|
ownerKey: string
|
||||||
folderId: string
|
folderId: string
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type LoraCache = {
|
|||||||
let cache: LoraCache | null = null
|
let cache: LoraCache | null = null
|
||||||
let inflight: Promise<LoraCache> | null = null
|
let inflight: Promise<LoraCache> | null = null
|
||||||
let inflightFresh = false
|
let inflightFresh = false
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
function uniqueNames(values: unknown[]) {
|
function uniqueNames(values: unknown[]) {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -80,14 +81,44 @@ function namesFromObjectInfoNode(info: unknown) {
|
|||||||
return uniqueNames(names)
|
return uniqueNames(names)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(path: string, via: 'video' | 'image') {
|
async function fetchJson(path: string, via: 'video' | 'image', timeoutMs = 8000) {
|
||||||
const res = via === 'image'
|
const res = via === 'image'
|
||||||
? await imageComfyFetch(path, { signal: AbortSignal.timeout(8000) })
|
? await imageComfyFetch(path, { signal: AbortSignal.timeout(timeoutMs) })
|
||||||
: await comfyFetch(path, { signal: AbortSignal.timeout(8000) })
|
: await comfyFetch(path, { signal: AbortSignal.timeout(timeoutMs) })
|
||||||
if (!res.ok) return null
|
if (!res.ok) return null
|
||||||
return res.json().catch(() => null)
|
return res.json().catch(() => null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectObjectInfoLoras(all: Record<string, unknown>) {
|
||||||
|
const image: string[] = []
|
||||||
|
const video: string[] = []
|
||||||
|
let any = false
|
||||||
|
for (const node of OBJECT_INFO_NODES) {
|
||||||
|
const info = all[node]
|
||||||
|
if (!info || typeof info !== 'object') continue
|
||||||
|
const names = namesFromObjectInfoNode(info)
|
||||||
|
if (!names.length) continue
|
||||||
|
any = true
|
||||||
|
if (node === LORA_LOADER) image.push(...names)
|
||||||
|
else video.push(...names)
|
||||||
|
if (node !== LORA_LOADER && node !== LORA_MODEL_ONLY) {
|
||||||
|
image.push(...names)
|
||||||
|
video.push(...names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (any) return { image: uniqueNames(image), video: uniqueNames(video) }
|
||||||
|
|
||||||
|
const imageAll: string[] = []
|
||||||
|
const videoAll: string[] = []
|
||||||
|
for (const [classType, info] of Object.entries(all)) {
|
||||||
|
if (!/lora/i.test(classType)) continue
|
||||||
|
const names = namesFromObjectInfoNode(info)
|
||||||
|
if (classType === LORA_LOADER) imageAll.push(...names)
|
||||||
|
else videoAll.push(...names)
|
||||||
|
}
|
||||||
|
return { image: uniqueNames(imageAll), video: uniqueNames(videoAll) }
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchModelsLoras(via: 'video' | 'image') {
|
async function fetchModelsLoras(via: 'video' | 'image') {
|
||||||
const payload = await fetchJson('/models/loras', via)
|
const payload = await fetchJson('/models/loras', via)
|
||||||
if (Array.isArray(payload)) return uniqueNames(payload)
|
if (Array.isArray(payload)) return uniqueNames(payload)
|
||||||
@@ -97,7 +128,14 @@ async function fetchModelsLoras(via: 'video' | 'image') {
|
|||||||
return [] as string[]
|
return [] as string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchObjectInfoLoras(via: 'video' | 'image') {
|
async function fetchObjectInfoLoras(via: 'video' | 'image', fresh = false) {
|
||||||
|
// Full /object_info is Comfy's model-list refresh (UI Refresh) and lists LoraLoader / model-only / rgthree / WanVideoLoraSelect.
|
||||||
|
if (fresh) {
|
||||||
|
const all = await fetchJson('/object_info', via, 45_000)
|
||||||
|
if (all && typeof all === 'object') return collectObjectInfoLoras(all as Record<string, unknown>)
|
||||||
|
return { image: [] as string[], video: [] as string[] }
|
||||||
|
}
|
||||||
|
|
||||||
const image: string[] = []
|
const image: string[] = []
|
||||||
const video: string[] = []
|
const video: string[] = []
|
||||||
let any = false
|
let any = false
|
||||||
@@ -120,22 +158,14 @@ async function fetchObjectInfoLoras(via: 'video' | 'image') {
|
|||||||
return { image: uniqueNames(image), video: uniqueNames(video) }
|
return { image: uniqueNames(image), video: uniqueNames(video) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = await fetchJson('/object_info', via)
|
const all = await fetchJson('/object_info', via, 45_000)
|
||||||
if (!all || typeof all !== 'object') return { image: [] as string[], video: [] as string[] }
|
if (!all || typeof all !== 'object') return { image: [] as string[], video: [] as string[] }
|
||||||
const imageAll: string[] = []
|
return collectObjectInfoLoras(all as Record<string, unknown>)
|
||||||
const videoAll: string[] = []
|
|
||||||
for (const [classType, info] of Object.entries(all as Record<string, unknown>)) {
|
|
||||||
if (!/lora/i.test(classType)) continue
|
|
||||||
const names = namesFromObjectInfoNode(info)
|
|
||||||
if (classType === LORA_LOADER) imageAll.push(...names)
|
|
||||||
else videoAll.push(...names)
|
|
||||||
}
|
|
||||||
return { image: uniqueNames(imageAll), video: uniqueNames(videoAll) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discoverFromHost(via: 'video' | 'image') {
|
async function discoverFromHost(via: 'video' | 'image', fresh = false) {
|
||||||
|
const fromInfo = await fetchObjectInfoLoras(via, fresh).catch(() => ({ image: [] as string[], video: [] as string[] }))
|
||||||
const models = await fetchModelsLoras(via).catch(() => [] as string[])
|
const models = await fetchModelsLoras(via).catch(() => [] as string[])
|
||||||
const fromInfo = await fetchObjectInfoLoras(via).catch(() => ({ image: [] as string[], video: [] as string[] }))
|
|
||||||
const image = uniqueNames([...fromInfo.image, ...models])
|
const image = uniqueNames([...fromInfo.image, ...models])
|
||||||
const video = uniqueNames([...fromInfo.video, ...models])
|
const video = uniqueNames([...fromInfo.video, ...models])
|
||||||
if (!image.length && video.length) return { image: video, video }
|
if (!image.length && video.length) return { image: video, video }
|
||||||
@@ -147,17 +177,19 @@ async function loadLoraCache(options: { fresh?: boolean } = {}): Promise<LoraCac
|
|||||||
const fresh = options.fresh === true
|
const fresh = options.fresh === true
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (!fresh && cache && now - cache.at < CACHE_MS) return cache
|
if (!fresh && cache && now - cache.at < CACHE_MS) return cache
|
||||||
|
// Fresh must not reuse a non-fresh inflight (stale list / frozen CACHE_MS window).
|
||||||
if (inflight && (!fresh || inflightFresh)) return inflight
|
if (inflight && (!fresh || inflightFresh)) return inflight
|
||||||
|
const seq = ++loadSeq
|
||||||
inflightFresh = fresh
|
inflightFresh = fresh
|
||||||
inflight = (async () => {
|
inflight = (async () => {
|
||||||
const videoHost = comfyConfigured() ? getComfyHost() : ''
|
const videoHost = comfyConfigured() ? getComfyHost() : ''
|
||||||
const imageHost = getBeastImageHost()
|
const imageHost = getBeastImageHost()
|
||||||
const same = Boolean(videoHost && imageHost && sameImageHost(videoHost, imageHost))
|
const same = Boolean(videoHost && imageHost && sameImageHost(videoHost, imageHost))
|
||||||
const video = videoHost
|
const video = videoHost
|
||||||
? await discoverFromHost('video').catch(() => ({ image: [] as string[], video: [] as string[] }))
|
? await discoverFromHost('video', fresh).catch(() => ({ image: [] as string[], video: [] as string[] }))
|
||||||
: { image: [] as string[], video: [] as string[] }
|
: { image: [] as string[], video: [] as string[] }
|
||||||
const image = imageHost && !same
|
const image = imageHost && !same
|
||||||
? await discoverFromHost('image').catch(() => ({ image: [] as string[], video: [] as string[] }))
|
? await discoverFromHost('image', fresh).catch(() => ({ image: [] as string[], video: [] as string[] }))
|
||||||
: video
|
: video
|
||||||
const next: LoraCache = {
|
const next: LoraCache = {
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
@@ -166,15 +198,19 @@ async function loadLoraCache(options: { fresh?: boolean } = {}): Promise<LoraCac
|
|||||||
}
|
}
|
||||||
// Comfy asleep / unreachable returns empty. Keep the last good list so the picker
|
// Comfy asleep / unreachable returns empty. Keep the last good list so the picker
|
||||||
// does not vanish — but do NOT refresh `at`, or a blip freezes new folder drops out.
|
// does not vanish — but do NOT refresh `at`, or a blip freezes new folder drops out.
|
||||||
// Never merge a shorter fresh list back into the old one: deletes must disappear.
|
// Never merge a shorter live list into the old one: deletes must disappear; adds must appear.
|
||||||
if (!next.image.length && !next.video.length && cache && (cache.image.length || cache.video.length)) {
|
if (!next.image.length && !next.video.length && cache && (cache.image.length || cache.video.length)) {
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
// Superseded non-fresh must not overwrite a newer fresh result.
|
||||||
|
if (seq !== loadSeq) return cache || next
|
||||||
cache = next
|
cache = next
|
||||||
return next
|
return next
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
inflight = null
|
if (seq === loadSeq) {
|
||||||
inflightFresh = false
|
inflight = null
|
||||||
|
inflightFresh = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return inflight
|
return inflight
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import { startYueGpJob } from './yueGp'
|
import { startYue2Job } from './yue2'
|
||||||
import { createJob, emitJob, type Job } from '~/server/utils/jobs'
|
|
||||||
import { extractAudio, fetchHistory, fetchHistoryAll, findHistoryAudio, purgeComfyArtifacts, queuePrompt } from '~/server/utils/comfy'
|
|
||||||
import { comfyWsUrl, comfyFetch } from '~/server/utils/comfy'
|
|
||||||
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
|
|
||||||
import { downloadComfyAudio, saveTrack } from '~/server/utils/library'
|
|
||||||
import { buildMusicWorkflow, musicFilenamePrefix, assertMusicEngineNodes } from '~/server/utils/musicWorkflow'
|
|
||||||
import { NODE_LABELS } from '~/server/utils/workflow'
|
|
||||||
import { musicExtension } from '~/utils/music'
|
|
||||||
import type { MusicEngine } from '~/utils/music'
|
import type { MusicEngine } from '~/utils/music'
|
||||||
|
|
||||||
export type MusicJobParams = {
|
export type MusicJobParams = {
|
||||||
@@ -22,412 +14,22 @@ export type MusicJobParams = {
|
|||||||
lyricsStrength: number
|
lyricsStrength: number
|
||||||
instrumental: boolean
|
instrumental: boolean
|
||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
yueProfile?: 1 | 3
|
|
||||||
engine?: MusicEngine
|
engine?: MusicEngine
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number) {
|
/** Music generation is YuE2-only. ACE Comfy and YuEGP paths are removed. */
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
export async function startMusicJob(params: MusicJobParams) {
|
||||||
}
|
if (params.instrumental) {
|
||||||
|
throw new Error('YuE2 requires lyrics. Instrumental mode is not supported.')
|
||||||
function classifyError(message: string) {
|
|
||||||
const lower = message.toLowerCase()
|
|
||||||
if (lower.includes('out of memory') || lower.includes('exceed allowed memory') || (lower.includes('cuda') && lower.includes('alloc')) || lower.includes('vram')) {
|
|
||||||
return 'ComfyUI ran out of VRAM.'
|
|
||||||
}
|
}
|
||||||
if (lower.includes('econnrefused') || lower.includes('unreachable') || lower.includes('fetch failed')) {
|
if (!String(params.lyrics || '').trim()) {
|
||||||
return 'ComfyUI host connection dropped. Confirm the desktop instance is running.'
|
throw new Error('YuE2 needs lyrics.')
|
||||||
}
|
}
|
||||||
if (lower.includes('timeout')) {
|
return startYue2Job({
|
||||||
return 'Network timeout talking to ComfyUI. The job may still be running on the desktop.'
|
...params,
|
||||||
}
|
engine: 'yue2',
|
||||||
return message
|
instrumental: false
|
||||||
}
|
|
||||||
|
|
||||||
async function settleMusicStudio(job: Job) {
|
|
||||||
try {
|
|
||||||
const { onLiveVideoSettled } = await import('~/server/utils/studioQueue')
|
|
||||||
await onLiveVideoSettled(job)
|
|
||||||
} catch {
|
|
||||||
/* queue kick is best-effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeLabel(node: string) {
|
|
||||||
if (!node) return ''
|
|
||||||
return NODE_LABELS[node] || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForSocket(job: Job, ms = 4000) {
|
|
||||||
const started = Date.now()
|
|
||||||
while (Date.now() - started < ms) {
|
|
||||||
if (job.socketReady) return
|
|
||||||
await sleep(100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchMusicJob(job: Job): Promise<void> {
|
|
||||||
job.socketReady = false
|
|
||||||
job.saving = false
|
|
||||||
const ws = new WebSocket(comfyWsUrl(job.clientId))
|
|
||||||
let settled = false
|
|
||||||
let finishing = false
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let localProgress = 0
|
|
||||||
const startedAt = Date.now()
|
|
||||||
let lastActivity = Date.now()
|
|
||||||
const IDLE_MS = 30 * 60 * 1000
|
|
||||||
const ABSOLUTE_MS = 3 * 60 * 60 * 1000
|
|
||||||
|
|
||||||
const markActivity = () => { lastActivity = Date.now() }
|
|
||||||
|
|
||||||
const emitLocal = (event: Parameters<typeof emitJob>[1], samplePct?: number) => {
|
|
||||||
markActivity()
|
|
||||||
if (typeof event.progress === 'number') localProgress = event.progress
|
|
||||||
if (event.message && typeof samplePct === 'number') {
|
|
||||||
event.message = `${event.message} (${samplePct}%)`
|
|
||||||
}
|
|
||||||
emitJob(job, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
if (timeout) clearTimeout(timeout)
|
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
|
||||||
timeout = null
|
|
||||||
pollTimer = null
|
|
||||||
try { ws.close() } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const armIdle = () => {
|
|
||||||
if (timeout) clearTimeout(timeout)
|
|
||||||
if (settled) return
|
|
||||||
const absLeft = ABSOLUTE_MS - (Date.now() - startedAt)
|
|
||||||
if (absLeft <= 0) {
|
|
||||||
void fail('Timed out waiting for ComfyUI (3 hours).')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const idleLeft = IDLE_MS - (Date.now() - lastActivity)
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
if (settled || finishing) return
|
|
||||||
if (Date.now() - lastActivity >= IDLE_MS) {
|
|
||||||
void fail('Timed out waiting for ComfyUI progress (30 minutes with no updates).')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
armIdle()
|
|
||||||
}, Math.max(1000, Math.min(idleLeft, absLeft)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const fail = async (error: string) => {
|
|
||||||
if (settled || finishing) return
|
|
||||||
settled = true
|
|
||||||
cleanup()
|
|
||||||
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
|
|
||||||
job.error = classifyError(error)
|
|
||||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
|
||||||
await settleMusicStudio(job)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
const adoptPromptId = (id: unknown) => {
|
|
||||||
const promptId = String(id || '').trim()
|
|
||||||
if (promptId && !job.promptId) job.promptId = promptId
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadJobAudio = async () => {
|
|
||||||
if (job.promptId) {
|
|
||||||
const scoped = await fetchHistory(job.promptId)
|
|
||||||
const fromPrompt = extractAudio(scoped, job.promptId)
|
|
||||||
if (fromPrompt) return fromPrompt
|
|
||||||
}
|
|
||||||
const found = findHistoryAudio(await fetchHistoryAll(), {
|
|
||||||
promptId: job.promptId,
|
|
||||||
tags: job.library?.tags,
|
|
||||||
since: job.startedAt - 60_000
|
|
||||||
})
|
|
||||||
if (found) {
|
|
||||||
job.promptId = found.promptId
|
|
||||||
return found.audio
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const succeed = async () => {
|
|
||||||
if (settled || finishing) return false
|
|
||||||
finishing = true
|
|
||||||
job.saving = true
|
|
||||||
try {
|
|
||||||
const audio = await loadJobAudio()
|
|
||||||
if (!audio) {
|
|
||||||
finishing = false
|
|
||||||
job.saving = false
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (settled) return false
|
|
||||||
settled = true
|
|
||||||
cleanup()
|
|
||||||
try {
|
|
||||||
job.audio = audio
|
|
||||||
emitLocal({ type: 'status', message: 'Saving to library...', progress: 98 })
|
|
||||||
if (job.library) {
|
|
||||||
const buffer = await downloadComfyAudio(audio)
|
|
||||||
const ext = musicExtension(audio.filename)
|
|
||||||
const track = await saveTrack({
|
|
||||||
ownerKey: job.library.ownerKey,
|
|
||||||
folderId: job.library.folderId,
|
|
||||||
name: job.library.name,
|
|
||||||
tags: job.library.tags || job.library.prompt,
|
|
||||||
lyrics: job.library.lyrics || '',
|
|
||||||
duration: job.library.duration || 0,
|
|
||||||
steps: job.library.steps,
|
|
||||||
seed: job.library.seed,
|
|
||||||
cfg: job.library.cfg || 0,
|
|
||||||
instrumental: job.library.instrumental === true,
|
|
||||||
audio: buffer,
|
|
||||||
ext,
|
|
||||||
engine: job.library.engine,
|
|
||||||
samplerName: job.library.samplerName,
|
|
||||||
scheduler: job.library.scheduler,
|
|
||||||
lyricsStrength: job.library.lyricsStrength,
|
|
||||||
comfyFilename: audio.filename
|
|
||||||
})
|
|
||||||
job.trackId = track.id
|
|
||||||
job.library.audioExt = ext
|
|
||||||
await purgeComfyArtifacts({
|
|
||||||
audio,
|
|
||||||
promptId: job.promptId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
job.status = 'complete'
|
|
||||||
emitJob(job, {
|
|
||||||
type: 'complete',
|
|
||||||
message: job.library?.folderLocked
|
|
||||||
? 'Saved to the locked folder. Unlock it to listen.'
|
|
||||||
: 'Track ready',
|
|
||||||
progress: 100,
|
|
||||||
filename: job.library?.folderLocked ? undefined : audio.filename,
|
|
||||||
subfolder: job.library?.folderLocked ? undefined : audio.subfolder,
|
|
||||||
mediaType: job.library?.folderLocked ? undefined : audio.type,
|
|
||||||
trackId: job.trackId,
|
|
||||||
folderLocked: job.library?.folderLocked
|
|
||||||
})
|
|
||||||
} catch (saveError) {
|
|
||||||
const message = saveError instanceof Error ? saveError.message : String(saveError)
|
|
||||||
job.status = 'error'
|
|
||||||
job.error = `Audio generated but library save failed: ${message}`
|
|
||||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
|
||||||
}
|
|
||||||
await settleMusicStudio(job)
|
|
||||||
job.saving = false
|
|
||||||
resolve()
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
finishing = false
|
|
||||||
job.saving = false
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pollHistory = async () => {
|
|
||||||
if (settled || finishing) return
|
|
||||||
if (job.promptId) {
|
|
||||||
try {
|
|
||||||
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(2500) })
|
|
||||||
if (response.ok) {
|
|
||||||
const queue = await response.json() as { queue_running?: unknown[][] }
|
|
||||||
job.musicActivity = {
|
|
||||||
checkedAt: Date.now(),
|
|
||||||
running: Boolean(queue.queue_running?.some(row => row[1] === job.promptId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* Keep the last confirmation timestamp so the UI can show stale checks. */ }
|
|
||||||
}
|
|
||||||
if (settled || finishing) return
|
|
||||||
try {
|
|
||||||
const history = await fetchHistoryAll()
|
|
||||||
const found = findHistoryAudio(history, {
|
|
||||||
promptId: job.promptId,
|
|
||||||
tags: job.library?.tags,
|
|
||||||
since: job.startedAt - 60_000
|
|
||||||
})
|
|
||||||
if (found) {
|
|
||||||
job.promptId = found.promptId
|
|
||||||
await succeed()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const entry = (job.promptId ? history?.[job.promptId] : undefined) as {
|
|
||||||
status?: {
|
|
||||||
status_str?: string
|
|
||||||
completed?: boolean
|
|
||||||
messages?: Array<[string, Record<string, unknown>]>
|
|
||||||
}
|
|
||||||
} | undefined
|
|
||||||
const status = entry?.status?.status_str
|
|
||||||
if (status === 'error') {
|
|
||||||
const err = entry?.status?.messages?.find(([type]) => type === 'execution_error')?.[1]
|
|
||||||
const detail = String(err?.exception_message || err?.message || '').trim()
|
|
||||||
await fail(detail || 'ComfyUI reported an execution error')
|
|
||||||
} else if (status === 'interrupted') await fail('Job interrupted.')
|
|
||||||
} catch {
|
|
||||||
// history can miss while the graph is still running
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
armIdle()
|
|
||||||
pollTimer = setInterval(() => { void pollHistory() }, 4000)
|
|
||||||
|
|
||||||
ws.addEventListener('open', () => {
|
|
||||||
job.socketReady = true
|
|
||||||
emitLocal({ type: 'status', message: 'Connected to ComfyUI', progress: Math.max(localProgress, 4) })
|
|
||||||
})
|
|
||||||
ws.addEventListener('error', () => {
|
|
||||||
job.socketReady = false
|
|
||||||
})
|
|
||||||
ws.addEventListener('message', (event) => {
|
|
||||||
let payload: { type?: string; data?: Record<string, any> }
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(String(event.data))
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const type = String(payload.type || '')
|
|
||||||
const data = payload.data || {}
|
|
||||||
adoptPromptId(data.prompt_id)
|
|
||||||
if (type === 'status') markActivity()
|
|
||||||
if (type === 'progress') {
|
|
||||||
const value = Number(data.value || 0)
|
|
||||||
const max = Number(data.max || 0)
|
|
||||||
const pct = max > 0 ? Math.min(95, Math.round((value / max) * 90) + 8) : Math.max(localProgress, 12)
|
|
||||||
const node = String(data.node || '')
|
|
||||||
emitLocal({
|
|
||||||
type: 'progress',
|
|
||||||
message: nodeLabel(node) || 'Sampling',
|
|
||||||
progress: pct,
|
|
||||||
step: value,
|
|
||||||
maxStep: max,
|
|
||||||
node
|
|
||||||
}, max > 0 ? Math.round((value / max) * 100) : undefined)
|
|
||||||
}
|
|
||||||
if (type === 'executing') {
|
|
||||||
const node = data.node == null ? '' : String(data.node)
|
|
||||||
if (!node) void succeed()
|
|
||||||
else if (node) {
|
|
||||||
emitLocal({
|
|
||||||
type: 'status',
|
|
||||||
message: nodeLabel(node) || 'Running node',
|
|
||||||
progress: Math.max(localProgress, 10),
|
|
||||||
node
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (type === 'executed') {
|
|
||||||
void succeed()
|
|
||||||
}
|
|
||||||
if (type === 'execution_error') {
|
|
||||||
const message = String(data.exception_message || data.message || 'ComfyUI node execution failed')
|
|
||||||
void fail(message)
|
|
||||||
}
|
|
||||||
if (type === 'execution_interrupted') {
|
|
||||||
job.status = 'cancelled'
|
|
||||||
void fail('Job interrupted.')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startMusicJob(params: MusicJobParams) {
|
|
||||||
if (params.engine === 'yue') return startYueGpJob(params)
|
|
||||||
const job = createJob('music')
|
|
||||||
job.library = {
|
|
||||||
ownerKey: params.ownerKey,
|
|
||||||
folderId: params.folderId,
|
|
||||||
hideThumbnail: false,
|
|
||||||
folderLocked: params.folderLocked === true,
|
|
||||||
name: params.name,
|
|
||||||
prompt: params.tags,
|
|
||||||
tags: params.tags,
|
|
||||||
lyrics: params.lyrics,
|
|
||||||
instrumental: params.instrumental,
|
|
||||||
aspect: 'audio',
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
steps: params.steps,
|
|
||||||
turbo: false,
|
|
||||||
seed: params.seed,
|
|
||||||
cfg: params.cfg,
|
|
||||||
duration: params.duration,
|
|
||||||
sound: true,
|
|
||||||
engine: params.engine || 'ace-step',
|
|
||||||
samplerName: params.samplerName || 'euler',
|
|
||||||
scheduler: params.scheduler || 'simple',
|
|
||||||
lyricsStrength: params.lyricsStrength
|
|
||||||
}
|
|
||||||
job.maxStep = params.steps
|
|
||||||
job.status = 'queued'
|
|
||||||
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const done = watchMusicJob(job)
|
|
||||||
await ensureComfyReady((status) => {
|
|
||||||
emitJob(job, {
|
|
||||||
type: status.state === 'busy' ? 'busy' : 'status',
|
|
||||||
message: status.message,
|
|
||||||
progress: status.state === 'online' ? 3 : 1,
|
|
||||||
busy: status.state === 'busy',
|
|
||||||
queueRunning: status.queueRunning,
|
|
||||||
queuePending: status.queuePending
|
|
||||||
})
|
|
||||||
})
|
|
||||||
job.status = 'running'
|
|
||||||
const engineLabel = params.engine === 'ace-step-1.5' ? 'ACE-Step 1.5' : 'ACE-Step'
|
|
||||||
emitJob(job, { type: 'status', message: `Queueing ${engineLabel}...`, progress: 6 })
|
|
||||||
await waitForSocket(job, 4000)
|
|
||||||
await assertMusicEngineNodes(params.engine)
|
|
||||||
const graph = buildMusicWorkflow({
|
|
||||||
tags: params.tags,
|
|
||||||
lyrics: params.instrumental ? '' : params.lyrics,
|
|
||||||
duration: params.duration,
|
|
||||||
steps: params.steps,
|
|
||||||
seed: params.seed,
|
|
||||||
cfg: params.cfg,
|
|
||||||
lyricsStrength: params.instrumental ? 0 : params.lyricsStrength,
|
|
||||||
samplerName: params.samplerName,
|
|
||||||
scheduler: params.scheduler,
|
|
||||||
filenamePrefix: musicFilenamePrefix(),
|
|
||||||
engine: params.engine,
|
|
||||||
vocalMultiplier: params.instrumental ? 0.2 : 1
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
const queued = await queuePrompt(graph, job.clientId, { timeoutMs: 25_000 })
|
|
||||||
if (queued?.prompt_id) job.promptId = queued.prompt_id
|
|
||||||
const runningMessage = 'Job queued on ComfyUI'
|
|
||||||
emitJob(job, { type: 'status', message: runningMessage, progress: 8 })
|
|
||||||
} catch (queueError: unknown) {
|
|
||||||
const statusCode = Number((queueError as { statusCode?: number; status?: number })?.statusCode
|
|
||||||
|| (queueError as { status?: number })?.status || 0)
|
|
||||||
if (statusCode >= 400) throw queueError
|
|
||||||
emitJob(job, {
|
|
||||||
type: 'status',
|
|
||||||
message: 'Waiting for ComfyUI to pick up the job...',
|
|
||||||
progress: 8
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await done
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
|
||||||
if (job.status !== 'cancelled' && job.status !== 'complete') {
|
|
||||||
job.status = 'error'
|
|
||||||
job.error = classifyError(message)
|
|
||||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
|
||||||
await settleMusicStudio(job)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return job
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ function buildAce15Workflow(params: MusicWorkflowParams): WorkflowGraph {
|
|||||||
export function buildMusicWorkflow(params: MusicWorkflowParams): WorkflowGraph {
|
export function buildMusicWorkflow(params: MusicWorkflowParams): WorkflowGraph {
|
||||||
const engine = params.engine || 'ace-step'
|
const engine = params.engine || 'ace-step'
|
||||||
if (engine === 'yue') throw new Error('YuE requires the standalone YuEGP backend.')
|
if (engine === 'yue') throw new Error('YuE requires the standalone YuEGP backend.')
|
||||||
|
if (engine === 'yue2') throw new Error('YuE2 requires the standalone YuE2 backend.')
|
||||||
if (engine === 'ace-step-1.5') return buildAce15Workflow(params)
|
if (engine === 'ace-step-1.5') return buildAce15Workflow(params)
|
||||||
return buildAceV1Workflow(params)
|
return buildAceV1Workflow(params)
|
||||||
}
|
}
|
||||||
@@ -108,6 +109,7 @@ export function buildMusicWorkflow(params: MusicWorkflowParams): WorkflowGraph {
|
|||||||
export async function assertMusicEngineNodes(engine: MusicEngine | undefined) {
|
export async function assertMusicEngineNodes(engine: MusicEngine | undefined) {
|
||||||
const { comfyHasClassType } = await import('~/server/utils/comfy')
|
const { comfyHasClassType } = await import('~/server/utils/comfy')
|
||||||
if (engine === 'yue') throw new Error('YuE cannot run on Comfy.')
|
if (engine === 'yue') throw new Error('YuE cannot run on Comfy.')
|
||||||
|
if (engine === 'yue2') throw new Error('YuE2 cannot run on Comfy.')
|
||||||
if (engine === 'ace-step-1.5') {
|
if (engine === 'ace-step-1.5') {
|
||||||
const present = await comfyHasClassType('TextEncodeAceStepAudio1.5')
|
const present = await comfyHasClassType('TextEncodeAceStepAudio1.5')
|
||||||
if (present === false) {
|
if (present === false) {
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { ownerRoot } from '~/server/utils/library'
|
||||||
|
import {
|
||||||
|
normalizePromptSequence,
|
||||||
|
normalizePromptSequenceName,
|
||||||
|
normalizeCharacter,
|
||||||
|
normalizeSet,
|
||||||
|
normalizeScene,
|
||||||
|
normalizeSectionOrder,
|
||||||
|
emptyCharacter,
|
||||||
|
emptyScene,
|
||||||
|
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||||
|
type PromptSequence,
|
||||||
|
type PromptSequenceCharacter,
|
||||||
|
type PromptSequenceSectionKey,
|
||||||
|
type PromptSequenceSet,
|
||||||
|
type PromptSequenceScene
|
||||||
|
} from '~/utils/promptSequences'
|
||||||
|
|
||||||
|
const writeChains = new Map<string, Promise<unknown>>()
|
||||||
|
|
||||||
|
function sequencesPath(owner: string) {
|
||||||
|
return join(ownerRoot(owner), 'prompt-sequences.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOwner(owner: string) {
|
||||||
|
mkdirSync(ownerRoot(owner), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSequences(owner: string): PromptSequence[] {
|
||||||
|
ensureOwner(owner)
|
||||||
|
const path = sequencesPath(owner)
|
||||||
|
if (!existsSync(path)) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.map(normalizePromptSequence).filter((item): item is PromptSequence => Boolean(item))
|
||||||
|
: []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSequences(owner: string, sequences: PromptSequence[]) {
|
||||||
|
ensureOwner(owner)
|
||||||
|
const path = sequencesPath(owner)
|
||||||
|
const tmp = `${path}.tmp`
|
||||||
|
writeFileSync(tmp, JSON.stringify(sequences, null, 2))
|
||||||
|
renameSync(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutate<T>(owner: string, fn: (sequences: PromptSequence[]) => T): Promise<T> {
|
||||||
|
const prev = writeChains.get(owner) || Promise.resolve()
|
||||||
|
const run = prev.then(() => {
|
||||||
|
const sequences = readSequences(owner)
|
||||||
|
const result = fn(sequences)
|
||||||
|
writeSequences(owner, sequences)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
writeChains.set(owner, run.then(() => undefined, () => undefined))
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCharactersInput(raw: unknown): PromptSequenceCharacter[] {
|
||||||
|
if (!Array.isArray(raw) || !raw.length) return [emptyCharacter()]
|
||||||
|
return raw.map(normalizeCharacter)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSetsInput(raw: unknown): PromptSequenceSet[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw.map(normalizeSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScenesInput(
|
||||||
|
raw: unknown,
|
||||||
|
characters: PromptSequenceCharacter[],
|
||||||
|
sets: PromptSequenceSet[]
|
||||||
|
): PromptSequenceScene[] {
|
||||||
|
if (!Array.isArray(raw) || !raw.length) return [emptyScene(1, characters, sets)]
|
||||||
|
return raw.map((scene, index) => normalizeScene(scene, index, characters, sets))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPromptSequences(owner: string) {
|
||||||
|
return readSequences(owner).sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPromptSequence(owner: string, id: string) {
|
||||||
|
const wanted = String(id || '').trim()
|
||||||
|
const sequence = readSequences(owner).find(item => item.id === wanted)
|
||||||
|
if (!sequence) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
|
||||||
|
return sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePromptSequence(owner: string, input: {
|
||||||
|
name: string
|
||||||
|
sectionOrder?: unknown
|
||||||
|
characters?: unknown
|
||||||
|
sets?: unknown
|
||||||
|
scenes?: unknown
|
||||||
|
overwrite?: boolean
|
||||||
|
}) {
|
||||||
|
const name = normalizePromptSequenceName(input.name)
|
||||||
|
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
|
||||||
|
const characters = normalizeCharactersInput(input.characters)
|
||||||
|
const sets = normalizeSetsInput(input.sets)
|
||||||
|
const scenes = normalizeScenesInput(input.scenes, characters, sets)
|
||||||
|
const sectionOrder = input.sectionOrder !== undefined
|
||||||
|
? normalizeSectionOrder(input.sectionOrder)
|
||||||
|
: [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]
|
||||||
|
const now = Date.now()
|
||||||
|
return mutate(owner, (sequences) => {
|
||||||
|
const existing = sequences.find(item => item.name.toLowerCase() === name.toLowerCase())
|
||||||
|
if (existing && !input.overwrite) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: `Overwrite sequence “${existing.name}”?` })
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
existing.sectionOrder = sectionOrder
|
||||||
|
existing.characters = characters
|
||||||
|
existing.sets = sets
|
||||||
|
existing.scenes = scenes
|
||||||
|
existing.updatedAt = now
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
const created: PromptSequence = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
sectionOrder,
|
||||||
|
characters,
|
||||||
|
sets,
|
||||||
|
scenes,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
sequences.unshift(created)
|
||||||
|
return created
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePromptSequence(owner: string, id: string, input: {
|
||||||
|
name?: string
|
||||||
|
sectionOrder?: unknown
|
||||||
|
characters?: unknown
|
||||||
|
sets?: unknown
|
||||||
|
scenes?: unknown
|
||||||
|
}) {
|
||||||
|
const wanted = String(id || '').trim()
|
||||||
|
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
|
||||||
|
return mutate(owner, (sequences) => {
|
||||||
|
const existing = sequences.find(item => item.id === wanted)
|
||||||
|
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
|
||||||
|
if (input.name !== undefined) {
|
||||||
|
const name = normalizePromptSequenceName(input.name)
|
||||||
|
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
|
||||||
|
const clash = sequences.find(item => item.id !== wanted && item.name.toLowerCase() === name.toLowerCase())
|
||||||
|
if (clash) throw createError({ statusCode: 409, statusMessage: `A sequence named “${clash.name}” already exists` })
|
||||||
|
existing.name = name
|
||||||
|
}
|
||||||
|
if (input.sectionOrder !== undefined) {
|
||||||
|
existing.sectionOrder = normalizeSectionOrder(input.sectionOrder)
|
||||||
|
} else if (!existing.sectionOrder?.length) {
|
||||||
|
existing.sectionOrder = [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER] as PromptSequenceSectionKey[]
|
||||||
|
}
|
||||||
|
if (input.characters !== undefined || input.sets !== undefined || input.scenes !== undefined) {
|
||||||
|
const characters = input.characters !== undefined
|
||||||
|
? normalizeCharactersInput(input.characters)
|
||||||
|
: existing.characters
|
||||||
|
const sets = input.sets !== undefined
|
||||||
|
? normalizeSetsInput(input.sets)
|
||||||
|
: (existing.sets || [])
|
||||||
|
const scenes = input.scenes !== undefined
|
||||||
|
? normalizeScenesInput(input.scenes, characters, sets)
|
||||||
|
: existing.scenes.map((scene, index) => normalizeScene(scene, index, characters, sets))
|
||||||
|
existing.characters = characters
|
||||||
|
existing.sets = sets
|
||||||
|
existing.scenes = scenes
|
||||||
|
}
|
||||||
|
existing.updatedAt = Date.now()
|
||||||
|
return existing
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePromptSequence(owner: string, id: string) {
|
||||||
|
const wanted = String(id || '').trim()
|
||||||
|
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
|
||||||
|
return mutate(owner, (sequences) => {
|
||||||
|
const index = sequences.findIndex(item => item.id === wanted)
|
||||||
|
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
|
||||||
|
const [removed] = sequences.splice(index, 1)
|
||||||
|
return removed
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,13 +8,24 @@ import { queueSeeds } from '~/shared/studio2/seed.mjs';
|
|||||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import template from '../../assets/studio2_minimax_native.json';
|
import template from '../../assets/studio2_minimax_native.json';
|
||||||
|
import qwen21Template from '../../assets/studio2_qwen21_t2i.json'
|
||||||
|
import qwen21EditTemplate from '../../assets/studio2_qwen21_edit.json'
|
||||||
|
import qwen21T2iTurboTemplate from '../../assets/studio2_qwen21_t2i_turbo.json'
|
||||||
|
import qwen21EditTurboTemplate from '../../assets/studio2_qwen21_edit_turbo.json'
|
||||||
|
import qwen21PeT2iTemplate from '../../assets/studio2_qwen21_pe_t2i.json'
|
||||||
|
import qwen21PeEditTemplate from '../../assets/studio2_qwen21_pe_edit.json';
|
||||||
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
||||||
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
||||||
|
import { resolveQwen21Size } from '~/shared/studio2/qwen21-size.mjs';
|
||||||
|
import { ensureQwen21EditPrompt, stitchQwen21PeEditPrompt } from '~/shared/studio2/qwen21-edit.mjs';
|
||||||
|
import { applyPhotosToRequest, injectQwenEditMentionTags } from '~/shared/studio2/photos.mjs';
|
||||||
|
import { qwen21PeRefusal } from '~/shared/studio2/qwen21-pe.mjs';
|
||||||
|
import { QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from '~/shared/studio2/qwen21-turbo.mjs';
|
||||||
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
||||||
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
||||||
import { readRecord, saveRecord, studio2Root, records } from './store';
|
import { readRecord, saveRecord, studio2Root, records } from './store';
|
||||||
import { ensureComfyReady } from '../comfyLifecycle';
|
import { ensureComfyReady } from '../comfyLifecycle';
|
||||||
import { comfyFetch, queuePrompt, fetchHistory, extractVideo } from '../comfy';
|
import { comfyFetch, queuePrompt, fetchHistory, extractVideo, freeComfyVram } from '../comfy';
|
||||||
import { extractEditedImage } from '../imageComfy';
|
import { extractEditedImage } from '../imageComfy';
|
||||||
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
|
import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflowV2';
|
||||||
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
|
import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras';
|
||||||
@@ -34,10 +45,177 @@ function update(r: any, state: string) { r.state = state; r.updatedAt = Date.now
|
|||||||
// extend/video start frame (which is either the exact anchor PNG or an already-canvas-sized
|
// extend/video start frame (which is either the exact anchor PNG or an already-canvas-sized
|
||||||
// extracted last frame). Fitting these every hop is what destroys identity across chained extends.
|
// extracted last frame). Fitting these every hop is what destroys identity across chained extends.
|
||||||
const NEVER_FIT = new Set(['hero', 'start']);
|
const NEVER_FIT = new Set(['hero', 'start']);
|
||||||
|
|
||||||
|
function previewAnyText(outputs: Record<string, any> | undefined, nodeId: string): string {
|
||||||
|
const node = outputs?.[nodeId]
|
||||||
|
if (!node || typeof node !== 'object') return ''
|
||||||
|
const raw = node.text ?? node.STRING ?? node.source
|
||||||
|
if (Array.isArray(raw)) return String(raw[0] ?? '')
|
||||||
|
if (raw == null) return ''
|
||||||
|
return String(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
function harvestQwen21Pe(history: Record<string, any> | null | undefined, promptId: string, edit: boolean) {
|
||||||
|
const outputs = history?.[promptId]?.outputs as Record<string, any> | undefined
|
||||||
|
const positive_prompt = previewAnyText(outputs, '30').trim()
|
||||||
|
const wh_ratio = previewAnyText(outputs, '31').trim()
|
||||||
|
const ratio_follow = edit ? previewAnyText(outputs, '32').trim() : ''
|
||||||
|
const thinking = previewAnyText(outputs, edit ? '33' : '32').trim()
|
||||||
|
const parseRaw = previewAnyText(outputs, edit ? '34' : '33').trim()
|
||||||
|
const parse_ok = /^(true|1|yes)$/i.test(parseRaw)
|
||||||
|
return { positive_prompt, wh_ratio, ratio_follow, thinking, parse_ok }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitPromptHistory(r: any, job: Job, promptId: string) {
|
||||||
|
let history: any = null, missingSince = 0
|
||||||
|
for (;;) {
|
||||||
|
try { history = await fetchHistory(promptId) }
|
||||||
|
catch { history = null }
|
||||||
|
const e = history?.[promptId]
|
||||||
|
if (job.status === 'cancelled') throw new Error('Cancelled')
|
||||||
|
if (['error', 'interrupted'].includes(e?.status?.status_str))
|
||||||
|
throw new Error('Comfy generation failed or was interrupted; inspect the host error log.')
|
||||||
|
if (!e) {
|
||||||
|
try {
|
||||||
|
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) })
|
||||||
|
if (response.ok) {
|
||||||
|
const q = await response.json() as any
|
||||||
|
const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === promptId)
|
||||||
|
if (present) missingSince = 0
|
||||||
|
else if (!missingSince) missingSince = Date.now()
|
||||||
|
else if (Date.now() - missingSince > 60000) throw new Error('PROMPT_MISSING')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'PROMPT_MISSING')
|
||||||
|
throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e?.status?.completed || e?.status?.status_str === 'success') break
|
||||||
|
if (job.status === 'cancelled') throw new Error('Cancelled')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
|
}
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeQwen21EditSample(rawPrompt: string, request: any) {
|
||||||
|
const photos = Array.isArray(request?.photos) ? request.photos : []
|
||||||
|
const tagged = injectQwenEditMentionTags(String(rawPrompt || ''), photos)
|
||||||
|
return ensureQwen21EditPrompt(tagged, { hasImage2: !!request?.imageBId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQwen21PromptEnhance(r: any, job: Job) {
|
||||||
|
const q = r.request, s = q.settings
|
||||||
|
if (!q.enhancePrompt || q.engine !== 'qwen21' || !['generate', 'edit'].includes(q.mode)) return
|
||||||
|
const edit = q.mode === 'edit'
|
||||||
|
if (Array.isArray(q.photos) && q.photos.length) {
|
||||||
|
const bound = applyPhotosToRequest(q)
|
||||||
|
q.imageAId = bound.imageAId
|
||||||
|
q.imageBId = bound.imageBId
|
||||||
|
q.identityStillId = bound.identityStillId
|
||||||
|
q.photos = bound.photos
|
||||||
|
}
|
||||||
|
// Edit → PE-I2I only (pe_i2i). Generate → PE-T2I only. Never cross-feed.
|
||||||
|
if (edit && !q.imageAId)
|
||||||
|
throw new Error('Add a photo and mark it Photo to change.')
|
||||||
|
update(r, 'enhancing')
|
||||||
|
emitJob(job, { type: 'status', message: 'Enhancing prompt…' })
|
||||||
|
// promptRaw = exactly what they typed (compiled from sections before PE).
|
||||||
|
const userPrompt = String(q.compiledPrompt || '')
|
||||||
|
r.request.promptRaw = userPrompt
|
||||||
|
// Edit PE gets the raw edit instruction + canvas still — not the keep stanza.
|
||||||
|
const peInput = userPrompt
|
||||||
|
const graph = structuredClone(edit ? qwen21PeEditTemplate : qwen21PeT2iTemplate)
|
||||||
|
graph['9'].inputs.prompt = peInput
|
||||||
|
graph['9'].inputs.seed = s.seed
|
||||||
|
if (edit) {
|
||||||
|
// Photo to change → image_1. Outfit/extra → image_2 when present.
|
||||||
|
const a = await upload(r, 'pe-source', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageAId).id)))
|
||||||
|
graph['10'].inputs.image = a
|
||||||
|
graph['9'].inputs.image_1 = ['10', 0]
|
||||||
|
if (q.imageBId) {
|
||||||
|
const b = await upload(r, 'pe-compose', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageBId).id)))
|
||||||
|
graph['11'] = {
|
||||||
|
inputs: { image: b },
|
||||||
|
class_type: 'LoadImage',
|
||||||
|
_meta: { title: 'image_2 optional' }
|
||||||
|
}
|
||||||
|
graph['9'].inputs.image_2 = ['11', 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.peGraphId = edit ? 'studio2_qwen21_pe_edit.json' : 'studio2_qwen21_pe_t2i.json'
|
||||||
|
// PE is stage 1 of the same studio job — not a complete/requeue.
|
||||||
|
r.progress = null
|
||||||
|
saveRecord(r)
|
||||||
|
const queued = await queuePrompt(graph, job.clientId)
|
||||||
|
r.pePromptId = queued.prompt_id
|
||||||
|
// watchProgress listens on promptId; point it at the PE Comfy prompt for this stage.
|
||||||
|
r.promptId = queued.prompt_id
|
||||||
|
job.promptId = queued.prompt_id
|
||||||
|
saveRecord(r)
|
||||||
|
const history = await waitPromptHistory(r, job, queued.prompt_id)
|
||||||
|
const result = harvestQwen21Pe(history, queued.prompt_id, edit)
|
||||||
|
const refusal = qwen21PeRefusal(userPrompt, result)
|
||||||
|
if (refusal.refused) {
|
||||||
|
// Fail open: keep the job, send the raw brief (Edit keep-identity stanza still applies).
|
||||||
|
const compiled = edit ? finalizeQwen21EditSample(userPrompt, q) : userPrompt
|
||||||
|
r.request.compiledPrompt = compiled
|
||||||
|
r.request.prompt = compiled
|
||||||
|
r.request.enhance = {
|
||||||
|
used: false,
|
||||||
|
wh_ratio: '',
|
||||||
|
...(edit ? { ratio_follow: '' } : {}),
|
||||||
|
parse_ok: !!result.parse_ok,
|
||||||
|
refused: true,
|
||||||
|
skipReason: refusal.reason,
|
||||||
|
thinking: result.thinking || '',
|
||||||
|
comfyPromptId: queued.prompt_id,
|
||||||
|
}
|
||||||
|
} else if (edit) {
|
||||||
|
// Edit only: stanza + raw first; PE chunk only if it is an edit directive (never PE-alone).
|
||||||
|
// Generate keeps the long observer paragraph as the product — no describe sanitize.
|
||||||
|
const stitched = stitchQwen21PeEditPrompt(userPrompt, result.positive_prompt)
|
||||||
|
const compiled = finalizeQwen21EditSample(stitched.prompt, q)
|
||||||
|
r.request.compiledPrompt = compiled
|
||||||
|
r.request.prompt = compiled
|
||||||
|
r.request.enhance = {
|
||||||
|
used: !stitched.skippedAsDescribe,
|
||||||
|
wh_ratio: result.wh_ratio || '',
|
||||||
|
ratio_follow: result.ratio_follow || '',
|
||||||
|
parse_ok: !!result.parse_ok,
|
||||||
|
refused: false,
|
||||||
|
...(stitched.skippedAsDescribe ? { skippedAsDescribe: true } : {}),
|
||||||
|
thinking: result.thinking || '',
|
||||||
|
comfyPromptId: queued.prompt_id,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r.request.compiledPrompt = result.positive_prompt
|
||||||
|
r.request.prompt = result.positive_prompt
|
||||||
|
r.request.enhance = {
|
||||||
|
used: true,
|
||||||
|
wh_ratio: result.wh_ratio || '',
|
||||||
|
parse_ok: !!result.parse_ok,
|
||||||
|
refused: false,
|
||||||
|
thinking: result.thinking || '',
|
||||||
|
comfyPromptId: queued.prompt_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (edit || q.mode === 'generate') r.request.task = q.mode
|
||||||
|
// Stage flip PE → generate: clear PE prompt id and reset the bar (no leftover 100%).
|
||||||
|
r.pePromptId = ''
|
||||||
|
r.promptId = ''
|
||||||
|
job.promptId = ''
|
||||||
|
r.progress = null
|
||||||
|
saveRecord(r)
|
||||||
|
await freeComfyVram()
|
||||||
|
// Same job continues into generate — do not complete or requeue.
|
||||||
|
update(r, 'submitting')
|
||||||
|
emitJob(job, { type: 'status', message: 'Generating…' })
|
||||||
|
}
|
||||||
|
|
||||||
async function upload(r: any, name: string, data: Buffer): Promise<string> {
|
async function upload(r: any, name: string, data: Buffer): Promise<string> {
|
||||||
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
|
const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
|
||||||
let fitted = false;
|
let fitted = false;
|
||||||
if (!NEVER_FIT.has(name) && needsFit(data, r.request.settings.width, r.request.settings.height)) {
|
if (!NEVER_FIT.has(name) && r.request?.engine !== 'qwen21' && needsFit(data, r.request.settings.width, r.request.settings.height)) {
|
||||||
data = await fitStill(data, r.request.settings.width, r.request.settings.height);
|
data = await fitStill(data, r.request.settings.width, r.request.settings.height);
|
||||||
fitted = true;
|
fitted = true;
|
||||||
}
|
}
|
||||||
@@ -60,10 +238,21 @@ async function upload(r: any, name: string, data: Buffer): Promise<string> {
|
|||||||
}
|
}
|
||||||
async function prepareGraph(r: any) {
|
async function prepareGraph(r: any) {
|
||||||
const q = r.request, s = q.settings, prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
|
const q = r.request, s = q.settings, prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
|
||||||
|
// Photos roles → graph sockets (imageAId / imageBId / identityStillId). Payload is source of truth.
|
||||||
|
if (Array.isArray(q.photos) && q.photos.length) {
|
||||||
|
const bound = applyPhotosToRequest(q)
|
||||||
|
q.imageAId = bound.imageAId
|
||||||
|
q.imageBId = bound.imageBId
|
||||||
|
q.identityStillId = bound.identityStillId
|
||||||
|
q.photos = bound.photos
|
||||||
|
}
|
||||||
const load = async (id: string, label: string) => id ? upload(r, label, readFileSync(stillPath(r.owner, getStill(r.owner, id).id))) : '';
|
const load = async (id: string, label: string) => id ? upload(r, label, readFileSync(stillPath(r.owner, getStill(r.owner, id).id))) : '';
|
||||||
const video=['video','extend'].includes(q.mode);
|
const video=['video','extend'].includes(q.mode);
|
||||||
const identityPlan=kleinIdentityPlan(q);
|
const identityPlan=kleinIdentityPlan(q);
|
||||||
const sourceId=identityPlan ? identityPlan.sourceId : q.imageAId;
|
// Qwen Edit: Photo to change (imageAId) → image_1. Never Klein hero-ref.
|
||||||
|
const sourceId = q.engine === 'qwen21'
|
||||||
|
? (q.imageAId || '')
|
||||||
|
: (identityPlan ? identityPlan.sourceId : q.imageAId);
|
||||||
await resolveRequestSize(r.owner,identityPlan ? {...q,imageAId:sourceId} : q);
|
await resolveRequestSize(r.owner,identityPlan ? {...q,imageAId:sourceId} : q);
|
||||||
saveRecord(r);
|
saveRecord(r);
|
||||||
const a=await load(sourceId,'source'),b=await load(q.imageBId,'compose'),mask=await load(q.maskId,'mask');
|
const a=await load(sourceId,'source'),b=await load(q.imageBId,'compose'),mask=await load(q.maskId,'mask');
|
||||||
@@ -74,8 +263,6 @@ async function prepareGraph(r: any) {
|
|||||||
|
|
||||||
let graph: any;
|
let graph: any;
|
||||||
if (['video', 'extend'].includes(q.mode)) {
|
if (['video', 'extend'].includes(q.mode)) {
|
||||||
if (q.engine !== 'minimax')
|
|
||||||
throw new Error('Studio 2 native identity video currently requires MiniMax. Use the existing xAIGen studio for LTX.');
|
|
||||||
let start = a;
|
let start = a;
|
||||||
if (q.startClipId) {
|
if (q.startClipId) {
|
||||||
const dir = join(studio2Root(), r.id);
|
const dir = join(studio2Root(), r.id);
|
||||||
@@ -86,14 +273,141 @@ async function prepareGraph(r: any) {
|
|||||||
start = await upload(r, 'start', readFileSync(dest));
|
start = await upload(r, 'start', readFileSync(dest));
|
||||||
unlinkSync(dest);
|
unlinkSync(dest);
|
||||||
}
|
}
|
||||||
const end = await load(q.endStillId, 'end'), guides = [];
|
if (q.engine === 'ltx') {
|
||||||
for (const [i, g] of q.guides.entries())
|
const { buildWorkflow } = await import('../workflow');
|
||||||
guides.push({ image: await load(g.stillId, `guide${i}`), frame: g.frame });
|
const { frameLength } = await import('../videoChain');
|
||||||
graph = nativeVideoGraph(template, q, { hero, start, end, guides }, prefix);
|
const { ltxWorkflowEnabled, LTX_DISABLED_MESSAGE } = await import('~/utils/videoModels');
|
||||||
await ensureComfyLoraNames('video');
|
if (!ltxWorkflowEnabled()) throw new Error(LTX_DISABLED_MESSAGE);
|
||||||
resolveGraphLoraNames(graph, 'video');
|
const textToVideo = q.mode === 'video' && !start;
|
||||||
applyUserLoraToGraph(graph, s.loraStack);
|
if (!start && !textToVideo) throw new Error('Choose a start still for LTX video.');
|
||||||
r.graphId = 'studio2_minimax_native.json';
|
const fps = s.fps || 24;
|
||||||
|
graph = buildWorkflow({
|
||||||
|
workflow: textToVideo ? 'ltx-t2v' : 'ltx',
|
||||||
|
imageName: start || '',
|
||||||
|
prompt: q.compiledPrompt,
|
||||||
|
width: s.width,
|
||||||
|
height: s.height,
|
||||||
|
length: frameLength(s.duration || 5, fps),
|
||||||
|
fps,
|
||||||
|
steps: s.steps,
|
||||||
|
cfg: s.cfg,
|
||||||
|
seed: s.seed,
|
||||||
|
samplerName: 'euler',
|
||||||
|
scheduler: 'simple',
|
||||||
|
turbo: false,
|
||||||
|
duration: s.duration || 5,
|
||||||
|
filenamePrefix: prefix + '/video',
|
||||||
|
loraStack: s.loraStack,
|
||||||
|
useIdentityRefs: false
|
||||||
|
});
|
||||||
|
await ensureComfyLoraNames('video');
|
||||||
|
r.graphId = textToVideo ? 'workflow_ltx_video.json#t2v' : 'workflow_ltx_video.json';
|
||||||
|
} else {
|
||||||
|
if (q.engine !== 'minimax')
|
||||||
|
throw new Error('Studio 2 video supports MiniMax H3 and LTX (xAIGen).');
|
||||||
|
const end = await load(q.endStillId, 'end'), guides = [];
|
||||||
|
for (const [i, g] of q.guides.entries())
|
||||||
|
guides.push({ image: await load(g.stillId, `guide${i}`), frame: g.frame });
|
||||||
|
graph = nativeVideoGraph(template, q, { hero, start, end, guides }, prefix);
|
||||||
|
await ensureComfyLoraNames('video');
|
||||||
|
resolveGraphLoraNames(graph, 'video');
|
||||||
|
applyUserLoraToGraph(graph, s.loraStack);
|
||||||
|
r.graphId = 'studio2_minimax_native.json';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (q.engine === 'qwen21') {
|
||||||
|
if (!['generate', 'edit'].includes(q.mode))
|
||||||
|
throw new Error('Qwen 2.1 supports Generate and Edit.')
|
||||||
|
const turbo = !!q.turbo
|
||||||
|
if (turbo) {
|
||||||
|
q.lora = QWEN21_TURBO_LORA
|
||||||
|
q.sigmas = QWEN21_TURBO_SIGMAS
|
||||||
|
s.steps = QWEN21_TURBO_STEPS
|
||||||
|
s.cfg = 1
|
||||||
|
}
|
||||||
|
const negative = turbo ? '' : stylePrompt(q.imageStyles, true)
|
||||||
|
if (q.mode === 'edit') {
|
||||||
|
// Photo to change → image_1. Outfit/extra → image_2. Never T2I / EmptyLatent.
|
||||||
|
if (!q.imageAId || !a)
|
||||||
|
throw new Error('Add a photo and mark it Photo to change.')
|
||||||
|
// promptRaw stays typed; prompt/compiledPrompt = what TextEncode samples (tags injected silently).
|
||||||
|
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
|
||||||
|
const prompt = finalizeQwen21EditSample(String(q.compiledPrompt || ''), q)
|
||||||
|
q.compiledPrompt = prompt
|
||||||
|
q.prompt = prompt
|
||||||
|
q.task = 'edit'
|
||||||
|
graph = structuredClone(turbo ? qwen21EditTurboTemplate : qwen21EditTemplate)
|
||||||
|
graph['10'].inputs.image = a
|
||||||
|
graph['9'].inputs['images.image_1'] = ['10', 0]
|
||||||
|
if (b) {
|
||||||
|
graph['11'] = {
|
||||||
|
inputs: { image: b },
|
||||||
|
class_type: 'LoadImage',
|
||||||
|
_meta: { title: 'image_2 optional' }
|
||||||
|
}
|
||||||
|
graph['9'].inputs['images.image_2'] = ['11', 0]
|
||||||
|
}
|
||||||
|
graph['9'].inputs.prompt = prompt
|
||||||
|
graph['9'].inputs.negative_prompt = negative
|
||||||
|
// Official edit: sampler latent comes from TextEncode (sized from image_1).
|
||||||
|
graph['9'].inputs.resolution = 1024
|
||||||
|
if (turbo) {
|
||||||
|
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
|
||||||
|
graph['7'].inputs.strength = 1.0
|
||||||
|
graph['31'].inputs.noise_seed = s.seed
|
||||||
|
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
|
||||||
|
graph['36'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 edit latent'
|
||||||
|
r.graphId = 'studio2_qwen21_edit_turbo.json'
|
||||||
|
} else {
|
||||||
|
graph['15'].inputs.seed = s.seed
|
||||||
|
graph['15'].inputs.steps = s.steps || 25
|
||||||
|
graph['15'].inputs.cfg = s.cfg ?? 1
|
||||||
|
graph['15'].inputs.sampler_name = 'euler'
|
||||||
|
graph['15'].inputs.scheduler = 'simple'
|
||||||
|
graph['21'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 edit latent'
|
||||||
|
r.graphId = 'studio2_qwen21_edit.json'
|
||||||
|
}
|
||||||
|
r.sampleDenoise = null
|
||||||
|
r.heroReferenceAttached = false
|
||||||
|
} else {
|
||||||
|
// Bench aspect is law for Generate. PE wh_ratio is advisory only (never sizes the canvas).
|
||||||
|
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
|
||||||
|
q.prompt = String(q.compiledPrompt || '')
|
||||||
|
q.task = 'generate'
|
||||||
|
const size = resolveQwen21Size(s.aspect)
|
||||||
|
s.width = size.width
|
||||||
|
s.height = size.height
|
||||||
|
graph = structuredClone(turbo ? qwen21T2iTurboTemplate : qwen21Template)
|
||||||
|
graph['9'].inputs.prompt = q.compiledPrompt
|
||||||
|
graph['9'].inputs.negative_prompt = negative
|
||||||
|
graph['9'].inputs.resolution = 1024
|
||||||
|
graph['16'].inputs.width = size.width
|
||||||
|
graph['16'].inputs.height = size.height
|
||||||
|
if (turbo) {
|
||||||
|
graph['7'].inputs.lora_name = QWEN21_TURBO_LORA
|
||||||
|
graph['7'].inputs.strength = 1.0
|
||||||
|
graph['31'].inputs.noise_seed = s.seed
|
||||||
|
graph['33'].inputs.nodes = QWEN21_TURBO_SIGMAS
|
||||||
|
graph['36'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 empty latent'
|
||||||
|
r.graphId = 'studio2_qwen21_t2i_turbo.json'
|
||||||
|
} else {
|
||||||
|
graph['15'].inputs.seed = s.seed
|
||||||
|
graph['15'].inputs.steps = s.steps || 25
|
||||||
|
graph['15'].inputs.cfg = s.cfg ?? 1
|
||||||
|
graph['15'].inputs.sampler_name = 'euler'
|
||||||
|
graph['15'].inputs.scheduler = 'simple'
|
||||||
|
graph['21'].inputs.filename_prefix = prefix + '/image'
|
||||||
|
r.sampleLatent = 'qwen21 empty latent'
|
||||||
|
r.graphId = 'studio2_qwen21_t2i.json'
|
||||||
|
}
|
||||||
|
r.sampleDenoise = null
|
||||||
|
r.heroReferenceAttached = false
|
||||||
|
}
|
||||||
|
await ensureComfyLoraNames('image')
|
||||||
|
applyUserLoraToGraph(graph, s.loraStack)
|
||||||
}
|
}
|
||||||
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);
|
||||||
@@ -138,58 +452,37 @@ async function run(r: any, job: Job) {
|
|||||||
r.progress = null;
|
r.progress = null;
|
||||||
r.request.promptSections = prompts[r.index];
|
r.request.promptSections = prompts[r.index];
|
||||||
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
|
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
|
||||||
|
// Typed brief before PE / TextEncode — Copy raw / Load input raw need this even when Enhance is off.
|
||||||
|
r.request.promptRaw = String(r.request.compiledPrompt || '');
|
||||||
update(r, 'waking');
|
update(r, 'waking');
|
||||||
await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
|
await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
|
||||||
|
if (job.status === 'cancelled')
|
||||||
|
throw new Error('Cancelled');
|
||||||
|
await runQwen21PromptEnhance(r, job);
|
||||||
if (job.status === 'cancelled')
|
if (job.status === 'cancelled')
|
||||||
throw new Error('Cancelled');
|
throw new Error('Cancelled');
|
||||||
const graph = await prepareGraph(r);
|
const graph = await prepareGraph(r);
|
||||||
if (job.status === 'cancelled')
|
if (job.status === 'cancelled')
|
||||||
throw new Error('Cancelled');
|
throw new Error('Cancelled');
|
||||||
update(r, 'submitting');
|
update(r, 'submitting');
|
||||||
const queued = await queuePrompt(graph, job.clientId);
|
emitJob(job, { type: 'status', message: 'Generating…' });
|
||||||
|
let queued: { prompt_id: string; number?: number }
|
||||||
|
try {
|
||||||
|
queued = await queuePrompt(graph, job.clientId);
|
||||||
|
} catch (error: any) {
|
||||||
|
const msg = String(error?.statusMessage || error?.message || error)
|
||||||
|
if (r.request?.turbo && /lora|ViggleTurbo|not in list|value_not_in_list/i.test(msg))
|
||||||
|
throw new Error(`Qwen Turbo LoRA missing on host (${QWEN21_TURBO_LORA}). Install the r128 file under models/loras and restart Comfy.`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
job.promptId = queued.prompt_id;
|
job.promptId = queued.prompt_id;
|
||||||
r.promptId = queued.prompt_id;
|
r.promptId = queued.prompt_id;
|
||||||
|
r.generateComfyPromptId = queued.prompt_id;
|
||||||
|
r.progress = null;
|
||||||
r.renderStartedAt = Date.now();
|
r.renderStartedAt = Date.now();
|
||||||
update(r, 'rendering');
|
update(r, 'rendering');
|
||||||
}
|
}
|
||||||
let history: any, missingSince = 0;
|
const history = await waitPromptHistory(r, job, r.promptId);
|
||||||
for (;;) {
|
|
||||||
try {
|
|
||||||
history = await fetchHistory(r.promptId);
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
history = null;
|
|
||||||
}
|
|
||||||
const e = history?.[r.promptId];
|
|
||||||
if (job.status === 'cancelled')
|
|
||||||
throw new Error('Cancelled');
|
|
||||||
if (['error', 'interrupted'].includes(e?.status?.status_str))
|
|
||||||
throw new Error('Comfy generation failed or was interrupted; inspect the host error log.');
|
|
||||||
if (!e) {
|
|
||||||
try {
|
|
||||||
const response = await comfyFetch('/queue', { signal: AbortSignal.timeout(5000) });
|
|
||||||
if (response.ok) {
|
|
||||||
const q = await response.json() as any;
|
|
||||||
const present = [...(q.queue_running || []), ...(q.queue_pending || [])].some((entry: any) => entry[1] === r.promptId);
|
|
||||||
if (present)
|
|
||||||
missingSince = 0;
|
|
||||||
else if (!missingSince)
|
|
||||||
missingSince = Date.now();
|
|
||||||
else if (Date.now() - missingSince > 60000)
|
|
||||||
throw new Error('PROMPT_MISSING');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error: any) {
|
|
||||||
if (error.message === 'PROMPT_MISSING')
|
|
||||||
throw new Error('The saved Comfy prompt is no longer in history or the queue. It was not resubmitted.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e?.status?.completed || e?.status?.status_str === 'success')
|
|
||||||
break;
|
|
||||||
if (job.status === 'cancelled')
|
|
||||||
throw new Error('Cancelled');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
||||||
}
|
|
||||||
const messages = history[r.promptId]?.status?.messages || [];
|
const messages = history[r.promptId]?.status?.messages || [];
|
||||||
const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp;
|
const executionStart = messages.find((m: any) => m[0] === 'execution_start')?.[1]?.timestamp;
|
||||||
const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp;
|
const executionEnd = messages.find((m: any) => m[0] === 'execution_success')?.[1]?.timestamp;
|
||||||
@@ -224,7 +517,21 @@ async function run(r: any, job: Job) {
|
|||||||
}
|
}
|
||||||
else if (!asset) {
|
else if (!asset) {
|
||||||
const data = await downloadComfyImage(file), size = imageDimensions(data) || { width: s.width, height: s.height };
|
const data = await downloadComfyImage(file), size = imageDimensions(data) || { width: s.width, height: s.height };
|
||||||
asset = await saveStill({ ownerKey: r.owner, folderId: q.folderId, filename: file.filename, data, ...size, role: 'output', prompt: q.compiledPrompt, familyId: r.familyId, chainIndex: r.index, settings: s });
|
// prompt = TextEncode string (after PE + stitch); promptRaw = typed brief.
|
||||||
|
const samplePrompt = String(q.prompt || q.compiledPrompt || '')
|
||||||
|
asset = await saveStill({
|
||||||
|
ownerKey: r.owner,
|
||||||
|
folderId: q.folderId,
|
||||||
|
filename: file.filename,
|
||||||
|
data,
|
||||||
|
...size,
|
||||||
|
role: 'output',
|
||||||
|
prompt: samplePrompt,
|
||||||
|
promptRaw: q.promptRaw != null ? String(q.promptRaw) : undefined,
|
||||||
|
familyId: r.familyId,
|
||||||
|
chainIndex: r.index,
|
||||||
|
settings: s,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!asset)
|
if (!asset)
|
||||||
throw new Error('Library did not save the output');
|
throw new Error('Library did not save the output');
|
||||||
@@ -232,6 +539,11 @@ async function run(r: any, job: Job) {
|
|||||||
saveRecord(r);
|
saveRecord(r);
|
||||||
r.savedAt = Date.now();
|
r.savedAt = Date.now();
|
||||||
r.wallTime = (r.savedAt - r.startedAt) / 1000;
|
r.wallTime = (r.savedAt - r.startedAt) / 1000;
|
||||||
|
if (q.engine === 'qwen21' && ['generate', 'edit'].includes(q.mode)) {
|
||||||
|
q.task = q.mode
|
||||||
|
q.prompt = String(q.prompt || q.compiledPrompt || '')
|
||||||
|
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
|
||||||
|
}
|
||||||
const metadata = { ...structuredClone(q), id: r.id, sourceStillId:r.sourceStillId,heroReferenceAttached:r.heroReferenceAttached,sampleLatent:r.sampleLatent,sampleDenoise:r.sampleDenoise,kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host', handoffSource: r.handoffSource || null, fitted: video && q.startClipId ? !!r.handoffFitted : null };
|
const metadata = { ...structuredClone(q), id: r.id, sourceStillId:r.sourceStillId,heroReferenceAttached:r.heroReferenceAttached,sampleLatent:r.sampleLatent,sampleDenoise:r.sampleDenoise,kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host', handoffSource: r.handoffSource || null, fitted: video && q.startClipId ? !!r.handoffFitted : null };
|
||||||
await attachStudio2Metadata(r.owner, asset.id, metadata);
|
await attachStudio2Metadata(r.owner, asset.id, metadata);
|
||||||
if (!r.outputs.some((o:any)=>o.id===asset.id)) r.outputs.push({id:asset.id,kind:video?'clip':'still',studio2:metadata});
|
if (!r.outputs.some((o:any)=>o.id===asset.id)) r.outputs.push({id:asset.id,kind:video?'clip':'still',studio2:metadata});
|
||||||
|
|||||||
@@ -1,25 +1,46 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import { openSync, readSync, closeSync } from 'node:fs'
|
||||||
import { execFile } from 'node:child_process'
|
import { execFile } from 'node:child_process'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import { getClip, getStill, clipVideoPath, stillPath } from '../library'
|
import { getClip, getStill, clipVideoPath, stillPath } from '../library'
|
||||||
import { imageDimensions } from '../resolution'
|
import { imageDimensions } from '../resolution'
|
||||||
import { resolveSize } from '~/shared/studio2/input.mjs'
|
import { resolveSize } from '~/shared/studio2/input.mjs'
|
||||||
const exec = promisify(execFile)
|
const exec = promisify(execFile)
|
||||||
|
|
||||||
|
function readImageHeader(path: string, maxBytes = 65536) {
|
||||||
|
const fd = openSync(path, 'r')
|
||||||
|
try {
|
||||||
|
const buf = Buffer.alloc(maxBytes)
|
||||||
|
const n = readSync(fd, buf, 0, maxBytes, 0)
|
||||||
|
return buf.subarray(0, n)
|
||||||
|
} finally {
|
||||||
|
closeSync(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveRequestSize(owner: string, request: any) {
|
export async function resolveRequestSize(owner: string, request: any) {
|
||||||
const video=['video','extend'].includes(request.mode)
|
const video = ['video', 'extend'].includes(request.mode)
|
||||||
let dimensions:any
|
let dimensions: any
|
||||||
if (video && request.startClipId) {
|
if (video && request.startClipId) {
|
||||||
getClip(owner,request.startClipId)
|
const clip = getClip(owner, request.startClipId)
|
||||||
const result=await exec('ffprobe',['-v','error','-select_streams','v:0','-show_entries','stream=width,height','-of','json',clipVideoPath(owner,request.startClipId)],{windowsHide:true,timeout:15000})
|
if (clip.width && clip.height) {
|
||||||
dimensions=JSON.parse(result.stdout).streams?.[0]
|
dimensions = { width: clip.width, height: clip.height }
|
||||||
|
} else {
|
||||||
|
const result = await exec('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'json', clipVideoPath(owner, request.startClipId)], { windowsHide: true, timeout: 15000 })
|
||||||
|
dimensions = JSON.parse(result.stdout).streams?.[0]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const id=request.imageAId || request.identityStillId
|
const id = request.imageAId || request.identityStillId
|
||||||
if (id) {
|
if (id) {
|
||||||
getStill(owner,id)
|
const still = getStill(owner, id)
|
||||||
dimensions=imageDimensions(readFileSync(stillPath(owner,id)))
|
if (still.width && still.height) {
|
||||||
|
dimensions = { width: still.width, height: still.height }
|
||||||
|
} else {
|
||||||
|
// Header only — never read the full archive PNG into memory on Generate click
|
||||||
|
dimensions = imageDimensions(readImageHeader(stillPath(owner, id)))
|
||||||
|
}
|
||||||
if (!dimensions) throw new Error('Cannot measure the source still.')
|
if (!dimensions) throw new Error('Cannot measure the source still.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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,10 +13,12 @@ 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
|
||||||
|
/** Full Studio 2 validateRequest snapshot for Load / Re-queue. */
|
||||||
|
studio2Request?: Record<string, any>
|
||||||
upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string }
|
upscale?: { sourceId: string; scale: 2 | 4; target: string; fps: string | number; enhance: string }
|
||||||
prompt: string
|
prompt: string
|
||||||
promptMid?: string
|
promptMid?: string
|
||||||
@@ -40,6 +42,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
|
||||||
@@ -103,6 +106,9 @@ export interface StudioJob {
|
|||||||
resumeAutoRun?: boolean
|
resumeAutoRun?: boolean
|
||||||
lastError?: string
|
lastError?: string
|
||||||
waitReason?: string
|
waitReason?: string
|
||||||
|
resultText?: string
|
||||||
|
/** Hidden from Queue UI; keeps resultText on the stored row. */
|
||||||
|
dismissed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudioQueueStore = {
|
type StudioQueueStore = {
|
||||||
@@ -224,6 +230,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'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +247,11 @@ export function summarizeStudioJob(job: StudioJob) {
|
|||||||
familyId: job.familyId,
|
familyId: job.familyId,
|
||||||
shotQueueId: job.shotQueueId,
|
shotQueueId: job.shotQueueId,
|
||||||
liveJobId: job.liveJobId,
|
liveJobId: job.liveJobId,
|
||||||
|
folderId: job.payload.folderId,
|
||||||
|
musicEngine: job.payload.musicEngine,
|
||||||
stillId: job.payload.stillId,
|
stillId: job.payload.stillId,
|
||||||
|
captionStyle: job.payload.captionStyle,
|
||||||
|
resultText: job.resultText,
|
||||||
workflow: job.payload.workflow,
|
workflow: job.payload.workflow,
|
||||||
imagePipeline: job.payload.imagePipeline || 'v1',
|
imagePipeline: job.payload.imagePipeline || 'v1',
|
||||||
duration: job.payload.duration,
|
duration: job.payload.duration,
|
||||||
@@ -286,7 +297,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.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')
|
||||||
@@ -306,12 +317,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.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')
|
||||||
@@ -351,7 +362,7 @@ async function reapZombieLiveJobs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function liveJobOwnsGpu(job: Job) {
|
function liveJobOwnsGpu(job: Job) {
|
||||||
if ((job.studio2 || job.yueGp || 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
|
||||||
@@ -371,7 +382,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.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') {
|
||||||
@@ -465,8 +476,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)
|
||||||
@@ -523,6 +537,40 @@ function canCancelStudioStatus(status: StudioJobStatus) {
|
|||||||
return status === 'waiting' || status === 'held' || status === 'running' || status === 'error'
|
return status === 'waiting' || status === 'held' || status === 'running' || status === 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFinishedStudioStatus(status: StudioJobStatus) {
|
||||||
|
return status === 'complete' || status === 'error' || status === 'cancelled'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide a finished studio-queue row from the Queue panel (keeps resultText). */
|
||||||
|
export async function dismissStudioJob(owner: string, id: string) {
|
||||||
|
return mutate(owner, (jobs) => {
|
||||||
|
const job = jobs.find(item => item.id === id)
|
||||||
|
if (!job) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||||
|
if (!isFinishedStudioStatus(job.status)) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'Only finished jobs can be dismissed' })
|
||||||
|
}
|
||||||
|
job.dismissed = true
|
||||||
|
job.updatedAt = Date.now()
|
||||||
|
return structuredClone(job)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss finished studio-queue rows (caption complete cards, errors, etc.). */
|
||||||
|
export async function dismissFinishedStudioJobs(owner: string, ids?: string[]) {
|
||||||
|
return mutate(owner, (jobs) => {
|
||||||
|
let cleared = 0
|
||||||
|
for (const job of jobs) {
|
||||||
|
if (ids?.length && !ids.includes(job.id)) continue
|
||||||
|
if (job.dismissed) continue
|
||||||
|
if (!isFinishedStudioStatus(job.status)) continue
|
||||||
|
job.dismissed = true
|
||||||
|
job.updatedAt = Date.now()
|
||||||
|
cleared += 1
|
||||||
|
}
|
||||||
|
return cleared
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function cancelStudioJob(owner: string, id: string) {
|
export async function cancelStudioJob(owner: string, id: string) {
|
||||||
const before = readJobs(owner).find(item => item.id === id)
|
const before = readJobs(owner).find(item => item.id === id)
|
||||||
if (!before) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
if (!before) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||||
@@ -598,6 +646,14 @@ export async function clearStuckStudioWork(owner: string) {
|
|||||||
const { cancelYueGpJob } = await import('./yueGp')
|
const { cancelYueGpJob } = await import('./yueGp')
|
||||||
await cancelYueGpJob(job)
|
await cancelYueGpJob(job)
|
||||||
}
|
}
|
||||||
|
if (job.yue2) {
|
||||||
|
const { cancelYue2Job } = await import('./yue2')
|
||||||
|
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) {
|
||||||
@@ -665,6 +721,16 @@ async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
|
|||||||
await cancelYueGpJob(live)
|
await cancelYueGpJob(live)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (live?.yue2) {
|
||||||
|
const { cancelYue2Job } = await import('./yue2')
|
||||||
|
await cancelYue2Job(live)
|
||||||
|
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) {
|
||||||
@@ -872,6 +938,14 @@ function pendingAlive(job: StudioJob) {
|
|||||||
if (job.liveJobId) {
|
if (job.liveJobId) {
|
||||||
const pending = readPendingJob(job.liveJobId)
|
const pending = readPendingJob(job.liveJobId)
|
||||||
if (pending?.promptId && freshEnough(pending.startedAt)) return true
|
if (pending?.promptId && freshEnough(pending.startedAt)) return true
|
||||||
|
if (job.kind === 'music') {
|
||||||
|
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yue2-pending', `${job.liveJobId}.json`)
|
||||||
|
if (existsSync(root)) return true
|
||||||
|
}
|
||||||
|
if (job.kind === 'caption') {
|
||||||
|
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'caption-pending', `${job.liveJobId}.json`)
|
||||||
|
if (existsSync(root)) return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!job.shotQueueId) return false
|
if (!job.shotQueueId) return false
|
||||||
return listPendingJobs().some(pending => (
|
return listPendingJobs().some(pending => (
|
||||||
@@ -914,7 +988,7 @@ function repairStaleJobs(jobs: StudioJob[]) {
|
|||||||
job.updatedAt = Date.now()
|
job.updatedAt = Date.now()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!job.payload.upscale && job.payload.musicEngine !== 'yue' && job.status === 'error' && isTransientComfyError(job.lastError)) {
|
if (!job.payload.upscale && job.kind !== 'music' && job.status === 'error' && isTransientComfyError(job.lastError)) {
|
||||||
job.status = 'waiting'
|
job.status = 'waiting'
|
||||||
job.liveJobId = undefined
|
job.liveJobId = undefined
|
||||||
job.lastError = undefined
|
job.lastError = undefined
|
||||||
@@ -923,7 +997,7 @@ function repairStaleJobs(jobs: StudioJob[]) {
|
|||||||
job.updatedAt = Date.now()
|
job.updatedAt = Date.now()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!job.payload.upscale && job.payload.musicEngine !== 'yue' && job.status === 'held' && isTransientComfyError(job.lastError)) {
|
if (!job.payload.upscale && job.kind !== 'music' && job.status === 'held' && isTransientComfyError(job.lastError)) {
|
||||||
job.status = 'waiting'
|
job.status = 'waiting'
|
||||||
job.liveJobId = undefined
|
job.liveJobId = undefined
|
||||||
job.lastError = undefined
|
job.lastError = undefined
|
||||||
@@ -1380,6 +1454,32 @@ 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,
|
||||||
|
name: item.payload.name
|
||||||
|
})
|
||||||
|
await markStudioLive(item.ownerKey, item.id, live.id)
|
||||||
|
} 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 {
|
||||||
@@ -1398,8 +1498,7 @@ async function startStudioMusicJob(item: StudioJob) {
|
|||||||
lyricsStrength: payload.instrumental ? 0 : (payload.lyricsStrength ?? 0.9),
|
lyricsStrength: payload.instrumental ? 0 : (payload.lyricsStrength ?? 0.9),
|
||||||
instrumental: payload.instrumental === true,
|
instrumental: payload.instrumental === true,
|
||||||
folderLocked: payload.folderLocked,
|
folderLocked: payload.folderLocked,
|
||||||
engine: payload.musicEngine || 'ace-step',
|
engine: 'yue2',
|
||||||
yueProfile: payload.yueProfile ?? 1,
|
|
||||||
samplerName: payload.samplerName || 'euler',
|
samplerName: payload.samplerName || 'euler',
|
||||||
scheduler: payload.scheduler || 'simple'
|
scheduler: payload.scheduler || 'simple'
|
||||||
})
|
})
|
||||||
@@ -1423,6 +1522,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')
|
||||||
@@ -1687,7 +1790,7 @@ export async function onLiveVideoSettled(job: Job) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const remaining = remainingStudioShots(job)
|
const remaining = remainingStudioShots(job)
|
||||||
const wakeFail = !job.studio2 && !job.yueGp && !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) => {
|
||||||
@@ -1761,6 +1864,7 @@ export async function onLiveVideoSettled(job: Job) {
|
|||||||
: failed
|
: failed
|
||||||
? 'error'
|
? 'error'
|
||||||
: 'complete'
|
: 'complete'
|
||||||
|
if (job.caption && job.resultText) row.resultText = job.resultText
|
||||||
clearStudioRowSlot(row, status, failed ? job.error : undefined)
|
clearStudioRowSlot(row, status, failed ? job.error : undefined)
|
||||||
syncPausedFlag(store)
|
syncPausedFlag(store)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { createJob, emitJob, getJob, restoreMusicJob, type Job } from './jobs'
|
||||||
|
import { saveTrack, findYue2Track } from './library'
|
||||||
|
import { sharedGpuHeaders } from './sharedGpu'
|
||||||
|
import type { MusicJobParams } from './musicChain'
|
||||||
|
|
||||||
|
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 yue2Configured() { return Boolean(settings().url) }
|
||||||
|
function pendingRoot() { return join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yue2-pending') }
|
||||||
|
export function yue2PendingAlive(jobId: string) {
|
||||||
|
if (!jobId) return false
|
||||||
|
return existsSync(join(pendingRoot(), `${jobId}.json`))
|
||||||
|
}
|
||||||
|
async function request(path: string, body?: unknown) {
|
||||||
|
const { url, token } = settings()
|
||||||
|
if (!url) throw new Error('YuE2 host is not configured. Set COMFY_CONTROL_URL.')
|
||||||
|
const response = await fetch(`${url}/yue2/${path}`, {
|
||||||
|
method: body === undefined ? 'GET' : 'POST',
|
||||||
|
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(body === undefined ? {} : { ...sharedGpuHeaders(), 'Content-Type': 'application/json' }) },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000)
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.json().catch(() => ({})) as { message?: string; error?: string }
|
||||||
|
throw Object.assign(new Error(detail.message || detail.error || `YuE2 host returned ${response.status}`), { statusCode: response.status })
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
function persist(job: Job) {
|
||||||
|
mkdirSync(pendingRoot(), { recursive: true })
|
||||||
|
const path = join(pendingRoot(), `${job.id}.json`)
|
||||||
|
writeFileSync(path + '.tmp', JSON.stringify({ id: job.id, clientId: job.clientId, startedAt: job.startedAt, library: job.library, trackId: job.trackId }))
|
||||||
|
renameSync(path + '.tmp', path)
|
||||||
|
}
|
||||||
|
async function settle(job: Job) {
|
||||||
|
const { onLiveVideoSettled } = await import('./studioQueue')
|
||||||
|
await onLiveVideoSettled(job)
|
||||||
|
const path = join(pendingRoot(), `${job.id}.json`)
|
||||||
|
if (existsSync(path)) unlinkSync(path)
|
||||||
|
}
|
||||||
|
export async function cancelYue2Job(job: Job) {
|
||||||
|
await request(`jobs/${job.id}/cancel`, {})
|
||||||
|
job.status = 'cancelled'
|
||||||
|
emitJob(job, { type: 'error', error: 'Cancelled', message: 'Cancelled' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function watch(job: Job) {
|
||||||
|
let failures = 0
|
||||||
|
while (job.status !== 'cancelled') {
|
||||||
|
try {
|
||||||
|
const state = await (await request(`jobs/${job.id}`)).json() as {
|
||||||
|
status: string; message: string; error?: string; stage?: string; progress?: number; step?: number; maxStep?: number; duration?: number
|
||||||
|
}
|
||||||
|
if (job.status === 'cancelled') { await settle(job); return }
|
||||||
|
job.musicActivity = { checkedAt: Date.now(), running: ['starting', 'running', 'cancelling'].includes(state.status) }
|
||||||
|
if (state.status === 'error' || state.status === 'cancelled') {
|
||||||
|
job.status = state.status === 'cancelled' ? 'cancelled' : 'error'
|
||||||
|
job.error = state.error || state.message
|
||||||
|
if (/out of memory/i.test(job.error || '')) job.error += ' YuE2 does not switch engines automatically.'
|
||||||
|
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||||
|
await settle(job)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.status === 'complete') {
|
||||||
|
job.saving = true
|
||||||
|
emitJob(job, { type: 'status', message: 'Saving audio to library…', progress: 98 })
|
||||||
|
const lib = job.library!
|
||||||
|
lib.audioExt = 'wav'
|
||||||
|
job.trackId ||= findYue2Track(lib.ownerKey, job.id)?.id
|
||||||
|
if (!job.trackId) {
|
||||||
|
const audio = Buffer.from(await (await request(`jobs/${job.id}/audio`)).arrayBuffer())
|
||||||
|
const track = await saveTrack({ ownerKey: lib.ownerKey, folderId: lib.folderId, name: lib.name,
|
||||||
|
tags: lib.tags || lib.prompt, lyrics: lib.lyrics || '', duration: state.duration || lib.duration || 60,
|
||||||
|
seed: lib.seed, steps: 0, cfg: 0, instrumental: lib.instrumental === true,
|
||||||
|
engine: 'yue2', audio, ext: 'wav', comfyFilename: `yue2-${job.id}.wav` })
|
||||||
|
job.trackId = track.id
|
||||||
|
persist(job)
|
||||||
|
}
|
||||||
|
job.status = 'complete'; job.saving = false
|
||||||
|
emitJob(job, { type: 'complete', message: lib.folderLocked ? 'Saved to the locked folder.' : 'Track ready',
|
||||||
|
progress: 100, trackId: job.trackId, folderLocked: lib.folderLocked, audioExt: 'wav' })
|
||||||
|
await settle(job)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failures = 0
|
||||||
|
job.status = 'running'
|
||||||
|
const percent = Number(state.progress || 0)
|
||||||
|
emitJob(job, { type: 'progress', message: `${state.message}${state.maxStep ? ` · ${state.step}/${state.maxStep}` : ''}`,
|
||||||
|
progress: Math.min(95, Math.max(1, percent)), step: state.step || 0, maxStep: state.maxStep || 0 })
|
||||||
|
} catch (error) {
|
||||||
|
failures++
|
||||||
|
job.saving = false
|
||||||
|
emitJob(job, { type: 'status', message: `YuE2 connection/save check failed; retrying: ${error instanceof Error ? error.message : String(error)}` })
|
||||||
|
if (failures >= 10) {
|
||||||
|
job.status = 'error'; job.error = 'YuE2 host unreachable or audio save failed. Job files are retained for recovery.'
|
||||||
|
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||||
|
const { onLiveVideoSettled } = await import('./studioQueue')
|
||||||
|
await onLiveVideoSettled(job)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||||
|
}
|
||||||
|
await settle(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startYue2Job(params: MusicJobParams) {
|
||||||
|
const job = createJob('music')
|
||||||
|
job.yue2 = true
|
||||||
|
job.library = { ...params, prompt: params.tags, engine: 'yue2', aspect: 'audio', width: 0, height: 0,
|
||||||
|
hideThumbnail: false, turbo: false, sound: true }
|
||||||
|
persist(job)
|
||||||
|
setTimeout(() => { void (async () => {
|
||||||
|
try {
|
||||||
|
if (job.status === 'cancelled') { await settle(job); return }
|
||||||
|
await request('jobs', { id: job.id, tags: params.tags, lyrics: params.lyrics,
|
||||||
|
duration: params.duration, seed: params.seed })
|
||||||
|
await watch(job)
|
||||||
|
} 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 })
|
||||||
|
await settle(job)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitJob(job, { type: 'status', message: `Checking YuE2 submission: ${error instanceof Error ? error.message : String(error)}` })
|
||||||
|
await watch(job)
|
||||||
|
}
|
||||||
|
})() }, 0)
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeYue2Jobs() {
|
||||||
|
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 = restoreMusicJob(record)
|
||||||
|
job.yueGp = false
|
||||||
|
job.yue2 = true
|
||||||
|
job.message = 'Reconnecting to YuE2'
|
||||||
|
void watch(job)
|
||||||
|
} catch { /* Preserve invalid records for diagnosis. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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 validateCaptionBody(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.sourcePath || '')
|
||||||
|
if (!stillId) fail('Choose a still to describe.')
|
||||||
|
return { folderId, stillId, captionStyle: style }
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import {normalizeStyles,stylePrompt} from './styles.mjs'
|
import {normalizeStyles,stylePrompt} from './styles.mjs'
|
||||||
import { queueSeeds } from './seed.mjs'
|
import { queueSeeds } from './seed.mjs'
|
||||||
import { sectionsFrom, compileSections } from './prompt.mjs'
|
import { sectionsFrom, compileSections } from './prompt.mjs'
|
||||||
|
import { QWEN21_ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
|
||||||
|
import { QWEN21_TURBO_CFG, QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from './qwen21-turbo.mjs'
|
||||||
|
import { applyPhotosToRequest } from './photos.mjs'
|
||||||
export { sectionsFrom } from './prompt.mjs'
|
export { sectionsFrom } from './prompt.mjs'
|
||||||
export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right']
|
export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right']
|
||||||
export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.'
|
export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.'
|
||||||
@@ -13,7 +16,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}.` : '',
|
||||||
@@ -26,40 +29,57 @@ export function restoreInput(item) {
|
|||||||
return { ...saved, lockFace:saved.lockFace !== false, lockOutfit:saved.lockOutfit !== false, hideInput:!!saved.hideInput, settings:structuredClone(saved.settings || {}), promptSections: sectionsFrom(saved.promptSections || { action: item.prompt || saved.compiledPrompt || '' }) }
|
return { ...saved, lockFace:saved.lockFace !== false, lockOutfit:saved.lockOutfit !== false, hideInput:!!saved.hideInput, settings:structuredClone(saved.settings || {}), promptSections: sectionsFrom(saved.promptSections || { action: item.prompt || saved.compiledPrompt || '' }) }
|
||||||
}
|
}
|
||||||
export function validateRequest(raw, xaigen = false) {
|
export function validateRequest(raw, xaigen = false) {
|
||||||
raw = raw || {}
|
raw = applyPhotosToRequest(raw || {})
|
||||||
const fail = message => { throw Object.assign(new Error(message), {statusCode:400}) }
|
const fail = message => { throw Object.assign(new Error(message), {statusCode:400}) }
|
||||||
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
||||||
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
||||||
const video = ['video','extend'].includes(mode)
|
const video = ['video','extend'].includes(mode)
|
||||||
if (!(video ? ['minimax','ltx'] : ['flux','krea']).includes(engine)) fail('Engine does not support this task.')
|
if (!(video ? ['minimax','ltx'] : ['flux','krea','qwen21']).includes(engine)) fail('Engine does not support this task.')
|
||||||
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
||||||
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
||||||
|
if (engine === 'qwen21' && !['generate', 'edit'].includes(mode)) fail('Qwen 2.1 supports Generate and Edit.')
|
||||||
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
||||||
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
||||||
if (!raw.folderId) fail('Choose a folder.')
|
if (!raw.folderId) fail('Choose a folder.')
|
||||||
if ((video || ['edit','compose','iterate'].includes(mode)) && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
|
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) {
|
||||||
if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
|
if (engine === 'qwen21') fail('Add a photo and mark it Photo to change.')
|
||||||
|
fail('Add a photo to change.')
|
||||||
|
}
|
||||||
|
if ((video || ['edit','compose','iterate'].includes(mode)) && engine !== 'qwen21' && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Add a Person to keep photo.')
|
||||||
|
if (mode === 'compose' && !raw.imageBId) fail('Compose needs a second photo.')
|
||||||
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
|
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
|
||||||
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) fail('Choose the source still.')
|
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip.')
|
||||||
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip or Previous clip.')
|
|
||||||
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
|
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
|
||||||
const promptSections = video ? allSections : {action:allSections.action}
|
const promptSections = video ? allSections : {action:allSections.action}
|
||||||
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
||||||
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
||||||
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
|
const qwen = engine === 'qwen21'
|
||||||
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
const defaultSteps = video ? 8 : qwen ? 25 : 24
|
||||||
|
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
|
||||||
|
const aspect = QWEN21_ASPECT_IDS.has(raw.settings?.aspect) ? raw.settings.aspect : 'auto'
|
||||||
|
const qwenSize = qwen && mode === 'generate' ? resolveQwen21Size(aspect) : null
|
||||||
|
const defaultWidth = qwenSize ? qwenSize.width : qwen ? 1024 : 1344
|
||||||
|
const defaultHeight = qwenSize ? qwenSize.height : qwen ? 1024 : 768
|
||||||
|
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?defaultSteps:1,0,2147483647))) fail(`Invalid ${key}.`)
|
||||||
|
for (const key of ['width','height']) if (number(key,key==='width'?defaultWidth:defaultHeight,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
||||||
const duration=number('duration',5,1,15)
|
const duration=number('duration',5,1,15)
|
||||||
if (video) {
|
if (video) {
|
||||||
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
||||||
if (promptSections.cameraBeats.reduce((sum,b)=>sum+(b.durationSec || 0),0)>duration) fail('Camera beats exceed the clip duration.')
|
if (promptSections.cameraBeats.reduce((sum,b)=>sum+(b.durationSec || 0),0)>duration) fail('Camera beats exceed the clip duration.')
|
||||||
if (raw.lockFace === false && raw.lockOutfit === false && !raw.imageAId && !raw.startClipId) fail('Choose a start still or previous clip when both identity locks are off.')
|
|
||||||
}
|
}
|
||||||
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
||||||
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
||||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
const enhancePrompt = qwen && !video && !!raw.enhancePrompt
|
||||||
|
const turbo = qwen && !video && !!raw.turbo
|
||||||
|
const width = qwenSize ? qwenSize.width : number('width',defaultWidth,256,4096)
|
||||||
|
const height = qwenSize ? qwenSize.height : number('height',defaultHeight,256,4096)
|
||||||
|
const steps = turbo ? QWEN21_TURBO_STEPS : number('steps',defaultSteps,1,100)
|
||||||
|
const cfg = turbo ? QWEN21_TURBO_CFG : number('cfg',defaultCfg,0,20)
|
||||||
|
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, enhancePrompt, turbo, ...(turbo ? { lora: QWEN21_TURBO_LORA, sigmas: QWEN21_TURBO_SIGMAS } : {}), lockFace:qwen ? false : raw.lockFace !== false, lockOutfit:qwen ? false : raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:qwen ? '' : (raw.identityStillId || ''), imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||||
|
photos: Array.isArray(raw.photos) ? raw.photos : [],
|
||||||
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
||||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
|
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
|
||||||
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
|
settings: { aspect, fps:number('fps',24,1,60), width, height, steps, cfg, seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack: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}}) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,20 @@ export function nativeVideoGraph(template, request, files, prefix) {
|
|||||||
const appearance = request.lockFace !== false || request.lockOutfit !== false
|
const appearance = request.lockFace !== false || request.lockOutfit !== false
|
||||||
if (appearance && !files.hero) throw new Error('Identity still is required; never substitute the start frame.')
|
if (appearance && !files.hero) throw new Error('Identity still is required; never substitute the start frame.')
|
||||||
const g=structuredClone(template), s=request.settings
|
const g=structuredClone(template), s=request.settings
|
||||||
|
const textToVideo = request.mode === 'video' && !appearance && !files.start
|
||||||
if (appearance) { g.hero.inputs.image=files.hero; g['155'].inputs['ref_images.ref_image_0']=['hero',0] }
|
if (appearance) { g.hero.inputs.image=files.hero; g['155'].inputs['ref_images.ref_image_0']=['hero',0] }
|
||||||
else { delete g.hero; delete g['155'].inputs['ref_images.ref_image_0'] }
|
else { delete g.hero; delete g['155'].inputs['ref_images.ref_image_0'] }
|
||||||
Object.assign(g['155'].inputs,{prompt:request.compiledPrompt,width:s.width,height:s.height,length:Math.ceil((s.duration*(s.fps || 24)-5)/17)*17+5,ref_image_size:'max'})
|
Object.assign(g['155'].inputs,{prompt:request.compiledPrompt,width:s.width,height:s.height,length:Math.ceil((s.duration*(s.fps || 24)-5)/17)*17+5,ref_image_size:'max'})
|
||||||
|
if (textToVideo) {
|
||||||
|
// Studio 1 T2V: MiniMaxH3ImageToVideo with first_frame disconnected (no empty placeholder still).
|
||||||
|
g['155'].class_type = 'MiniMaxH3ImageToVideo'
|
||||||
|
g['155']._meta = { title: 'MiniMax H3 Text to Video' }
|
||||||
|
delete g['155'].inputs['ref_images.ref_image_0']
|
||||||
|
delete g['155'].inputs.ref_image_size
|
||||||
|
delete g['155'].inputs.audio_vae
|
||||||
|
delete g['155'].inputs.first_frame
|
||||||
|
delete g['155'].inputs.last_frame
|
||||||
|
}
|
||||||
let conditioning=['155',0]
|
let conditioning=['155',0]
|
||||||
for (const [name,image,frame] of [['start',files.start,0],['end',files.end,-1],...(files.guides||[]).map((f,i)=>[`guide${i}`,f.image,f.frame])]) {
|
for (const [name,image,frame] of [['start',files.start,0],['end',files.end,-1],...(files.guides||[]).map((f,i)=>[`guide${i}`,f.image,f.frame])]) {
|
||||||
if (!image) continue
|
if (!image) continue
|
||||||
|
|||||||
@@ -1,19 +1,39 @@
|
|||||||
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
||||||
import { restoreInput } from './contracts.mjs'
|
import { restoreInput, sectionsFrom } from './contracts.mjs'
|
||||||
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
|
import { normalizeStyles } from './styles.mjs'
|
||||||
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) {
|
import { QWEN21_ASPECT_IDS as ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
|
||||||
|
export { resolveQwen21Size, QWEN21_SIZE_TABLE } from './qwen21-size.mjs'
|
||||||
|
|
||||||
|
export const aspectOptions = [
|
||||||
|
{id:'auto',label:'Automatic',hint:'Match start or hero still'},
|
||||||
|
{id:'16:9',label:'16:9',hint:'Widescreen'},
|
||||||
|
{id:'9:16',label:'9:16',hint:'Tall'},
|
||||||
|
{id:'1:1',label:'1:1',hint:'Square'},
|
||||||
|
{id:'4:3',label:'4:3',hint:'Standard'},
|
||||||
|
{id:'3:4',label:'3:4',hint:'Portrait standard'},
|
||||||
|
{id:'3:2',label:'3:2',hint:'Photo'},
|
||||||
|
{id:'2:3',label:'2:3',hint:'Portrait photo'},
|
||||||
|
{id:'21:9',label:'21:9',hint:'Ultrawide'},
|
||||||
|
{id:'9:21',label:'9:21',hint:'Ultra-tall'}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function resolveSize(aspect, source, fallback = {width:1344,height:768}, engine = '') {
|
||||||
|
if (engine === 'qwen21') return resolveQwen21Size(aspect, source)
|
||||||
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
||||||
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
||||||
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
|
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
|
||||||
const factor = Math.min(1344/Math.max(source.width,source.height),768/Math.min(source.width,source.height))
|
const factor = Math.min(1344/Math.max(source.width,source.height),768/Math.min(source.width,source.height))
|
||||||
return {width:Math.max(256,Math.round(source.width*factor/32)*32),height:Math.max(256,Math.round(source.height*factor/32)*32)}
|
return {width:Math.max(256,Math.round(source.width*factor/32)*32),height:Math.max(256,Math.round(source.height*factor/32)*32)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ASPECT_IDS }
|
||||||
|
|
||||||
export function bindClip(form, clip, xaigen = false, memory) {
|
export function bindClip(form, clip, xaigen = false, memory) {
|
||||||
if (!clip?.id) return form
|
if (!clip?.id) return form
|
||||||
const saved = clip.studio2 || clip.job || {}
|
const saved = clip.studio2 || clip.job || {}
|
||||||
const settings = saved.settings || clip.settings || {}
|
const settings = saved.settings || clip.settings || {}
|
||||||
const next = {...form, startClipId:clip.id, mode:'extend', settings:{...form.settings}}
|
const next = {...form, startClipId:clip.id, mode:'extend', settings:{...form.settings}}
|
||||||
next.settings.aspect=['auto','16:9','9:16','1:1'].includes(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto')
|
next.settings.aspect=ASPECT_IDS.has(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto')
|
||||||
for (const key of ['duration','width','height','fps','loraStack','turbo','steps','cfg']) {
|
for (const key of ['duration','width','height','fps','loraStack','turbo','steps','cfg']) {
|
||||||
if (settings[key] == null) continue
|
if (settings[key] == null) continue
|
||||||
try { next.settings[key] = typeof structuredClone === 'function' ? structuredClone(settings[key]) : JSON.parse(JSON.stringify(settings[key])) }
|
try { next.settings[key] = typeof structuredClone === 'function' ? structuredClone(settings[key]) : JSON.parse(JSON.stringify(settings[key])) }
|
||||||
@@ -35,7 +55,7 @@ export function restoreAsset(form, item, xaigen = false, memory) {
|
|||||||
const restored = restoreInput(item)
|
const restored = restoreInput(item)
|
||||||
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
|
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
|
||||||
next.settings.seedMode='random'
|
next.settings.seedMode='random'
|
||||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||||
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
|
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
|
||||||
next.imageAId=item.id
|
next.imageAId=item.id
|
||||||
next.startClipId=''
|
next.startClipId=''
|
||||||
@@ -58,11 +78,77 @@ export function changeSource(form) {
|
|||||||
|
|
||||||
export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
|
export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
|
||||||
export function useAsSource(form, item) { return {...form,imageAId:item.id} }
|
export function useAsSource(form, item) { return {...form,imageAId:item.id} }
|
||||||
|
|
||||||
|
/** Hydrate the live bench from a saved Studio 2 request (Load). Does not enqueue. */
|
||||||
|
export function hydrateStudio2Job(form, saved, { stillIds = new Set(), clipIds = new Set(), xaigen = false, memory } = {}) {
|
||||||
|
const src = structuredClone(saved?.studio2 || saved || {})
|
||||||
|
const missing = []
|
||||||
|
const keepStill = (id, label) => {
|
||||||
|
const value = id || ''
|
||||||
|
if (!value) return ''
|
||||||
|
if (stillIds.has(value)) return value
|
||||||
|
missing.push(`${label} missing from library`)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const keepClip = (id, label) => {
|
||||||
|
const value = id || ''
|
||||||
|
if (!value) return ''
|
||||||
|
if (clipIds.has(value)) return value
|
||||||
|
missing.push(`${label} missing from library`)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const modeRaw = src.mode === 'iterate' ? 'edit' : src.mode
|
||||||
|
const mode = ['generate','edit','compose','refine','video','extend','music'].includes(modeRaw) ? modeRaw : 'edit'
|
||||||
|
let engine = src.engine === 'qwen21' ? 'qwen21' : src.engine === 'krea' ? 'krea' : src.engine === 'ltx' ? 'ltx' : src.engine === 'minimax' ? 'minimax' : 'flux'
|
||||||
|
if (['video','extend'].includes(mode)) engine = xaigen && engine === 'ltx' ? 'ltx' : 'minimax'
|
||||||
|
else if (engine === 'minimax' || engine === 'ltx') engine = 'flux'
|
||||||
|
const settings = structuredClone(src.settings || {})
|
||||||
|
const aspect = ASPECT_IDS.has(settings.aspect) ? settings.aspect : 'auto'
|
||||||
|
const loraStack = Array.isArray(settings.loraStack) ? settings.loraStack.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
strengthModel: Number(item.strengthModel ?? item.strength ?? 1),
|
||||||
|
strengthClip: Number(item.strengthClip ?? item.strengthModel ?? item.strength ?? 1)
|
||||||
|
})) : []
|
||||||
|
const guides = (Array.isArray(src.guides) ? src.guides : []).map(g => {
|
||||||
|
const stillId = keepStill(g.stillId, 'guide still')
|
||||||
|
return stillId ? { stillId, frame: g.frame } : null
|
||||||
|
}).filter(Boolean)
|
||||||
|
const next = {
|
||||||
|
...form,
|
||||||
|
mode,
|
||||||
|
engine,
|
||||||
|
enhancePrompt: engine === 'qwen21' && !!src.enhancePrompt,
|
||||||
|
turbo: engine === 'qwen21' && !!src.turbo,
|
||||||
|
lockFace: src.lockFace !== false,
|
||||||
|
lockOutfit: src.lockOutfit !== false,
|
||||||
|
hideInput: !!src.hideInput,
|
||||||
|
folderId: src.folderId || form.folderId,
|
||||||
|
identityStillId: keepStill(src.identityStillId, 'hero'),
|
||||||
|
imageAId: keepStill(src.imageAId, 'start'),
|
||||||
|
imageBId: keepStill(src.imageBId, 'second still'),
|
||||||
|
maskId: keepStill(src.maskId, 'mask'),
|
||||||
|
startClipId: keepClip(src.startClipId, 'start clip'),
|
||||||
|
endStillId: keepStill(src.endStillId, 'end still'),
|
||||||
|
guides,
|
||||||
|
imageStyles: normalizeStyles(src.imageStyles || { positive: [], negative: [] }),
|
||||||
|
promptSections: sectionsFrom(src.promptSections || { action: src.compiledPrompt || src.prompt || '' }),
|
||||||
|
settings: {
|
||||||
|
...form.settings,
|
||||||
|
...settings,
|
||||||
|
aspect,
|
||||||
|
seed: settings.seed ?? form.settings?.seed ?? null,
|
||||||
|
seedMode: 'locked',
|
||||||
|
loraStack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { form: restoreLoras(next, loraStack, xaigen, memory), missing: missing[0] || '', warnings: missing }
|
||||||
|
}
|
||||||
|
|
||||||
export function restoreAll(form,item,xaigen=false,memory) {
|
export function restoreAll(form,item,xaigen=false,memory) {
|
||||||
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
|
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
|
||||||
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
|
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
|
||||||
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
|
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
|
||||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||||
next.imageAId=item.id
|
next.imageAId=item.id
|
||||||
next.startClipId=''
|
next.startClipId=''
|
||||||
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
|
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
export async function cachedLoras({read,write,live,fresh=false}) {
|
export async function cachedLoras({read,write,live,fresh: _fresh=false}) {
|
||||||
const cached=read() || {image:[],video:[]};
|
const cached=read() || {image:[],video:[]};
|
||||||
if (!fresh && (cached.image?.length || cached.video?.length)) return cached;
|
|
||||||
try {
|
try {
|
||||||
|
// Always live-scan. Disk is asleep/empty fallback only — never a stale snapshot that skips Comfy.
|
||||||
const listed=await live();
|
const listed=await live();
|
||||||
const next={image:listed.image?.length?listed.image:cached.image||[],video:listed.video?.length?listed.video:cached.video||[]};
|
const image=Array.isArray(listed?.image)?listed.image:[];
|
||||||
if(next.image.length || next.video.length) write(next);
|
const video=Array.isArray(listed?.video)?listed.video:[];
|
||||||
|
if (!image.length && !video.length) return cached;
|
||||||
|
const next={image,video};
|
||||||
|
write(next);
|
||||||
return next;
|
return next;
|
||||||
} catch { return cached; }
|
} catch { return cached; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import {filterLoraStackForUse} from '../../utils/loras.ts'
|
|||||||
|
|
||||||
export function loraUse(form) {
|
export function loraUse(form) {
|
||||||
const video=['video','extend'].includes(form.mode)
|
const video=['video','extend'].includes(form.mode)
|
||||||
return video ? (form.engine==='ltx'?'ltx':'minimax') : (form.engine==='krea'?'krea':'flux')
|
if (video) return form.engine==='ltx'?'ltx':'minimax'
|
||||||
|
if (form.engine==='qwen21') return 'qwen21'
|
||||||
|
return form.engine==='krea'?'krea':'flux'
|
||||||
}
|
}
|
||||||
|
|
||||||
// One controller per bench. Storage is optional (SSR/private browsing still works).
|
// One controller per bench. Storage is optional (SSR/private browsing still works).
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
/**
|
||||||
|
* Inspector Photos: role cards (plain English). The runner maps roles → graph sockets.
|
||||||
|
* UI never teaches <imageN> / Hero / Edit source.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PHOTO_ROLE = {
|
||||||
|
person: 'person',
|
||||||
|
change: 'change',
|
||||||
|
outfit: 'outfit',
|
||||||
|
look: 'look',
|
||||||
|
first: 'first',
|
||||||
|
extra: 'extra'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PHOTO_ROLE_LABEL = {
|
||||||
|
person: 'Person to keep',
|
||||||
|
change: 'Photo to change',
|
||||||
|
outfit: 'Outfit / object',
|
||||||
|
look: 'Look / style',
|
||||||
|
first: 'First frame',
|
||||||
|
extra: 'Extra photo'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human phrase inserted by Mention in prompt (not a Comfy token). */
|
||||||
|
export function mentionPhrase(role, index = 0) {
|
||||||
|
const r = String(role || '')
|
||||||
|
if (r === PHOTO_ROLE.change) return 'this photo'
|
||||||
|
if (r === PHOTO_ROLE.outfit) return 'the outfit photo'
|
||||||
|
if (r === PHOTO_ROLE.person) return 'the face photo'
|
||||||
|
if (r === PHOTO_ROLE.look) return 'the look photo'
|
||||||
|
if (r === PHOTO_ROLE.first) return 'the first frame'
|
||||||
|
return `Photo ${Number(index) + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertMention(text, phrase, selectionStart = 0, selectionEnd = 0) {
|
||||||
|
const value = String(text || '')
|
||||||
|
const chip = String(phrase || '')
|
||||||
|
if (!chip) return { text: value, selectionStart: 0, selectionEnd: 0 }
|
||||||
|
const existing = value.indexOf(chip)
|
||||||
|
if (existing >= 0) {
|
||||||
|
return { text: value, selectionStart: existing, selectionEnd: existing + chip.length }
|
||||||
|
}
|
||||||
|
const start = Math.max(0, Math.min(Number(selectionStart) || 0, value.length))
|
||||||
|
const end = Math.max(start, Math.min(Number(selectionEnd) || start, value.length))
|
||||||
|
const next = `${value.slice(0, start)}${chip}${value.slice(end)}`
|
||||||
|
return { text: next, selectionStart: start, selectionEnd: start + chip.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{
|
||||||
|
* show: boolean,
|
||||||
|
* max: number,
|
||||||
|
* roles: string[],
|
||||||
|
* defaultRole: string,
|
||||||
|
* emptyHint: string|null,
|
||||||
|
* handoffReadonly: boolean
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function photosPlan({ mode, engine } = {}) {
|
||||||
|
const task = String(mode || 'generate')
|
||||||
|
const eng = String(engine || 'flux')
|
||||||
|
|
||||||
|
if (task === 'music') {
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eng === 'qwen21') {
|
||||||
|
if (task === 'generate') {
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
if (task === 'edit') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 3,
|
||||||
|
roles: [PHOTO_ROLE.change, PHOTO_ROLE.outfit, PHOTO_ROLE.person, PHOTO_ROLE.extra],
|
||||||
|
defaultRole: PHOTO_ROLE.change,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eng === 'krea') {
|
||||||
|
if (task === 'generate') {
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
if (task === 'refine') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 1,
|
||||||
|
roles: [PHOTO_ROLE.change],
|
||||||
|
defaultRole: PHOTO_ROLE.change,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task === 'extend') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 0,
|
||||||
|
roles: [],
|
||||||
|
defaultRole: '',
|
||||||
|
emptyHint: null,
|
||||||
|
handoffReadonly: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task === 'video') {
|
||||||
|
if (eng === 'minimax') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 2,
|
||||||
|
roles: [PHOTO_ROLE.first, PHOTO_ROLE.person, PHOTO_ROLE.look],
|
||||||
|
defaultRole: PHOTO_ROLE.first,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LTX: first frame only — existing start binding
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 1,
|
||||||
|
roles: [PHOTO_ROLE.first],
|
||||||
|
defaultRole: PHOTO_ROLE.first,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klein (flux)
|
||||||
|
if (task === 'generate') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 1,
|
||||||
|
roles: [PHOTO_ROLE.person],
|
||||||
|
defaultRole: PHOTO_ROLE.person,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (task === 'edit' || task === 'iterate') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 2,
|
||||||
|
roles: [PHOTO_ROLE.change, PHOTO_ROLE.person],
|
||||||
|
defaultRole: PHOTO_ROLE.change,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (task === 'compose') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 3,
|
||||||
|
roles: [PHOTO_ROLE.change, PHOTO_ROLE.person, PHOTO_ROLE.extra],
|
||||||
|
defaultRole: PHOTO_ROLE.change,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (task === 'refine') {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
max: 1,
|
||||||
|
roles: [PHOTO_ROLE.change],
|
||||||
|
defaultRole: PHOTO_ROLE.change,
|
||||||
|
emptyHint: 'Add a photo, then say what it is.',
|
||||||
|
handoffReadonly: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePhotos(raw, plan) {
|
||||||
|
const list = Array.isArray(raw) ? raw : []
|
||||||
|
const roles = new Set(plan?.roles || [])
|
||||||
|
const max = Math.max(0, Number(plan?.max) || 0)
|
||||||
|
const out = []
|
||||||
|
for (const row of list) {
|
||||||
|
if (out.length >= max) break
|
||||||
|
const stillId = String(row?.stillId || row?.id || '').trim()
|
||||||
|
if (!stillId) continue
|
||||||
|
let role = String(row?.role || plan?.defaultRole || '').trim()
|
||||||
|
if (!roles.has(role)) role = plan?.defaultRole || [...roles][0] || PHOTO_ROLE.extra
|
||||||
|
out.push({ stillId, role })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First card wins when two share a role. Returns duplicate role label or null. */
|
||||||
|
export function duplicatePhotoRole(photos) {
|
||||||
|
const seen = new Set()
|
||||||
|
for (const row of photos || []) {
|
||||||
|
const role = String(row?.role || '')
|
||||||
|
if (!role) continue
|
||||||
|
if (seen.has(role)) return PHOTO_ROLE_LABEL[role] || role
|
||||||
|
seen.add(role)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Photos cards → legacy request fields the graphs already consume.
|
||||||
|
* Qwen Edit: change→imageAId, outfit|extra→imageBId, person→imageBId if empty.
|
||||||
|
* Klein: person→identityStillId, change→imageAId.
|
||||||
|
*/
|
||||||
|
export function bindPhotosToRequest(photos, { mode, engine } = {}) {
|
||||||
|
const task = String(mode || 'generate')
|
||||||
|
const eng = String(engine || 'flux')
|
||||||
|
const list = Array.isArray(photos) ? photos.filter(p => p?.stillId) : []
|
||||||
|
const find = (...roles) => list.find(p => roles.includes(p.role))
|
||||||
|
|
||||||
|
let imageAId = ''
|
||||||
|
let imageBId = ''
|
||||||
|
let identityStillId = ''
|
||||||
|
|
||||||
|
if (eng === 'qwen21' && task === 'edit') {
|
||||||
|
let change = find(PHOTO_ROLE.change)
|
||||||
|
if (!change && list[0]) change = { ...list[0], role: PHOTO_ROLE.change }
|
||||||
|
const outfit = find(PHOTO_ROLE.outfit, PHOTO_ROLE.extra)
|
||||||
|
const person = find(PHOTO_ROLE.person)
|
||||||
|
imageAId = change?.stillId || ''
|
||||||
|
imageBId = outfit?.stillId || ''
|
||||||
|
if (!imageBId && person) imageBId = person.stillId
|
||||||
|
// Person with outfit already in image_2: leave as imageBId only (2-input graph).
|
||||||
|
identityStillId = ''
|
||||||
|
return { imageAId, imageBId, identityStillId, photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eng === 'krea' && task === 'refine') {
|
||||||
|
imageAId = find(PHOTO_ROLE.change)?.stillId || list[0]?.stillId || ''
|
||||||
|
return { imageAId, imageBId: '', identityStillId: '', photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task === 'video' || (task === 'extend' && eng === 'minimax')) {
|
||||||
|
imageAId = find(PHOTO_ROLE.first)?.stillId || list[0]?.stillId || ''
|
||||||
|
identityStillId = find(PHOTO_ROLE.person, PHOTO_ROLE.look)?.stillId || ''
|
||||||
|
return { imageAId, imageBId: '', identityStillId, photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eng === 'ltx' && task === 'video') {
|
||||||
|
imageAId = find(PHOTO_ROLE.first)?.stillId || list[0]?.stillId || ''
|
||||||
|
return { imageAId, imageBId: '', identityStillId: '', photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klein / flux
|
||||||
|
if (task === 'generate') {
|
||||||
|
identityStillId = find(PHOTO_ROLE.person)?.stillId || list[0]?.stillId || ''
|
||||||
|
return { imageAId: '', imageBId: '', identityStillId, photos: list }
|
||||||
|
}
|
||||||
|
if (task === 'edit' || task === 'iterate' || task === 'compose' || task === 'refine') {
|
||||||
|
imageAId = find(PHOTO_ROLE.change)?.stillId || list[0]?.stillId || ''
|
||||||
|
identityStillId = find(PHOTO_ROLE.person)?.stillId || ''
|
||||||
|
imageBId = find(PHOTO_ROLE.extra)?.stillId || ''
|
||||||
|
return { imageAId, imageBId, identityStillId, photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imageAId, imageBId, identityStillId, photos: list }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hydrate Photos cards from legacy hero/source fields when loading a job. */
|
||||||
|
export function photosFromRequest(request = {}) {
|
||||||
|
if (Array.isArray(request.photos) && request.photos.length) {
|
||||||
|
return request.photos.map(p => ({
|
||||||
|
stillId: String(p.stillId || p.id || ''),
|
||||||
|
role: String(p.role || PHOTO_ROLE.change)
|
||||||
|
})).filter(p => p.stillId)
|
||||||
|
}
|
||||||
|
const mode = String(request.mode || 'generate')
|
||||||
|
const engine = String(request.engine || 'flux')
|
||||||
|
const out = []
|
||||||
|
if (engine === 'qwen21' && mode === 'edit') {
|
||||||
|
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.change })
|
||||||
|
if (request.imageBId) out.push({ stillId: request.imageBId, role: PHOTO_ROLE.outfit })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if (mode === 'generate' && engine === 'flux' && request.identityStillId) {
|
||||||
|
out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if (['edit', 'iterate', 'compose', 'refine'].includes(mode)) {
|
||||||
|
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.change })
|
||||||
|
if (request.identityStillId && request.identityStillId !== request.imageAId) {
|
||||||
|
out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||||
|
}
|
||||||
|
if (request.imageBId) out.push({ stillId: request.imageBId, role: PHOTO_ROLE.extra })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if (mode === 'video') {
|
||||||
|
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.first })
|
||||||
|
if (request.identityStillId) out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Photos → request fields. Call before validate / queue.
|
||||||
|
* If Edit+Qwen has cards but no Photo to change, promote the first card.
|
||||||
|
* When `photos` is an explicit array (even empty), it is source of truth —
|
||||||
|
* do not resurrect cards from leftover imageAId / identityStillId.
|
||||||
|
*/
|
||||||
|
export function applyPhotosToRequest(raw = {}) {
|
||||||
|
const next = { ...raw }
|
||||||
|
const plan = photosPlan({ mode: next.mode, engine: next.engine })
|
||||||
|
if (!plan.show || plan.handoffReadonly) {
|
||||||
|
if (!Array.isArray(next.photos)) next.photos = []
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
const photosProvided = Array.isArray(raw.photos)
|
||||||
|
let photos = normalizePhotos(next.photos, plan)
|
||||||
|
if (!photosProvided && !photos.length && (next.imageAId || next.identityStillId || next.imageBId)) {
|
||||||
|
photos = normalizePhotos(photosFromRequest(next), plan)
|
||||||
|
}
|
||||||
|
// Promote first card to Photo to change when Edit needs it.
|
||||||
|
if (
|
||||||
|
plan.roles.includes(PHOTO_ROLE.change)
|
||||||
|
&& photos.length
|
||||||
|
&& !photos.some(p => p.role === PHOTO_ROLE.change)
|
||||||
|
) {
|
||||||
|
photos = [{ ...photos[0], role: PHOTO_ROLE.change }, ...photos.slice(1)]
|
||||||
|
}
|
||||||
|
const bound = bindPhotosToRequest(photos, { mode: next.mode, engine: next.engine })
|
||||||
|
next.photos = bound.photos
|
||||||
|
next.imageAId = bound.imageAId
|
||||||
|
next.imageBId = bound.imageBId
|
||||||
|
next.identityStillId = bound.identityStillId
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Qwen Edit: map mention phrases → <imageN> for TextEncode.
|
||||||
|
* Photo to change → <image1>; outfit/extra/person-on-2 → <image2>.
|
||||||
|
*/
|
||||||
|
export function injectQwenEditMentionTags(prompt, photos = []) {
|
||||||
|
let text = String(prompt || '')
|
||||||
|
const list = Array.isArray(photos) ? photos.filter(p => p?.stillId) : []
|
||||||
|
let change = list.find(p => p.role === PHOTO_ROLE.change)
|
||||||
|
if (!change && list[0]) change = { ...list[0], role: PHOTO_ROLE.change }
|
||||||
|
const outfit = list.find(p => p.role === PHOTO_ROLE.outfit || p.role === PHOTO_ROLE.extra)
|
||||||
|
const person = list.find(p => p.role === PHOTO_ROLE.person)
|
||||||
|
const image2 = outfit || (!outfit ? person : null)
|
||||||
|
|
||||||
|
const pairs = []
|
||||||
|
if (change) {
|
||||||
|
const idx = list.indexOf(list.find(p => p.stillId === change.stillId && p.role === change.role) || change)
|
||||||
|
pairs.push({ phrase: mentionPhrase(PHOTO_ROLE.change, Math.max(0, idx)), tag: '<image1>' })
|
||||||
|
// Also map generic Photo 1 if they used chip style
|
||||||
|
pairs.push({ phrase: 'Photo 1', tag: '<image1>' })
|
||||||
|
}
|
||||||
|
if (image2) {
|
||||||
|
const idx = list.findIndex(p => p.stillId === image2.stillId)
|
||||||
|
pairs.push({ phrase: mentionPhrase(image2.role, Math.max(0, idx)), tag: '<image2>' })
|
||||||
|
pairs.push({ phrase: 'Photo 2', tag: '<image2>' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longer phrases first so "the outfit photo" wins over fragments.
|
||||||
|
pairs.sort((a, b) => b.phrase.length - a.phrase.length)
|
||||||
|
for (const { phrase, tag } of pairs) {
|
||||||
|
if (!phrase || text.includes(tag) && !text.includes(phrase)) continue
|
||||||
|
if (text.includes(phrase)) text = text.split(phrase).join(tag)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||