Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776d585345 | ||
|
|
608f7d8d47 | ||
|
|
68731916ca | ||
|
|
08d507c491 | ||
|
|
d65420f34a | ||
|
|
d733e80881 | ||
|
|
dccfafa08b | ||
|
|
589791cdb8 | ||
|
|
c0289afa0e | ||
|
|
a9799e4920 | ||
|
|
52bd6cf4a8 |
@@ -0,0 +1,97 @@
|
||||
# Cursor prompt — unique mobile portrait shell (new file)
|
||||
|
||||
Do not edit `STUDIO2-DISPLAY-CURSOR-PROMPT.md`, `STUDIO2-DISPLAY-LAYOUT-SPEC.md`, `STUDIO2-LORA-DIALS-PROMPT.md`, or `STUDIO2-VIEWER-FIT-PROMPT.md`.
|
||||
|
||||
Branch: **`studio-2`**. Push `origin/studio-2` only. Chrome only. Same `form`, queue, generate, locks, LoRAs, library. No graph/runner/extend/YuE2 changes.
|
||||
|
||||
Desktop 3-column bench is **done**. Do not restyle desktop to make mobile work. Do not stack the three columns in a tall scroll and call it mobile.
|
||||
|
||||
Breakpoint: `(max-width: 767px)` **and** coarse pointer. Use a **separate mobile shell** (`Studio2Mobile.vue` or `Bench` template `v-if="isMobile"`) that binds the **same** state as desktop Bench.
|
||||
|
||||
Reference mock: `studio2-display-ux/mobile-mock.html` (open on a 390×844 viewport).
|
||||
|
||||
---
|
||||
|
||||
## Why unique
|
||||
|
||||
Portrait cannot show 260 | stage | 280. If you shrink that grid you lose Generate, LoRAs, and the still.
|
||||
|
||||
Mobile is a **viewer-first phone app** with sheets for the inspector and the queue. Every desktop control still exists. It is just not on screen at once.
|
||||
|
||||
---
|
||||
|
||||
## Chrome map (portrait)
|
||||
|
||||
```
|
||||
┌ status / 48px header ─────────────────────────────────────┐
|
||||
│ AIGen Inbox ▾ GPU ☰ queue(n) │
|
||||
├ task chips (scroll-x) Generate Edit … Music ─────────────┤
|
||||
├ model chips Klein | Krea or H3 | LTX ───────┤
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ STAGE (contain, full remaining height) │
|
||||
│ │
|
||||
├ filmstrip scroll-x, tiles 72px ───────────────────────────┤
|
||||
├ [Slots] [Prompt] [Look] [Gen] Generate (amber, 48px) ─┤
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Bottom **dock** is 4 tabs that open sheets, plus Generate always visible:
|
||||
|
||||
| Tab | Sheet contents (all desktop fields) |
|
||||
|---|---|
|
||||
| **Slots** | Hero, Start, End, Guides, Upload, Lock face, Lock outfit + Klein caption |
|
||||
| **Prompt** | Prompt textarea, negative chips, style/render/quality/lighting chips |
|
||||
| **Look** | LoRA stack (filename + Model/CLIP sliders + numbers), Seed + dice, Aspect, extra generation settings (steps/CFG only if that engine uses them) |
|
||||
| **Gen** | Engine-specific video/music extras: duration/target length, fps, turbo, camera beats, YuE2 hints. Not a second Generate button. |
|
||||
|
||||
**Generate** is the rightmost dock control — 48×48 or a 120px amber pill. Never labeled Queue.
|
||||
|
||||
Header **☰** or badge **Queue (n)** opens a **right/bottom sheet** of job cards (same cards as desktop right column).
|
||||
|
||||
Library and Settings stay header links (or a `…` menu). Do not drop them.
|
||||
|
||||
---
|
||||
|
||||
## Stage
|
||||
|
||||
- Same contain rule as desktop: `max-width/height 100%`, `object-fit: contain`, no inner max-width cap.
|
||||
- Still / video / audio switch on `selected.kind`. No waveform on a still.
|
||||
- Safe area: `padding-bottom: env(safe-area-inset-bottom)` on the dock.
|
||||
- Filmstrip under stage, 72×48 tiles, amber ring on selected, horizontal scroll. Trash on selected tile only.
|
||||
|
||||
---
|
||||
|
||||
## Sheets
|
||||
|
||||
- One sheet at a time. Height `min(85vh, content)`. Drag handle + Done.
|
||||
- Fields keep desktop order and payload keys.
|
||||
- LoRA card = filename + two (or one) sliders + numeric `0.00–2.00`. No hashed meters.
|
||||
- Krea disabled-on-Edit with the same one-line reason.
|
||||
- LTX disabled on AIGen with `xAIGen only`.
|
||||
- xAIGen hide defaults: eye on stage + each tile. No global hide checkbox required on mobile.
|
||||
|
||||
---
|
||||
|
||||
## What you must not do
|
||||
|
||||
- No desktop 3-column at `<768`.
|
||||
- No “rotate device” dead end.
|
||||
- No missing task (Iterate if desktop has it — add the chip).
|
||||
- No second `form` object.
|
||||
- Do not change Classic/Studio desktop tokens in this pass.
|
||||
|
||||
`isMobile` from `window.matchMedia('(max-width: 767px)')` + resize listener. SSR: default desktop to avoid hydration flip, then switch on mount.
|
||||
|
||||
---
|
||||
|
||||
## Done when
|
||||
|
||||
390×844 portrait:
|
||||
|
||||
- Stage fills the phone; dock + chips visible without page-zoom.
|
||||
- Slots, Prompt, Look, Gen sheets each reachable; Generate queues a job.
|
||||
- Queue sheet lists jobs.
|
||||
- Rotate to ≥768: desktop 3-column returns, same job state.
|
||||
|
||||
One or two present-tense commits. Screenshot 390 and 1440.
|
||||
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>AIGen — Display mock (Classic | Studio)</title>
|
||||
<link rel="stylesheet" href="./tokens/display.css" />
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: ui-sans-serif, system-ui, Inter, sans-serif; background: #0e0e10; color: #f3f1ea; }
|
||||
.app { min-height: 100vh; display: flex; flex-direction: column; gap: var(--chrome-gap); padding: var(--chrome-gap); }
|
||||
header.top {
|
||||
height: var(--header-h); display: flex; align-items: center; gap: 12px;
|
||||
border: 1px solid var(--line); border-radius: var(--radius); padding: 0 12px; background: var(--bg-raise);
|
||||
}
|
||||
.brand { font-weight: 650; letter-spacing: .02em; }
|
||||
.grow { flex: 1; }
|
||||
.pill { font-size: var(--type-sm); color: #b8e0a8; border: 1px solid #2d4a2a; background: #142016; border-radius: 999px; padding: 4px 10px; }
|
||||
.nav { color: var(--muted); font-size: var(--type-sm); display: flex; gap: 14px; }
|
||||
.nav b { color: var(--text); font-weight: 600; }
|
||||
.identity-header, .classic-hide { display: flex; gap: 10px; align-items: center; font-size: var(--type-sm); color: var(--muted); }
|
||||
.rail { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.seg {
|
||||
height: var(--rail-h); padding: 0 12px; border-radius: var(--radius);
|
||||
border: 1px solid var(--line); background: var(--bg-slot); color: var(--muted); cursor: pointer;
|
||||
}
|
||||
.seg[aria-pressed="true"] { background: #2a1f0c; border-color: var(--amber); color: var(--amber); }
|
||||
.seg:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.classic-selects { display: flex; gap: 8px; }
|
||||
.classic-selects select {
|
||||
background: var(--bg-slot); color: var(--text); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); height: var(--rail-h); padding: 0 8px;
|
||||
}
|
||||
.grid { display: grid; grid-template-columns: 240px 1fr 260px; gap: var(--chrome-gap); flex: 1; }
|
||||
.col { background: var(--bg-raise); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--card-pad); }
|
||||
.slot { background: var(--bg-slot); border: 1px dashed var(--line); border-radius: var(--radius); min-height: 72px; margin-bottom: 8px; padding: 8px; font-size: var(--type-sm); color: var(--muted); }
|
||||
.slot strong { display: block; color: var(--text); margin-bottom: 4px; }
|
||||
.viewer { position: relative; background: #111; border-radius: var(--radius); min-height: 320px; display: grid; place-items: center; color: var(--muted); border: 1px solid var(--line); }
|
||||
.eye-viewer { position: absolute; top: 8px; right: 8px; background: #000a; border: 1px solid var(--line); border-radius: 6px; padding: 4px; }
|
||||
.strip { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.tile { width: 72px; height: 48px; background: #222; border-radius: 6px; border: 1px solid var(--line); position: relative; }
|
||||
.eye-tile { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; }
|
||||
textarea, input {
|
||||
width: 100%; background: var(--bg-slot); color: var(--text); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 8px; font: inherit;
|
||||
}
|
||||
.cta { width: 100%; height: 44px; border: 0; border-radius: var(--radius); background: var(--amber); color: #1a1204; font-weight: 700; cursor: pointer; margin-top: 10px; }
|
||||
.hint { font-size: 11px; color: var(--muted); margin: 6px 0 10px; }
|
||||
.settings {
|
||||
position: fixed; right: 16px; bottom: 16px; width: 360px; background: var(--bg-raise);
|
||||
border: 1px solid var(--line); border-radius: 10px; padding: 14px; box-shadow: 0 12px 40px #0008;
|
||||
}
|
||||
.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.card {
|
||||
border: 1px solid var(--line); border-radius: 8px; padding: 10px; background: var(--bg-slot); cursor: pointer; text-align: left; color: inherit;
|
||||
}
|
||||
.card[aria-pressed="true"] { border-color: var(--amber); background: #2a1f0c; }
|
||||
.card small { display: block; color: var(--muted); margin-top: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app" data-display="studio" id="root">
|
||||
<header class="top">
|
||||
<span class="brand">AIGen</span>
|
||||
<span class="muted" style="color:var(--muted);font-size:12px">/ Inbox</span>
|
||||
<div class="grow"></div>
|
||||
<div class="identity-header">
|
||||
<label><input type="checkbox" checked /> Lock face</label>
|
||||
<label><input type="checkbox" checked /> Lock outfit</label>
|
||||
</div>
|
||||
<div class="classic-hide">
|
||||
<label><input type="checkbox" /> Hide input</label>
|
||||
<label><input type="checkbox" /> Hide output</label>
|
||||
</div>
|
||||
<nav class="nav"><span>Library</span><b>Queue</b><span>Settings</span></nav>
|
||||
<span class="pill">5080 Ready</span>
|
||||
</header>
|
||||
|
||||
<div class="rail rail-task" role="tablist">
|
||||
<button class="seg" aria-pressed="false">Image</button>
|
||||
<button class="seg" aria-pressed="false">Edit</button>
|
||||
<button class="seg" aria-pressed="false">Refine</button>
|
||||
<button class="seg" aria-pressed="true">Video</button>
|
||||
<button class="seg" aria-pressed="false">Extend</button>
|
||||
<button class="seg" aria-pressed="false">Music</button>
|
||||
</div>
|
||||
<div class="rail rail-model">
|
||||
<button class="seg" aria-pressed="true">MiniMax H3</button>
|
||||
<button class="seg" disabled title="xAIGen only">LTX</button>
|
||||
</div>
|
||||
<div class="classic-selects">
|
||||
<select><option>Video</option><option>Image</option><option>Extend</option><option>Music</option></select>
|
||||
<select><option>MiniMax H3</option><option disabled>LTX (xAIGen only)</option></select>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<aside class="col">
|
||||
<div class="slot"><strong>Hero</strong>car_bee still</div>
|
||||
<div class="slot"><strong>Start</strong>Select still or clip</div>
|
||||
<div class="slot"><strong>End</strong>Optional</div>
|
||||
<div class="identity-slots">
|
||||
<label><input type="checkbox" checked /> Lock face</label><br />
|
||||
<label><input type="checkbox" checked /> Lock outfit</label>
|
||||
<p class="hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="col">
|
||||
<div class="viewer">
|
||||
<button class="eye-viewer" title="Toggle viewer">eye</button>
|
||||
viewer · last frame / waveform
|
||||
</div>
|
||||
<div class="strip">
|
||||
<div class="tile"><span class="eye-tile">👁</span></div>
|
||||
<div class="tile"><span class="eye-tile">👁</span></div>
|
||||
<div class="tile"><span class="eye-tile">👁</span></div>
|
||||
<div class="tile"><span class="eye-tile">👁</span></div>
|
||||
</div>
|
||||
</main>
|
||||
<aside class="col">
|
||||
<textarea rows="5">car_bee walks the Great Hall, cinematic</textarea>
|
||||
<p class="hint">YuE2 target length is a plan hint, not a trim.</p>
|
||||
<input value="seed unlocked" />
|
||||
<button class="cta">Generate</button>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="settings">
|
||||
<strong>Settings → Display</strong>
|
||||
<p class="hint">Layout only. Jobs, engines, and library IDs stay the same.</p>
|
||||
<div class="cards">
|
||||
<button class="card" id="btnClassic" aria-pressed="false">Classic<small>packed header, dropdowns</small></button>
|
||||
<button class="card" id="btnStudio" aria-pressed="true">Studio<small>task rail, model rail, slots + eyes</small></button>
|
||||
</div>
|
||||
</aside>
|
||||
<script>
|
||||
const root = document.getElementById("root");
|
||||
const classic = document.getElementById("btnClassic");
|
||||
const studio = document.getElementById("btnStudio");
|
||||
function setMode(mode) {
|
||||
root.setAttribute("data-display", mode);
|
||||
classic.setAttribute("aria-pressed", mode === "classic" ? "true" : "false");
|
||||
studio.setAttribute("aria-pressed", mode === "studio" ? "true" : "false");
|
||||
}
|
||||
classic.onclick = () => setMode("classic");
|
||||
studio.onclick = () => setMode("studio");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 3l18 18"/>
|
||||
<path d="M10.6 10.6A3 3 0 0 0 12 15a3 3 0 0 0 2.4-4.4"/>
|
||||
<path d="M9.9 5.1A11 11 0 0 1 12 5c6.5 0 10 7 10 7a18 18 0 0 1-3.2 3.8"/>
|
||||
<path d="M6.1 6.1C3.7 7.8 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.2-.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||
<path d="M8 11V8a4 4 0 0 1 8 0v3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1,56 @@
|
||||
/* AIGen display tokens — Classic | Studio
|
||||
Apply on [data-display] wrapper. Do not fork components. */
|
||||
|
||||
:root,
|
||||
[data-display="studio"] {
|
||||
--bg: #0e0e10;
|
||||
--bg-raise: #161618;
|
||||
--bg-slot: #1c1c1f;
|
||||
--line: #2a2a2e;
|
||||
--text: #f3f1ea;
|
||||
--muted: #9a9588;
|
||||
--amber: #f5a524;
|
||||
--amber-dim: #c4841c;
|
||||
--radius: 8px;
|
||||
--chrome-gap: 16px;
|
||||
--rail-h: 40px;
|
||||
--card-pad: 14px;
|
||||
--header-h: 52px;
|
||||
--type: 14px;
|
||||
--type-sm: 12px;
|
||||
}
|
||||
|
||||
[data-display="classic"] {
|
||||
--chrome-gap: 8px;
|
||||
--rail-h: 32px;
|
||||
--card-pad: 10px;
|
||||
--header-h: 44px;
|
||||
--type: 13px;
|
||||
}
|
||||
|
||||
[data-display] {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: var(--type);
|
||||
}
|
||||
|
||||
[data-display="classic"] .rail-task,
|
||||
[data-display="classic"] .rail-model {
|
||||
display: none;
|
||||
}
|
||||
[data-display="studio"] .classic-selects {
|
||||
display: none;
|
||||
}
|
||||
[data-display="classic"] .identity-slots {
|
||||
display: none;
|
||||
}
|
||||
[data-display="studio"] .identity-header {
|
||||
display: none;
|
||||
}
|
||||
[data-display="classic"] .eye-tile,
|
||||
[data-display="classic"] .eye-viewer {
|
||||
display: none;
|
||||
}
|
||||
[data-display="studio"] .classic-hide {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,118 +1,657 @@
|
||||
/* Studio 2 display skins — Classic | Studio. Layout tokens only; palette matches main.css. */
|
||||
/* Pixel grid tokens — Classic | Studio. Match TARGET-classic-layout.png */
|
||||
|
||||
:root,
|
||||
[data-display='classic'],
|
||||
[data-display='studio'] {
|
||||
--display-bg: #09090b;
|
||||
--display-fg: #e4e4e7;
|
||||
--display-muted: #a1a1aa;
|
||||
--display-border: rgba(255, 255, 255, 0.1);
|
||||
--display-amber: #fbbf24;
|
||||
--display-amber-fg: #09090b;
|
||||
--display-radius: 8px;
|
||||
--display-focus: rgba(252, 211, 77, 0.5);
|
||||
--bg: #0b0b0d;
|
||||
--panel: #141416;
|
||||
--panel-raise: #1a1a1d;
|
||||
--line: #2a2a2e;
|
||||
--text: #f3f1ea;
|
||||
--muted: #9a9588;
|
||||
--amber: #f5a524;
|
||||
--amber-on: #1a1204;
|
||||
--chip-bg: #2a2418;
|
||||
--chip-line: #5a4a28;
|
||||
--chip-text: #e8c37a;
|
||||
--success: #7dda6f;
|
||||
--radius: 8px;
|
||||
--page-gap: 12px;
|
||||
--field-gap: 12px;
|
||||
--header-h: 48px;
|
||||
--rail-task-h: 40px;
|
||||
--rail-model-h: 36px;
|
||||
--left-w: 260px;
|
||||
--right-w: 280px;
|
||||
--type: 14px;
|
||||
--type-sm: 12px;
|
||||
--type-label: 11px;
|
||||
}
|
||||
|
||||
[data-display='classic'] {
|
||||
--chrome-gap: 0.5rem;
|
||||
--rail-h: 2.25rem;
|
||||
--card-pad: 0.75rem;
|
||||
--display-type-title: 1.75rem;
|
||||
--display-type-body: 0.875rem;
|
||||
--display-viewer-radius: 0.75rem;
|
||||
--field-gap: 8px;
|
||||
--type: 13px;
|
||||
}
|
||||
|
||||
[data-display='studio'] {
|
||||
--chrome-gap: 1rem;
|
||||
--rail-h: 3rem;
|
||||
--card-pad: 1rem;
|
||||
--display-type-title: 1.875rem;
|
||||
--display-type-body: 0.9375rem;
|
||||
--display-viewer-radius: 8px;
|
||||
[data-display] {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: var(--type);
|
||||
}
|
||||
|
||||
[data-display] .display-title {
|
||||
font-size: var(--display-type-title);
|
||||
/* Skin toggles */
|
||||
[data-display='classic'] .rail-task,
|
||||
[data-display='classic'] .rail-model,
|
||||
[data-display='classic'] .identity-slots,
|
||||
[data-display='classic'] .eye-viewer,
|
||||
[data-display='classic'] .eye-tile {
|
||||
display: none !important;
|
||||
}
|
||||
[data-display='studio'] .classic-task-model,
|
||||
[data-display='studio'] .viewer-lock-bar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-display] .display-body {
|
||||
font-size: var(--display-type-body);
|
||||
}
|
||||
|
||||
[data-display] .display-stack {
|
||||
.bench-root {
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
flex-direction: column;
|
||||
gap: var(--chrome-gap);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-display] .display-card {
|
||||
border-radius: var(--display-radius);
|
||||
border: 1px solid var(--display-border);
|
||||
padding: var(--card-pad);
|
||||
.bench-root[data-mobile="1"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-display] .display-viewer {
|
||||
border-radius: var(--display-viewer-radius);
|
||||
.bench-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
height: var(--header-h);
|
||||
min-height: var(--header-h);
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 12px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
[data-display] .display-rail {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
min-height: var(--rail-h);
|
||||
.bench-brand {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
[data-display] .display-rail__seg {
|
||||
min-height: var(--rail-h);
|
||||
border-radius: var(--display-radius);
|
||||
border: 1px solid var(--display-border);
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: var(--display-type-body);
|
||||
color: var(--display-fg);
|
||||
.bench-crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.bench-crumb select {
|
||||
max-width: 14rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-display] .display-rail__seg:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--display-focus);
|
||||
.bench-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
[data-display] .display-rail__seg[aria-pressed='true'],
|
||||
[data-display] .display-rail__seg[data-active='true'] {
|
||||
border-color: var(--display-amber);
|
||||
background: var(--display-amber);
|
||||
color: var(--display-amber-fg);
|
||||
.bench-nav {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: var(--type-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-display] .display-rail__seg:disabled,
|
||||
[data-display] .display-rail__seg[aria-disabled='true'] {
|
||||
opacity: 0.55;
|
||||
.bench-nav a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bench-nav a:hover,
|
||||
.bench-nav a.router-link-active {
|
||||
color: var(--amber);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gpu-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-raise);
|
||||
padding: 8px 12px;
|
||||
font-size: var(--type-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.gpu-chip .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.gpu-chip[data-tone='ready'] .dot { background: var(--success); }
|
||||
.gpu-chip[data-tone='busy'] .dot { background: var(--amber); animation: pulse 1.2s infinite; }
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.rail-task { min-height: var(--rail-task-h); align-items: center; padding-top: 8px; }
|
||||
.rail-model { min-height: var(--rail-model-h); align-items: center; padding-bottom: 8px; }
|
||||
|
||||
.seg {
|
||||
height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
padding: 0 14px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rail-model .seg { height: 32px; font-weight: 500; }
|
||||
|
||||
.seg[aria-pressed='true'] {
|
||||
border-color: var(--amber);
|
||||
background: var(--amber);
|
||||
color: var(--amber-on);
|
||||
}
|
||||
|
||||
.seg:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-display='studio'] .display-rail__seg {
|
||||
.seg:focus-visible {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||
}
|
||||
|
||||
.bench-grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
grid-template-columns: var(--left-w) minmax(0, 1fr) var(--right-w);
|
||||
gap: var(--page-gap);
|
||||
padding: var(--page-gap);
|
||||
}
|
||||
|
||||
.bench-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.bench-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--field-gap);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-label);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-raise);
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field-control:focus-visible,
|
||||
.field-area:focus-visible {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||
}
|
||||
|
||||
.field-area {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-raise);
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.neg-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.neg-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--chip-line);
|
||||
border-radius: 999px;
|
||||
background: var(--chip-bg);
|
||||
padding: 4px 10px;
|
||||
color: var(--chip-text);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.neg-chip button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.seed-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.seed-row .field-control { flex: 1; }
|
||||
|
||||
.dice-btn {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-raise);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewer-lock-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.center-stack {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Stage: fills center column above filmstrip + transport */
|
||||
.viewer-frame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #0b0b0d;
|
||||
}
|
||||
|
||||
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */
|
||||
.viewer-frame > img,
|
||||
.viewer-frame > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.eye-viewer {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #000a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filmstrip {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
height: 80px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 10px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
width: 96px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.tile { width: 120px; height: 80px; }
|
||||
.filmstrip { height: 96px; }
|
||||
}
|
||||
|
||||
.tile[data-active='true'] {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.tile button.pick {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.tile .eye-tile,
|
||||
.tile .tile-trash {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
display: grid;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #000a;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tile .tile-trash { right: 22px; }
|
||||
|
||||
.transport {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.transport .ops {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.transport button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.queue-col {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.queue-head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 12px 8px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.queue-badge {
|
||||
display: inline-grid;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--amber);
|
||||
color: var(--amber-on);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.job-card {
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel-raise);
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.job-card[data-active='true'] {
|
||||
border-width: 2px;
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.job-id {
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.job-title {
|
||||
margin-top: 2px;
|
||||
font-weight: 600;
|
||||
padding: 0.625rem 1.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
[data-display='classic'] .display-rail__seg {
|
||||
min-height: 2.25rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-weight: 500;
|
||||
.job-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
[data-display] .display-cta {
|
||||
border-radius: var(--display-radius);
|
||||
background: var(--display-amber);
|
||||
color: var(--display-amber-fg);
|
||||
font-weight: 600;
|
||||
.job-status {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
[data-display] .display-cta:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--display-focus);
|
||||
.job-status .left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-display] .display-control {
|
||||
border-radius: var(--display-radius);
|
||||
.job-status .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.job-status[data-state='queued'] .dot,
|
||||
.job-status[data-state='waiting'] .dot {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.kebab {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.queue-foot {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.generate-cta {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: var(--amber);
|
||||
color: var(--amber-on);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.generate-cta:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.queue-foot .tiny {
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.queue-foot .tiny button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-block { width: 100%; }
|
||||
|
||||
.slot-thumb {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #0a0a0c;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-thumb--hero { height: 148px; }
|
||||
.slot-thumb--slot { height: 96px; }
|
||||
.slot-thumb--empty {
|
||||
height: 72px;
|
||||
border-style: dashed;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.slot-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.slot-caption {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.slot-upload {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
color: var(--amber);
|
||||
font-size: var(--type-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.placeholder-still {
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
@@ -6,22 +6,21 @@
|
||||
<li
|
||||
v-for="(item, index) in model"
|
||||
:key="item.name"
|
||||
class="rounded-xl border bg-zinc-950/80"
|
||||
class="rounded-lg border border-[#2A2A2E] bg-[#1A1A1D] p-2.5"
|
||||
:class="[
|
||||
compact ? 'px-2 py-1.5' : 'px-3 py-2',
|
||||
dragOverIndex === index ? 'border-amber-300/50' : 'border-white/10',
|
||||
dragOverIndex === index ? 'border-amber-300/50' : '',
|
||||
draggingIndex === index ? 'opacity-60' : ''
|
||||
]"
|
||||
@dragover.prevent="onDragOver(index)"
|
||||
@drop.prevent="onDrop(index)"
|
||||
@dragleave="onDragLeave(index)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-start gap-2">
|
||||
<button
|
||||
v-if="model.length > 1"
|
||||
type="button"
|
||||
class="shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
|
||||
class="mt-0.5 shrink-0 cursor-grab touch-none text-zinc-500 hover:text-zinc-300 active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="disabled"
|
||||
draggable="true"
|
||||
title="Drag to reorder"
|
||||
@@ -31,61 +30,91 @@
|
||||
>
|
||||
<span class="block leading-none" aria-hidden="true">⋮⋮</span>
|
||||
</button>
|
||||
<span class="min-w-0"><span class="block truncate text-zinc-200">{{ labelFor(item.name) }}</span><span v-if="filenameSubtitle" class="block truncate text-[11px] text-zinc-500" :title="item.name">{{ item.name }}</span></span>
|
||||
<LoraTriggerTip
|
||||
:text="triggerFor(item.name)"
|
||||
:aria-label="'Activation for ' + labelFor(item.name)"
|
||||
@insert="emit('insert-trigger', $event)"
|
||||
@save="saveTrigger(item.name, $event)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="block truncate font-medium text-[#F3F1EA]" :title="labelFor(item.name)">{{ labelFor(item.name) }}</span>
|
||||
<LoraTriggerTip
|
||||
:text="triggerFor(item.name)"
|
||||
:aria-label="'Activation for ' + labelFor(item.name)"
|
||||
@insert="emit('insert-trigger', $event)"
|
||||
@save="saveTrigger(item.name, $event)"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="filenameSubtitle" class="mt-0.5 block truncate text-[11px] text-[#9A9588]" :title="item.name">{{ stemFor(item.name) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-[11px] text-zinc-500 hover:text-red-300 disabled:opacity-50"
|
||||
class="shrink-0 text-[11px] text-[#9A9588] hover:text-red-300 disabled:opacity-50"
|
||||
:disabled="disabled"
|
||||
@click="removeAt(index)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap gap-3">
|
||||
<Thumbwheel
|
||||
:model-value="item.strengthModel"
|
||||
label="Model"
|
||||
:lock-key="`lora-model:${item.name}`"
|
||||
:min="LORA_STRENGTH_MIN"
|
||||
:max="LORA_STRENGTH_MAX"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:fine-step="LORA_STRENGTH_STEP"
|
||||
:coarse-step="0.25"
|
||||
:digits="2"
|
||||
compact
|
||||
:disabled="disabled"
|
||||
@update:model-value="setStrength(index, 'strengthModel', $event)"
|
||||
/>
|
||||
<Thumbwheel
|
||||
:model-value="item.strengthClip"
|
||||
label="CLIP"
|
||||
:lock-key="`lora-clip:${item.name}`"
|
||||
:min="LORA_STRENGTH_MIN"
|
||||
:max="LORA_STRENGTH_MAX"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:fine-step="LORA_STRENGTH_STEP"
|
||||
:coarse-step="0.25"
|
||||
:digits="2"
|
||||
compact
|
||||
:disabled="disabled || !clipStrength"
|
||||
@update:model-value="setStrength(index, 'strengthClip', $event)"
|
||||
/>
|
||||
|
||||
<div class="mt-2 space-y-1.5">
|
||||
<label class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]">
|
||||
<span>{{ clipStrength ? 'Model' : 'Strength' }}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="lora-strength-range min-w-0"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:disabled="disabled"
|
||||
:value="displayStrength(item.strengthModel)"
|
||||
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' for ' + labelFor(item.name)"
|
||||
@input="onRange(index, 'strengthModel', $event)"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:disabled="disabled"
|
||||
:value="displayStrength(item.strengthModel).toFixed(2)"
|
||||
:aria-label="(clipStrength ? 'Model' : 'Strength') + ' value'"
|
||||
@change="onNumber(index, 'strengthModel', $event)"
|
||||
>
|
||||
</label>
|
||||
<label
|
||||
v-if="clipStrength"
|
||||
class="grid grid-cols-[3.25rem_minmax(0,1fr)_2.75rem] items-center gap-2 text-[11px] text-[#9A9588]"
|
||||
>
|
||||
<span>CLIP</span>
|
||||
<input
|
||||
type="range"
|
||||
class="lora-strength-range min-w-0"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:disabled="disabled"
|
||||
:value="displayStrength(item.strengthClip)"
|
||||
:aria-label="'CLIP for ' + labelFor(item.name)"
|
||||
@input="onRange(index, 'strengthClip', $event)"
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
class="lora-strength-num w-full rounded border border-[#2A2A2E] bg-[#0B0B0D] px-1 py-0.5 text-right font-mono text-[11px] text-[#F3F1EA] outline-none focus:border-[#F5A524]"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="LORA_STRENGTH_STEP"
|
||||
:disabled="disabled"
|
||||
:value="displayStrength(item.strengthClip).toFixed(2)"
|
||||
:aria-label="'CLIP value'"
|
||||
@change="onNumber(index, 'strengthClip', $event)"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!clipStrength && model.length" class="mt-1 text-[11px] text-zinc-500">CLIP unused on this graph — model strength only.</p>
|
||||
<select
|
||||
v-if="model.length < LORA_STACK_MAX"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 outline-none ring-amber-300/40 focus:ring-2 disabled:opacity-50"
|
||||
class="w-full rounded-lg border border-[#2A2A2E] bg-[#0B0B0D] text-[#F3F1EA] outline-none ring-[#F5A524]/40 focus:ring-2 disabled:opacity-50"
|
||||
:class="[
|
||||
compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm',
|
||||
compact ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
|
||||
model.length ? 'mt-2' : ''
|
||||
]"
|
||||
:disabled="disabled || !availableNames.length"
|
||||
@@ -103,13 +132,55 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lora-strength-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #2a2a2e;
|
||||
outline: none;
|
||||
}
|
||||
.lora-strength-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: #f5a524;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lora-strength-range::-moz-range-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #2a2a2e;
|
||||
}
|
||||
.lora-strength-range::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: #f5a524;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lora-strength-range:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.lora-strength-range::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #2a2a2e;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
LORA_STACK_MAX,
|
||||
LORA_STRENGTH_MAX,
|
||||
LORA_STRENGTH_MIN,
|
||||
LORA_STRENGTH_DEFAULT,
|
||||
LORA_STRENGTH_STEP,
|
||||
clampLoraStrength,
|
||||
loraDisplayName,
|
||||
loraFriendlyName,
|
||||
loraIdentityKey,
|
||||
loraMatchesImageEngine,
|
||||
@@ -120,6 +191,9 @@ import {
|
||||
type LoraStackItem
|
||||
} from '~/utils/loras'
|
||||
|
||||
const UI_MIN = 0
|
||||
const UI_MAX = 2
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
names: string[]
|
||||
filenameSubtitle?: boolean
|
||||
@@ -175,6 +249,17 @@ const selectPlaceholder = computed(() => {
|
||||
return model.value.length ? props.addLabel : props.emptyLabel
|
||||
})
|
||||
|
||||
function clampUi(raw: unknown) {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return LORA_STRENGTH_DEFAULT
|
||||
const snapped = Math.round(value / LORA_STRENGTH_STEP) * LORA_STRENGTH_STEP
|
||||
return Math.min(UI_MAX, Math.max(UI_MIN, Math.round(snapped * 100) / 100))
|
||||
}
|
||||
|
||||
function displayStrength(value: unknown) {
|
||||
return clampUi(value ?? LORA_STRENGTH_DEFAULT)
|
||||
}
|
||||
|
||||
function onAdd(event: Event) {
|
||||
const name = (event.target as HTMLSelectElement).value
|
||||
;(event.target as HTMLSelectElement).value = ''
|
||||
@@ -186,7 +271,11 @@ function addLora(name: string) {
|
||||
if (!item || model.value.length >= LORA_STACK_MAX) return
|
||||
const taken = new Set(model.value.map(entry => loraIdentityKey(entry.name)))
|
||||
if (taken.has(loraIdentityKey(item.name))) return
|
||||
model.value = [...model.value, item]
|
||||
model.value = [...model.value, {
|
||||
...item,
|
||||
strengthModel: clampUi(item.strengthModel ?? LORA_STRENGTH_DEFAULT),
|
||||
strengthClip: clampUi(item.strengthClip ?? LORA_STRENGTH_DEFAULT)
|
||||
}]
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
@@ -234,8 +323,22 @@ function onDragEnd() {
|
||||
}
|
||||
|
||||
function setStrength(index: number, key: 'strengthModel' | 'strengthClip', value: number) {
|
||||
const next = clampLoraStrength(value)
|
||||
model.value = model.value.map((item, i) => i === index ? { ...item, [key]: next } : item)
|
||||
const next = clampUi(value)
|
||||
model.value = model.value.map((item, i) => {
|
||||
if (i !== index) return item
|
||||
if (!props.clipStrength) {
|
||||
return { ...item, strengthModel: next, strengthClip: next }
|
||||
}
|
||||
return { ...item, [key]: next }
|
||||
})
|
||||
}
|
||||
|
||||
function onRange(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
function onNumber(index: number, key: 'strengthModel' | 'strengthClip', event: Event) {
|
||||
setStrength(index, key, Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
function triggerFor(name: string) {
|
||||
@@ -246,6 +349,10 @@ function labelFor(name: string) {
|
||||
return loraFriendlyName(name, props.aliases)
|
||||
}
|
||||
|
||||
function stemFor(name: string) {
|
||||
return loraDisplayName(name)
|
||||
}
|
||||
|
||||
function saveTrigger(name: string, trigger: string) {
|
||||
emit('update-trigger', name, trigger)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
@click="setDisplayMode('classic')"
|
||||
>
|
||||
<span class="block font-display text-lg font-semibold text-zinc-50">Classic</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">Packed header, compact selects, tight gaps.</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">packed header, dropdowns</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -46,7 +46,7 @@
|
||||
@click="setDisplayMode('studio')"
|
||||
>
|
||||
<span class="block font-display text-lg font-semibold text-zinc-50">Studio</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">Task and model rails, locks by slots, eye toggles.</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">task rail, model rail, slots + eyes</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
<template>
|
||||
<div class="w-36 shrink-0 space-y-2"><span class="text-xs text-zinc-400">{{ label }}</span><button class="flex h-24 w-full items-center justify-center overflow-hidden rounded-xl border border-white/15 bg-black" @click="$emit('pick')"><span v-if="asset && hidden" class="p-2 text-xs text-zinc-400">Still loaded · preview hidden</span><img v-else-if="asset" :src="asset.kind==='clip'?`/api/library/clips/${asset.id}/thumb`:`/api/library/stills/${asset.id}`" class="h-full w-full object-contain" :alt="label"><span v-else class="text-xs text-zinc-400">Choose {{ label.toLowerCase() }}</span></button><p v-if="asset" class="truncate text-xs" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p><label v-if="upload" class="block cursor-pointer text-xs text-amber-200">Upload {{ label.toLowerCase() }}<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)"></label></div>
|
||||
<div class="slot-block">
|
||||
<span class="field-label">{{ label }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="slot-thumb"
|
||||
:class="asset ? (size==='hero' ? 'slot-thumb--hero' : 'slot-thumb--slot') : 'slot-thumb--empty'"
|
||||
@click="$emit('pick')"
|
||||
>
|
||||
<span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Still loaded · preview hidden</span>
|
||||
<img v-else-if="asset" :src="asset.kind==='clip' ? `/api/library/clips/${asset.id}/thumb` : `/api/library/stills/${asset.id}`" :alt="label">
|
||||
<span v-else class="inline-flex items-center gap-1">Select <span aria-hidden="true">▾</span></span>
|
||||
</button>
|
||||
<p v-if="asset" class="slot-caption" :title="assetLabel(asset)">{{ assetLabel(asset) }}</p>
|
||||
<label v-if="upload" class="slot-upload">Upload {{ label.toLowerCase() }}
|
||||
<input type="file" accept="image/*" class="sr-only" :disabled="disabled" @change="$emit('upload',$event)">
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {assetLabel} from '~/shared/studio2/viewer.mjs'
|
||||
defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean}>()
|
||||
withDefaults(defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean;size?:'hero'|'slot'}>(),{size:'slot'})
|
||||
defineEmits(['pick','upload'])
|
||||
</script>
|
||||
|
||||
@@ -1,260 +1,290 @@
|
||||
<template>
|
||||
<main class="display-stack w-full min-w-0 p-4 md:p-6 xl:p-8" :data-display="displayMode">
|
||||
<header class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="display-title font-display font-bold tracking-tight text-zinc-50">Studio 2</h1>
|
||||
<p class="display-body mt-1 text-zinc-400">One hero. Every shot.</p>
|
||||
<div class="bench-root" :data-display="displayMode" :data-mobile="isMobile ? '1' : undefined">
|
||||
<Studio2Mobile v-if="isMobile" :ctx="mobileCtx" />
|
||||
|
||||
<template v-else>
|
||||
<header class="bench-header">
|
||||
<div class="bench-brand">{{ instanceName }}</div>
|
||||
<div class="bench-crumb" aria-label="Folder">
|
||||
<span aria-hidden="true">📁</span>
|
||||
<span>/ Projects /</span>
|
||||
<select v-model="form.folderId" :aria-label="'Folder'">
|
||||
<option v-for="f in library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="display-body text-zinc-300">Folder
|
||||
<select v-model="form.folderId" class="display-control ml-2 border border-white/10 bg-zinc-900 px-3 py-2 outline-none ring-amber-300/40 focus:ring-2">
|
||||
<option v-for="f in library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div v-if="isClassic && form.mode!=='music'" class="flex flex-wrap items-center gap-3 text-sm text-zinc-300" aria-label="Identity locks">
|
||||
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockFace" type="checkbox" class="accent-amber-400">Lock face</label>
|
||||
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockOutfit" type="checkbox" class="accent-amber-400">Lock outfit</label>
|
||||
</div>
|
||||
<div v-if="isClassic && xaigen" class="flex flex-wrap items-center gap-3 text-sm text-zinc-300" aria-label="Preview hide">
|
||||
<label class="inline-flex items-center gap-1.5"><input v-model="classicHideInput" type="checkbox" class="accent-amber-400">Hide input</label>
|
||||
<label class="inline-flex items-center gap-1.5"><input v-model="classicHideOutput" type="checkbox" class="accent-amber-400">Hide output</label>
|
||||
</div>
|
||||
<nav class="flex flex-wrap items-center gap-3 text-sm" aria-label="Studio links">
|
||||
<Studio2GpuStatus :status="gpu" />
|
||||
<NuxtLink to="/queue" class="text-zinc-300 outline-none ring-amber-300/40 focus-visible:ring-2">Queue</NuxtLink>
|
||||
<NuxtLink to="/library" class="text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2">Library</NuxtLink>
|
||||
<NuxtLink to="/settings" class="text-zinc-300 outline-none ring-amber-300/40 focus-visible:ring-2">Settings</NuxtLink>
|
||||
<div class="bench-header-right">
|
||||
<nav class="bench-nav" aria-label="Studio links">
|
||||
<NuxtLink to="/library">Library</NuxtLink>
|
||||
<NuxtLink to="/queue">Queue</NuxtLink>
|
||||
<NuxtLink to="/settings">Settings</NuxtLink>
|
||||
</nav>
|
||||
<span class="gpu-chip" :data-tone="gpu.tone" role="status" :title="gpu.detail">
|
||||
<span class="dot" aria-hidden="true" />
|
||||
{{ gpuChipLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="display-stack min-w-0">
|
||||
<label v-if="isClassic" class="display-body block text-zinc-300">Task
|
||||
<select :value="form.mode" class="display-control mt-1 w-full max-w-xs border border-white/10 bg-zinc-900 px-3 py-2 capitalize outline-none ring-amber-300/40 focus:ring-2" @change="onTaskSelect">
|
||||
<option v-for="task in tasks" :key="task" :value="task" class="capitalize">{{ task }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<nav v-else class="display-rail" aria-label="Tasks">
|
||||
<button
|
||||
v-for="task in tasks"
|
||||
:key="task"
|
||||
type="button"
|
||||
class="display-rail__seg capitalize"
|
||||
:aria-pressed="form.mode===task"
|
||||
@click="chooseTask(task)"
|
||||
>{{ task }}</button>
|
||||
</nav>
|
||||
|
||||
<div v-if="form.mode!=='music'" class="min-w-0">
|
||||
<label v-if="isClassic" class="display-body block text-zinc-300">Model
|
||||
<select v-model="form.engine" class="display-control mt-1 w-full max-w-md border border-white/10 bg-zinc-900 px-3 py-2 outline-none ring-amber-300/40 focus:ring-2">
|
||||
<option v-for="opt in engineOptions" :key="opt.value" :value="opt.value" :disabled="opt.disabled">{{ opt.label }}{{ opt.hint ? ' — '+opt.hint : '' }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div v-else class="display-rail" role="group" aria-label="Model">
|
||||
<button
|
||||
v-for="opt in engineOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="display-rail__seg"
|
||||
:aria-pressed="form.engine===opt.value"
|
||||
:aria-disabled="opt.disabled || undefined"
|
||||
:disabled="opt.disabled"
|
||||
:title="opt.hint || undefined"
|
||||
@click="!opt.disabled && (form.engine=opt.value)"
|
||||
>
|
||||
<span class="block">{{ opt.label }}</span>
|
||||
<span v-if="opt.hint" class="mt-0.5 block text-[11px] font-normal opacity-80">{{ opt.hint }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="display-body text-zinc-400">Model · YuE2</p>
|
||||
<nav class="rail rail-task" aria-label="Tasks">
|
||||
<button
|
||||
v-for="task in tasks"
|
||||
:key="task"
|
||||
type="button"
|
||||
class="seg"
|
||||
:aria-pressed="form.mode===task"
|
||||
@click="chooseTask(task)"
|
||||
>{{ task }}</button>
|
||||
</nav>
|
||||
<div v-if="form.mode!=='music'" class="rail rail-model" role="group" aria-label="Model">
|
||||
<button
|
||||
v-for="opt in engineOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="seg"
|
||||
:aria-pressed="form.engine===opt.value"
|
||||
:disabled="opt.disabled"
|
||||
:title="opt.hint || undefined"
|
||||
@click="!opt.disabled && (form.engine=opt.value)"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
<p v-else class="rail rail-model text-[var(--muted)]">YuE2</p>
|
||||
|
||||
<div class="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(24rem,2fr)] 2xl:gap-8">
|
||||
<section class="min-w-0 space-y-4">
|
||||
<div class="display-viewer relative flex min-h-80 items-center justify-center overflow-hidden border border-white/10 bg-black">
|
||||
<button
|
||||
v-if="selected && xaigen && isStudio"
|
||||
type="button"
|
||||
class="absolute right-3 top-3 z-10 rounded-full border border-white/20 bg-zinc-950/80 p-2 text-zinc-200 outline-none ring-amber-300/50 hover:border-amber-300/50 focus-visible:ring-2"
|
||||
:aria-pressed="!isHidden(selected)"
|
||||
:aria-label="isHidden(selected) ? 'Show preview' : 'Hide preview'"
|
||||
@click.stop="toggleReveal(selected.id)"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
|
||||
<path v-if="isHidden(selected)" d="M3 3l18 18M10.5 10.6a2.5 2.5 0 0 0 3 3M9.4 5.1A10.4 10.4 0 0 1 12 4.5c5 0 9.3 3.1 11 7.5a11.6 11.6 0 0 1-4.2 5.1M6.2 6.2A11.5 11.5 0 0 0 1 12c1.2 2.7 3.3 4.9 5.9 6.3A10.5 10.5 0 0 0 12 19.5c1.2 0 2.4-.2 3.5-.6" stroke-linecap="round"/>
|
||||
<template v-else>
|
||||
<path d="M2 12s3.5-7.5 10-7.5S22 12 22 12s-3.5 7.5-10 7.5S2 12 2 12Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</template>
|
||||
</svg>
|
||||
</button>
|
||||
<p v-if="selected && isHidden(selected)" class="text-zinc-400">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : selected.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</p>
|
||||
<div v-else-if="selected?.kind==='track'" class="w-full max-w-xl px-6 py-4">
|
||||
<AudioPlayer :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" />
|
||||
<div class="bench-grid">
|
||||
<!-- LEFT inspector -->
|
||||
<aside class="bench-panel">
|
||||
<div class="bench-scroll inspector">
|
||||
<div class="classic-task-model space-y-2">
|
||||
<label>
|
||||
<span class="field-label">Task</span>
|
||||
<select :value="form.mode" class="field-control capitalize" @change="onTaskSelect">
|
||||
<option v-for="task in tasks" :key="task" :value="task" class="capitalize">{{ task }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.mode!=='music'">
|
||||
<span class="field-label">Model</span>
|
||||
<select v-model="form.engine" class="field-control">
|
||||
<option v-for="opt in engineOptions" :key="opt.value" :value="opt.value" :disabled="opt.disabled">{{ opt.label }}{{ opt.hint ? ' — '+opt.hint : '' }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-else class="text-[var(--type-sm)] text-[var(--muted)]">Model · YuE2</p>
|
||||
</div>
|
||||
<video v-else-if="selected?.kind==='clip'" :key="selected.id" :src="`/api/library/clips/${selected.id}/video`" controls class="max-h-[65vh] w-full object-contain" />
|
||||
<img v-else-if="selected" :src="stillUrl(selected.id)" class="max-h-[65vh] max-w-full object-contain" alt="Current output">
|
||||
<p v-else class="text-zinc-500">Select a result to view it here</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="flex flex-wrap items-start gap-3">
|
||||
<button v-if="selected.kind!=='track'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="restore(selected)">Use as input</button>
|
||||
<button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="Object.assign(form,useAsSource(form,selected))">Use as source still</button>
|
||||
<button v-if="selected.kind==='still'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="Object.assign(form,useAsHero(form,selected))">Use as hero</button>
|
||||
<button v-if="selected.kind!=='track'" class="rounded-full border border-white/20 px-4 py-2 text-sm outline-none ring-amber-300/40 focus-visible:ring-2" @click="detailsOpen=true">Details</button>
|
||||
<button class="rounded-full border border-red-400/40 px-4 py-2 text-sm text-red-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="deleteOutput">Delete</button>
|
||||
<VideoUpscaleControl v-if="selected.kind==='clip'" :clip-id="selected.id" queue-only @complete="refresh" />
|
||||
</div>
|
||||
<template v-if="form.mode==='music'">
|
||||
<label><span class="field-label">Genre / style tags</span><textarea v-model="music.tags" rows="3" class="field-area" :placeholder="tagsPlaceholder" /></label>
|
||||
<label><span class="field-label">Lyrics</span><textarea v-model="music.lyrics" rows="8" class="field-area" :placeholder="lyricsPlaceholder" /></label>
|
||||
<label><span class="field-label">Target length (sec)</span><input v-model.number="music.duration" type="number" min="15" max="150" class="field-control"></label>
|
||||
<label><span class="field-label">Seed</span><input v-model="music.seed" placeholder="random" class="field-control"></label>
|
||||
<label><span class="field-label">Track name</span><input v-model="music.name" class="field-control" placeholder="Optional"></label>
|
||||
</template>
|
||||
|
||||
<div class="flex min-w-0 max-w-full gap-2 overflow-x-auto" aria-label="Current family outputs">
|
||||
<div
|
||||
v-for="o in family"
|
||||
:key="o.id"
|
||||
class="relative shrink-0 rounded-lg border p-1"
|
||||
:class="selected?.id===o.id ? 'border-amber-300' : 'border-white/10'"
|
||||
>
|
||||
<button type="button" class="block outline-none ring-amber-300/40 focus-visible:ring-2" @click="selectOutput(o)">
|
||||
<span v-if="isHidden(o)" class="flex h-20 w-24 items-center justify-center p-1 text-xs text-zinc-400">{{ o.kind==='track' ? 'Audio hidden' : o.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</span>
|
||||
<span v-else-if="o.kind==='track'" class="flex h-20 w-24 items-center justify-center rounded bg-zinc-900 text-xs text-amber-100">Audio</span>
|
||||
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : stillUrl(o.id)" class="h-20 w-24 rounded object-cover" alt="Family output">
|
||||
</button>
|
||||
<button
|
||||
v-if="xaigen && isStudio"
|
||||
type="button"
|
||||
class="absolute right-1 top-1 rounded-md border border-white/20 bg-zinc-950/80 p-1 text-zinc-200 outline-none ring-amber-300/50 focus-visible:ring-2"
|
||||
:aria-label="isHidden(o) ? 'Show tile' : 'Hide tile'"
|
||||
:aria-pressed="!isHidden(o)"
|
||||
@click.stop="toggleReveal(o.id)"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
|
||||
<path v-if="isHidden(o)" d="M3 3l18 18M10.5 10.6a2.5 2.5 0 0 0 3 3M9.4 5.1A10.4 10.4 0 0 1 12 4.5c5 0 9.3 3.1 11 7.5a11.6 11.6 0 0 1-4.2 5.1M6.2 6.2A11.5 11.5 0 0 0 1 12c1.2 2.7 3.3 4.9 5.9 6.3A10.5 10.5 0 0 0 12 19.5c1.2 0 2.4-.2 3.5-.6" stroke-linecap="round"/>
|
||||
<template v-else>
|
||||
<path d="M2 12s3.5-7.5 10-7.5S22 12 22 12s-3.5 7.5-10 7.5S2 12 2 12Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</template>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="form.mode!=='music'" aria-label="Source" class="display-card">
|
||||
<div class="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<h2 class="text-xs uppercase tracking-wider text-zinc-400">Source</h2>
|
||||
<div v-if="isStudio" class="flex flex-wrap gap-4 text-sm text-zinc-300" aria-label="Identity locks">
|
||||
<label class="flex items-start gap-2"><input v-model="form.lockFace" type="checkbox" class="mt-1 accent-amber-400"><span>Lock face<span class="block text-xs text-zinc-500">Hero encode + lock on Klein / Krea stills</span></span></label>
|
||||
<label class="flex items-start gap-2"><input v-model="form.lockOutfit" type="checkbox" class="mt-1 accent-amber-400"><span>Lock outfit<span class="block text-xs text-zinc-500">Hero encode + lock on Klein / Krea stills</span></span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<Studio2AssetSlot label="Hero" :asset="stills.find((a:any)=>a.id===form.identityStillId)" :hidden="slotHidden(form.identityStillId)" upload :disabled="uploading" @pick="picker='identityStillId'" @upload="upload($event,'hero')" />
|
||||
<template v-else>
|
||||
<Studio2AssetSlot size="hero" label="Hero still" :asset="stills.find((a:any)=>a.id===form.identityStillId)" :hidden="slotHidden(form.identityStillId)" upload :disabled="uploading" @pick="picker='identityStillId'" @upload="upload($event,'hero')" />
|
||||
<Studio2AssetSlot :label="form.mode==='extend'?'Previous clip':'Start still'" :asset="sourceSlot" :hidden="slotHidden(form.mode==='extend'?form.startClipId:form.imageAId)" upload :disabled="uploading" @pick="picker=form.mode==='extend'?'startClipId':'imageAId'" @upload="upload($event,'source')" />
|
||||
<Studio2AssetSlot v-if="form.mode==='compose'" label="Second still" :asset="stills.find((a:any)=>a.id===form.imageBId)" :hidden="slotHidden(form.imageBId)" @pick="picker='imageBId'" />
|
||||
<Studio2AssetSlot v-if="form.mode==='refine'" label="Mask" :asset="stills.find((a:any)=>a.id===form.maskId)" :hidden="slotHidden(form.maskId)" @pick="picker='maskId'" />
|
||||
<Studio2AssetSlot v-if="video" label="End still" :asset="stills.find((a:any)=>a.id===form.endStillId)" :hidden="slotHidden(form.endStillId)" @pick="picker='endStillId'" />
|
||||
</div>
|
||||
<label v-if="form.mode==='refine'" class="mt-3 block text-sm">Mask still (white = change). On-canvas mask comes later.
|
||||
<select v-model="form.maskId" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="">Choose mask still</option><option v-for="a in stills" :key="a.id" :value="a.id">{{ assetLabel(a) }}</option></select>
|
||||
</label>
|
||||
<p v-if="uploading || uploadMessage" role="status" class="mt-2 text-sm text-amber-200">{{ uploading?'Uploading image…':uploadMessage }}</p>
|
||||
</section>
|
||||
|
||||
<details v-if="video" class="rounded-xl border border-white/10 p-3"><summary>Add Guide stills</summary>
|
||||
<div v-for="(g,i) in form.guides" :key="i" class="mt-2 flex gap-2">
|
||||
<Studio2AssetSlot label="Guide" :asset="stills.find((a:any)=>a.id===g.stillId)" :hidden="slotHidden(g.stillId)" @pick="picker='guide:'+i" />
|
||||
<input v-model.number="g.frame" type="number" min="1" aria-label="Guide frame" class="w-20 bg-zinc-800 p-2">
|
||||
<button type="button" @click="form.guides.splice(i,1)">Remove</button>
|
||||
<div v-if="form.engine!=='qwen21'" class="identity-slots flex flex-col gap-2 text-[var(--type-sm)] text-[var(--muted)]" aria-label="Identity locks">
|
||||
<label class="inline-flex items-center gap-2"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
|
||||
<label class="inline-flex items-center gap-2"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
|
||||
<p class="text-[11px]">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
||||
</div>
|
||||
<p v-else class="text-[var(--type-sm)] text-[var(--muted)]">Qwen 2.1 is T2I — no hero, locks, or LoRA stack in this build.</p>
|
||||
|
||||
<label>
|
||||
<span class="field-label">Prompt</span>
|
||||
<textarea v-model="form.promptSections.action" class="field-area" placeholder="Describe the image or the shot." />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span class="field-label">Negative prompt</span>
|
||||
<div class="neg-row">
|
||||
<span v-for="chip in negativeChips" :key="chip" class="neg-chip">
|
||||
{{ chip }}
|
||||
<button type="button" :aria-label="'Remove '+chip" @click="removeNegative(chip)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.engine!=='qwen21'">
|
||||
<span class="field-label">LoRA</span>
|
||||
<LoraSelect v-model="form.settings.loraStack" compact :names="pickerLoras" :use="currentLoraUse" :clip-strength="!video" filename-subtitle :aliases="{...loraNames.aliases,...library.loraAliases}" :triggers="{...loraNames.triggers,...library.loraTriggers}" missing-label="None" @insert-trigger="form.promptSections.action += ' '+$event" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="field-label">Seed</span>
|
||||
<div class="seed-row">
|
||||
<input
|
||||
:value="form.settings.seed ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="2147483647"
|
||||
class="field-control"
|
||||
@input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'"
|
||||
>
|
||||
<button type="button" class="dice-btn" title="Random seed" aria-label="Random seed" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">🎲</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span class="field-label">Aspect ratio</span>
|
||||
<select v-model="form.settings.aspect" class="field-control">
|
||||
<option v-for="opt in aspectOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="button" class="text-left text-[var(--type-sm)] text-[var(--amber)]" @click="settingsOpen=true">More settings (steps / CFG)…</button>
|
||||
<p v-if="uploading || uploadMessage" role="status" class="text-[var(--type-sm)] text-[var(--amber)]">{{ uploading?'Uploading…':uploadMessage }}</p>
|
||||
<p v-if="error" role="alert" class="text-[var(--type-sm)] text-red-300">{{ error }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- CENTER viewer -->
|
||||
<section class="bench-panel">
|
||||
<div v-if="form.mode!=='music'" class="viewer-lock-bar" aria-label="Locks and hide">
|
||||
<template v-if="form.engine!=='qwen21'">
|
||||
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockFace" type="checkbox" class="accent-[var(--amber)]">Lock face</label>
|
||||
<label class="inline-flex items-center gap-1.5"><input v-model="form.lockOutfit" type="checkbox" class="accent-[var(--amber)]">Lock outfit</label>
|
||||
</template>
|
||||
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideInput" type="checkbox" class="accent-[var(--amber)]">Hide input</label>
|
||||
<label v-if="xaigen" class="inline-flex items-center gap-1.5"><input v-model="classicHideOutput" type="checkbox" class="accent-[var(--amber)]">Hide output</label>
|
||||
</div>
|
||||
|
||||
<div class="center-stack">
|
||||
<div class="viewer-frame">
|
||||
<button
|
||||
v-if="selected && xaigen"
|
||||
type="button"
|
||||
class="eye-viewer"
|
||||
:aria-pressed="!isHidden(selected)"
|
||||
:aria-label="isHidden(selected) ? 'Show preview' : 'Hide preview'"
|
||||
@click.stop="toggleReveal(selected.id)"
|
||||
>
|
||||
<img :src="isHidden(selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
|
||||
</button>
|
||||
|
||||
<template v-if="selected && isHidden(selected)">
|
||||
<p class="placeholder-still">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : selected.role==='input' ? 'Still loaded · preview hidden' : 'Output hidden' }}</p>
|
||||
</template>
|
||||
<template v-else-if="selected?.kind==='track'">
|
||||
<div class="w-full max-w-xl px-6 py-4">
|
||||
<AudioPlayer :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="selected?.kind==='clip'">
|
||||
<video
|
||||
ref="viewerVideo"
|
||||
:key="selected.id"
|
||||
:src="`/api/library/clips/${selected.id}/video`"
|
||||
:muted="viewerMuted"
|
||||
@timeupdate="onVideoTime"
|
||||
@loadedmetadata="onVideoTime"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="selected">
|
||||
<img :src="stillUrl(selected.id)" alt="Selected still">
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="placeholder-still">Select a still or clip in the strip</p>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" class="mt-3 text-amber-200" @click="form.guides.push({stillId:'',frame:24})">Add guide</button>
|
||||
</details>
|
||||
|
||||
<div class="filmstrip" aria-label="Current family outputs">
|
||||
<div
|
||||
v-for="o in family"
|
||||
:key="o.id"
|
||||
class="tile"
|
||||
:data-active="selected?.id===o.id ? 'true' : undefined"
|
||||
>
|
||||
<button type="button" class="pick" @click="selectOutput(o)">
|
||||
<span v-if="isHidden(o)" class="flex h-full items-center justify-center p-1 text-[10px] text-[var(--muted)]">Hidden</span>
|
||||
<span v-else-if="o.kind==='track'" class="flex h-full items-center justify-center text-[10px] text-[var(--amber)]">Audio</span>
|
||||
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : stillUrl(o.id)" alt="">
|
||||
</button>
|
||||
<button
|
||||
v-if="xaigen"
|
||||
type="button"
|
||||
class="eye-tile"
|
||||
:aria-label="isHidden(o) ? 'Show tile' : 'Hide tile'"
|
||||
@click.stop="toggleReveal(o.id)"
|
||||
>
|
||||
<img :src="isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
|
||||
</button>
|
||||
<button
|
||||
v-if="selected?.id===o.id"
|
||||
type="button"
|
||||
class="tile-trash"
|
||||
aria-label="Delete selected"
|
||||
title="Delete"
|
||||
@click.stop="deleteOutput"
|
||||
>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="transport">
|
||||
<span>{{ transportLabel }}</span>
|
||||
<div class="ops">
|
||||
<button type="button" :aria-pressed="viewerMuted" :aria-label="viewerMuted ? 'Unmute' : 'Mute'" @click="viewerMuted=!viewerMuted">{{ viewerMuted ? '🔇' : '🔊' }}</button>
|
||||
<button type="button" aria-label="Fullscreen" @click="toggleFullscreen">⛶</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="min-w-0 space-y-4">
|
||||
<template v-if="form.mode==='music'">
|
||||
<p class="text-sm text-zinc-400">YuE2 · lyrics-to-song · plan → synthesize → decode</p>
|
||||
<label class="block text-sm">Genre / style tags
|
||||
<textarea v-model="music.tags" rows="3" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" :placeholder="tagsPlaceholder" />
|
||||
</label>
|
||||
<label class="block text-sm">Lyrics
|
||||
<textarea v-model="music.lyrics" rows="8" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" :placeholder="lyricsPlaceholder" />
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block text-sm">Target length (sec)
|
||||
<input v-model.number="music.duration" type="number" min="15" max="150" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2">
|
||||
<span class="mt-1 block text-[11px] text-zinc-500">Caps semantic tokens (~25/s). Actual length follows the model end token — not an FFmpeg trim.</span>
|
||||
</label>
|
||||
<label class="block text-sm">Seed
|
||||
<input v-model="music.seed" placeholder="random" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2">
|
||||
</label>
|
||||
</div>
|
||||
<label class="block text-sm">Track name (optional)
|
||||
<input v-model="music.name" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="Optional track name">
|
||||
</label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="block">Prompt<textarea v-model="form.promptSections.action" rows="4" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="Describe the image or the shot." /></label>
|
||||
<Studio2PromptControls v-if="video" v-model="form.promptSections" />
|
||||
<Studio2StyleChips v-else v-model="form.imageStyles" />
|
||||
<label v-if="video" class="block">Extra constraints (optional) — do not repeat the Prompt. Example: keep the black glasses, no extra people.
|
||||
<textarea v-model="form.promptSections.locks" rows="2" class="mt-1 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" />
|
||||
</label>
|
||||
<details v-if="video || form.mode==='edit'" class="rounded-xl border border-white/10 p-3">
|
||||
<summary>{{ video?'Queue more shots':'Queue extra edit passes' }}</summary>
|
||||
<p class="mt-2 text-xs text-zinc-400">Each blank-line-separated block is another job. Hero and settings stay. Press Enter twice between shots. A wrapped sentence is still one shot.{{ video?'':' Each extra pass edits the previous result with Klein.' }}</p>
|
||||
<textarea v-model="batchText" rows="5" class="mt-2 w-full rounded-xl bg-zinc-900 p-3 outline-none ring-amber-300/40 focus:ring-2" placeholder="First additional prompt Next additional prompt" />
|
||||
</details>
|
||||
<button type="button" class="text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="settingsOpen=true">Generation settings</button>
|
||||
<p v-if="!video && form.engine==='krea'" class="text-sm text-zinc-400">Krea does not attach the hero identity reference.</p>
|
||||
<p v-if="form.mode==='extend'" class="text-sm text-amber-200">{{ form.startClipId ? 'Extend · '+(clips.find((c:any)=>c.id===form.startClipId)?.name || form.startClipId)+' · last frame' : 'Select the clip in the family strip or Previous clip.' }}</p>
|
||||
</template>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
|
||||
<button type="button" :disabled="submitting" class="display-cta w-full p-4 outline-none disabled:opacity-50" @click="queue">{{ submitting?'Queuing…':'Queue' }}</button>
|
||||
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Job queue</h2>
|
||||
<div v-for="job in upscales.filter(j=>j.folderId===form.folderId && ['queued','running'].includes(j.status))" :key="job.id" class="space-y-2 rounded-xl border border-amber-300/25 bg-zinc-900 p-4 text-sm" role="status">
|
||||
<div class="flex justify-between gap-3"><strong>Video upscale</strong><span class="capitalize text-amber-200">{{ job.status }}</span></div>
|
||||
<p class="truncate text-zinc-300">{{ job.name }}</p>
|
||||
<p class="text-xs text-zinc-400">{{ job.width?job.width+' × '+job.height+' · ':'' }}{{ Math.round((job.elapsedMs||0)/1000) }}s elapsed</p>
|
||||
<progress class="h-2 w-full accent-amber-300" max="100" :value="job.progress || 0" />
|
||||
<p class="text-xs text-zinc-400">{{ job.message }}</p>
|
||||
<!-- RIGHT queue -->
|
||||
<aside class="bench-panel queue-col">
|
||||
<div class="queue-head">
|
||||
Queue
|
||||
<span class="queue-badge">{{ queueCount }}</span>
|
||||
</div>
|
||||
<div v-for="job in musicJobs" :key="job.id" class="space-y-2 rounded-xl border border-amber-300/25 bg-zinc-900 p-3 text-sm" aria-live="polite" role="status">
|
||||
<div class="flex justify-between gap-3">
|
||||
<strong class="truncate">{{ job.name || 'YuE2' }}</strong>
|
||||
<span class="capitalize text-amber-200">{{ job.live?.status || job.status }}</span>
|
||||
<div class="queue-list">
|
||||
<div
|
||||
v-for="card in queueCards"
|
||||
:key="card.id"
|
||||
class="job-card"
|
||||
:data-active="focusedJobId===card.id ? 'true' : undefined"
|
||||
@click="focusQueueCard(card)"
|
||||
>
|
||||
<div class="job-id">{{ card.code }}</div>
|
||||
<div class="job-title">{{ card.title }}</div>
|
||||
<div class="job-meta">{{ card.meta }}</div>
|
||||
<div class="job-status" :data-state="card.state">
|
||||
<span class="left"><span class="dot" aria-hidden="true" />{{ card.statusLabel }}</span>
|
||||
<button v-if="card.cancelable" type="button" class="kebab" aria-label="Cancel job" @click.stop="cancel(card.job)">⋮</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-400">YuE2 · target {{ job.duration || '—' }}s<span v-if="job.live?.message"> · {{ job.live.message }}</span></p>
|
||||
<progress v-if="job.live?.progress" class="h-2 w-full accent-amber-300" max="100" :value="job.live.progress || 0" />
|
||||
<p v-if="job.lastError || job.live?.error" role="alert" class="text-red-300">{{ job.lastError || job.live?.error }}</p>
|
||||
<p v-if="!queueCards.length" class="px-1 text-[var(--type-sm)] text-[var(--muted)]">No active jobs</p>
|
||||
</div>
|
||||
<Studio2JobProgress v-for="job in benchJobs" :key="job.id" :job="job" :now="now" @select="focusJob(job)" @cancel="cancel(job)" />
|
||||
<button v-if="folderJobs.some(j=>['complete','failed','cancelled'].includes(j.state)) || musicJobs.some(j=>['complete','done','error','cancelled'].includes(j.status))" type="button" class="text-sm text-zinc-400 outline-none ring-amber-300/40 focus-visible:ring-2" @click="clearFinished">Clear finished</button>
|
||||
</section>
|
||||
<div class="queue-foot">
|
||||
<button type="button" class="generate-cta" :disabled="submitting" @click="queue">
|
||||
<span aria-hidden="true">▶</span>
|
||||
{{ submitting ? 'Queuing…' : 'Generate' }}
|
||||
</button>
|
||||
<p class="tiny">
|
||||
Job queue
|
||||
<button v-if="canClearFinished" type="button" @click="clearFinished"> · Clear finished</button>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="settingsOpen || detailsOpen" class="fixed inset-0 z-50 bg-black/70" @click.self="settingsOpen=false;detailsOpen=false">
|
||||
<aside class="absolute inset-y-0 right-0 w-full max-w-md overflow-auto border-l border-white/15 bg-zinc-950 p-6">
|
||||
<button type="button" class="float-right outline-none ring-amber-300/40 focus-visible:ring-2" @click="settingsOpen=false;detailsOpen=false">Close</button>
|
||||
<aside class="absolute inset-y-0 right-0 w-full max-w-md overflow-auto border-l border-[var(--line)] bg-[var(--bg)] p-6">
|
||||
<button type="button" class="float-right" @click="settingsOpen=false;detailsOpen=false">Close</button>
|
||||
<template v-if="settingsOpen">
|
||||
<h2 class="mb-6 text-xl font-semibold">Generation settings</h2>
|
||||
<AspectRatioPick v-model="form.settings.aspect" heading="Aspect" :options="aspectOptions" />
|
||||
<p class="my-3 text-sm text-zinc-400">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding, never stretch</p>
|
||||
<h2 class="mb-6 text-xl font-semibold">More settings</h2>
|
||||
<p class="mb-3 text-[var(--type-sm)] text-[var(--muted)]">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding</p>
|
||||
<label v-for="key in ['steps','cfg',...(video?['duration','fps']:[])]" :key="key" class="mb-4 block capitalize">{{ key }}
|
||||
<input v-model.number="form.settings[key]" type="number" class="mt-1 w-full rounded bg-zinc-800 p-2 outline-none ring-amber-300/40 focus:ring-2">
|
||||
<input v-model.number="form.settings[key]" type="number" class="field-control mt-1">
|
||||
</label>
|
||||
<label class="mb-4 block">Seed · {{ form.settings.seedMode==='locked'?'Locked':'Random each job' }}
|
||||
<input :value="form.settings.seed" type="number" min="0" max="2147483647" class="mt-1 w-full rounded bg-zinc-800 p-2 outline-none ring-amber-300/40 focus:ring-2" @input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'">
|
||||
</label>
|
||||
<button type="button" class="mb-4 text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">Random</button>
|
||||
<label v-if="video"><input v-model="form.settings.turbo" type="checkbox" class="accent-amber-400"> Turbo</label>
|
||||
<LoraSelect v-model="form.settings.loraStack" class="mt-5" :names="pickerLoras" :use="currentLoraUse" :clip-strength="true" filename-subtitle :aliases="{...loraNames.aliases,...library.loraAliases}" :triggers="{...loraNames.triggers,...library.loraTriggers}" missing-label="No cached LoRAs yet — Refresh to check Comfy" @insert-trigger="form.promptSections.action += ' '+$event" />
|
||||
<button type="button" class="mt-2 text-sm text-amber-200 outline-none ring-amber-300/40 focus-visible:ring-2" @click="loadLoras(true)">Refresh LoRAs</button>
|
||||
<details class="mt-6"><summary>Compiled prompt</summary>
|
||||
<pre class="whitespace-pre-wrap text-xs text-zinc-400">{{ compiled }}</pre>
|
||||
<p v-if="!video" class="mt-3 text-xs text-zinc-400">Negative: {{ stylePrompt(form.imageStyles,true) || 'None' }}</p>
|
||||
<label v-if="video" class="mb-4 flex items-center gap-2"><input v-model="form.settings.turbo" type="checkbox" class="accent-[var(--amber)]"> Turbo</label>
|
||||
<details v-if="video || form.mode==='edit'" class="mt-4">
|
||||
<summary>{{ video?'Queue more shots':'Queue extra edit passes' }}</summary>
|
||||
<textarea v-model="batchText" rows="5" class="field-area mt-2" placeholder="First additional prompt Next additional prompt" />
|
||||
</details>
|
||||
<button type="button" class="mt-4 text-[var(--type-sm)] text-[var(--amber)]" @click="loadLoras(true)">Refresh LoRAs</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2 class="mb-6 text-xl font-semibold">Output details</h2>
|
||||
@@ -264,36 +294,39 @@
|
||||
</div>
|
||||
|
||||
<div v-if="picker" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6" @click.self="picker=''">
|
||||
<section role="dialog" aria-modal="true" aria-label="Choose source asset" class="max-h-[80vh] w-full max-w-4xl overflow-auto rounded-2xl border border-white/15 bg-zinc-950 p-5">
|
||||
<section role="dialog" aria-modal="true" aria-label="Choose source asset" class="max-h-[80vh] w-full max-w-4xl overflow-auto rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel)] p-5">
|
||||
<div class="mb-4 flex justify-between"><h2>Choose {{ picker==='identityStillId'?'hero':'source' }}</h2><button type="button" @click="picker=''">Close</button></div>
|
||||
<button type="button" class="mb-4 text-sm text-zinc-400" @click="pickAsset(null)">Clear slot</button>
|
||||
<button type="button" class="mb-4 text-[var(--type-sm)] text-[var(--muted)]" @click="pickAsset(null)">Clear slot</button>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
|
||||
<button v-for="a in pickerAssets" :key="a.id" type="button" class="rounded-xl border border-white/15 p-2 text-left outline-none ring-amber-300/40 focus-visible:ring-2" @click="pickAsset(a)">
|
||||
<span v-if="isHidden(a)" class="flex h-24 items-center text-xs text-zinc-400">Still loaded · preview hidden</span>
|
||||
<img v-else :src="a.kind==='clip'?'/api/library/clips/'+a.id+'/thumb':stillUrl(a.id)" class="h-24 w-full object-contain" alt="Source option">
|
||||
<button v-for="a in pickerAssets" :key="a.id" type="button" class="rounded-[var(--radius)] border border-[var(--line)] p-2 text-left" @click="pickAsset(a)">
|
||||
<span v-if="isHidden(a)" class="flex h-24 items-center text-xs text-[var(--muted)]">Still loaded · preview hidden</span>
|
||||
<img v-else :src="a.kind==='clip'?'/api/library/clips/'+a.id+'/thumb':stillUrl(a.id)" class="h-24 w-full object-contain" alt="">
|
||||
<span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
|
||||
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
|
||||
import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs'
|
||||
import {aspectOptions,resolveSize,bindClip,changeSource,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs'
|
||||
import {sectionsFrom} from '~/shared/studio2/contracts.mjs'
|
||||
import {aspectOptions,resolveSize,bindClip,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs'
|
||||
import {folderAssets,newestOutput,assetLabel,reconcileSelection,previewHidden} from '~/shared/studio2/viewer.mjs'
|
||||
import {queueJobs} from '~/shared/studio2/queue-view.mjs'
|
||||
import {gpuStatus} from '~/shared/studio2/status.mjs'
|
||||
import {applyUploadedStill} from '~/shared/studio2/upload.mjs'
|
||||
import {parseBatch,stylePrompt} from '~/shared/studio2/styles.mjs'
|
||||
import {defaultNegatives,parseBatch} from '~/shared/studio2/styles.mjs'
|
||||
import {randomSeed} from '~/shared/studio2/seed.mjs'
|
||||
import {DEFAULT_MUSIC_LYRICS,DEFAULT_MUSIC_TAGS} from '~/utils/music'
|
||||
import {isXaigenStudio} from '~/utils/videoModels'
|
||||
|
||||
const xaigen=useRuntimeConfig().public.studio==='xaigen'
|
||||
const {displayMode,isStudio,isClassic}=useDisplayMode()
|
||||
const xaigen=isXaigenStudio(useRuntimeConfig().public)
|
||||
const instanceName=String(useRuntimeConfig().public.instanceName || 'AIGen')
|
||||
const {displayMode,isClassic}=useDisplayMode()
|
||||
const {isMobile}=useIsMobile()
|
||||
const classicHideInput=ref(true)
|
||||
const classicHideOutput=ref(true)
|
||||
const picker=ref('')
|
||||
@@ -304,7 +337,7 @@ const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),healthState=re
|
||||
const library=ref<any>({folders:[],stills:[],clips:[],tracks:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
|
||||
const studioQueue=ref<any[]>([])
|
||||
const liveMusic=ref<Record<string,any>>({})
|
||||
const form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[]},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
|
||||
const form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[...defaultNegatives]},lockFace:true,lockOutfit:true,hideInput:xaigen,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'16:9',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
|
||||
const music=reactive({tags:DEFAULT_MUSIC_TAGS,lyrics:DEFAULT_MUSIC_LYRICS,duration:60,seed:'',name:''})
|
||||
const tagsPlaceholder=DEFAULT_MUSIC_TAGS,lyricsPlaceholder=DEFAULT_MUSIC_LYRICS
|
||||
let loraMemory=createLoraMemory(xaigen)
|
||||
@@ -314,8 +347,14 @@ watch(()=>[currentLoraUse.value,form.settings.loraStack],()=>loraMemory.sync(for
|
||||
const route=useRoute()
|
||||
const tasks=['generate','edit','compose','refine','video','extend','music']
|
||||
const settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('')
|
||||
const viewerVideo=ref<HTMLVideoElement|null>(null)
|
||||
const viewerMuted=ref(false)
|
||||
const videoTime=ref(0)
|
||||
const videoDuration=ref(0)
|
||||
const focusedJobId=ref('')
|
||||
const video=computed(()=>['video','extend'].includes(form.mode))
|
||||
const kreaSticky=computed(()=>['edit','compose','iterate'].includes(form.mode))
|
||||
const qwenOnlyGenerate=computed(()=>form.mode!=='generate')
|
||||
const engineOptions=computed(()=>{
|
||||
if(video.value){
|
||||
return [
|
||||
@@ -325,16 +364,25 @@ const engineOptions=computed(()=>{
|
||||
}
|
||||
return [
|
||||
{value:'flux',label:'Klein',disabled:false,hint:''},
|
||||
{value:'krea',label:'Krea 2',disabled:kreaSticky.value,hint:kreaSticky.value?'sticky hero = Klein':''}
|
||||
{value:'krea',label:'Krea 2',disabled:kreaSticky.value,hint:kreaSticky.value?'sticky hero = Klein':''},
|
||||
{value:'qwen21',label:'Qwen 2.1',disabled:qwenOnlyGenerate.value,hint:qwenOnlyGenerate.value?'Qwen 2.1 is T2I in this build':''}
|
||||
]
|
||||
})
|
||||
watch(engineOptions,opts=>{
|
||||
const current=opts.find(o=>o.value===form.engine)
|
||||
if(!current || current.disabled)form.engine=opts.find(o=>!o.disabled)?.value || form.engine
|
||||
})
|
||||
watch(()=>form.engine,(engine,prev)=>{
|
||||
if(engine==='qwen21' && prev!=='qwen21'){
|
||||
form.settings.steps=25
|
||||
form.settings.cfg=1
|
||||
form.lockFace=false
|
||||
form.lockOutfit=false
|
||||
form.settings.loraStack=[]
|
||||
}
|
||||
})
|
||||
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
|
||||
const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
|
||||
const compiled=computed(()=>compilePrompt(form.promptSections,video.value,form))
|
||||
const stillUrl=(id:string)=>`/api/library/stills/${id}`
|
||||
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
|
||||
const benchJobs=computed(()=>queueJobs(folderJobs.value))
|
||||
@@ -342,14 +390,98 @@ const musicJobs=computed(()=>studioQueue.value
|
||||
.filter((j:any)=>j.kind==='music' && j.folderId===form.folderId && !['complete','done'].includes(j.status))
|
||||
.map((j:any)=>({...j,live:j.liveJobId?liveMusic.value[j.liveJobId]:null})))
|
||||
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value))
|
||||
const gpuChipLabel=computed(()=>{
|
||||
const raw=String(healthState.value?.comfy?.gpu || healthState.value?.gpuName || healthState.value?.comfy?.device || '').trim()
|
||||
const short=raw.match(/(\d{3,4})\b/)?.[1]
|
||||
if(gpu.value.tone==='ready') return short ? `${short} Ready` : 'Ready'
|
||||
return gpu.value.label
|
||||
})
|
||||
const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value})
|
||||
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
|
||||
const inputStill=computed(()=>stills.value.find((s:any)=>s.id===form.imageAId))
|
||||
const family=computed(()=>folderAssets(library.value,jobs.value,form.folderId))
|
||||
const sourceAsset=computed(()=>form.startClipId?clips.value.find((c:any)=>c.id===form.startClipId):stills.value.find((s:any)=>s.id===(form.imageAId||form.identityStillId)))
|
||||
const sourceSize=computed(()=>measured[sourceAsset.value?.id] || sourceAsset.value)
|
||||
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value))
|
||||
const resolvedSize=computed(()=>resolveSize(form.settings.aspect,sourceSize.value,undefined,form.engine))
|
||||
const negativeChips=computed(()=>form.imageStyles.negative || [])
|
||||
const queueCards=computed(()=>{
|
||||
const cards:any[]=[]
|
||||
for(const job of benchJobs.value){
|
||||
const action=job.request?.promptSections?.action || 'Job'
|
||||
const eng=job.request?.engine==='ltx'?'LTX':job.request?.engine==='krea'?'Krea 2':job.request?.engine==='qwen21'?'Qwen 2.1':job.request?.engine==='minimax'?'MiniMax H3':'Klein'
|
||||
const mode=job.request?.mode || 'generate'
|
||||
const modeLabel=mode.charAt(0).toUpperCase()+mode.slice(1)
|
||||
const aspect=job.request?.settings?.aspect || 'auto'
|
||||
const state=job.state==='waiting'?'queued':job.state
|
||||
cards.push({
|
||||
id:job.id,
|
||||
job,
|
||||
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
|
||||
title:action.slice(0,72),
|
||||
meta:`${modeLabel} · ${eng} · ${aspect}`,
|
||||
state,
|
||||
statusLabel:state==='queued'?'Queued':state==='rendering'||state==='submitting'||state==='waking'||state==='saving'?'Running':state==='complete'?'Ready':state,
|
||||
cancelable:!['complete','failed','cancelled'].includes(job.state)
|
||||
})
|
||||
}
|
||||
for(const job of musicJobs.value){
|
||||
cards.push({
|
||||
id:job.id,
|
||||
job,
|
||||
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
|
||||
title:job.name || 'YuE2',
|
||||
meta:`Music · YuE2 · ${job.duration || '—'}s`,
|
||||
state:job.live?.status || job.status,
|
||||
statusLabel:job.live?.status || job.status,
|
||||
cancelable:!['complete','done','error','cancelled'].includes(job.status)
|
||||
})
|
||||
}
|
||||
for(const job of upscales.value.filter((j:any)=>j.folderId===form.folderId && ['queued','running'].includes(j.status))){
|
||||
cards.push({
|
||||
id:job.id,
|
||||
job,
|
||||
code:'#'+String(job.id).replace(/\D/g,'').slice(-3).padStart(3,'0'),
|
||||
title:job.name || 'Video upscale',
|
||||
meta:'Upscale',
|
||||
state:job.status,
|
||||
statusLabel:job.status==='running'?'Running':'Queued',
|
||||
cancelable:false
|
||||
})
|
||||
}
|
||||
return cards
|
||||
})
|
||||
const queueCount=computed(()=>queueCards.value.length)
|
||||
const canClearFinished=computed(()=>folderJobs.value.some(j=>['complete','failed','cancelled'].includes(j.state)) || musicJobs.value.some(j=>['complete','done','error','cancelled'].includes(j.status)))
|
||||
const transportLabel=computed(()=>{
|
||||
if(selected.value?.kind==='clip') return `${formatTime(videoTime.value)} / ${formatTime(videoDuration.value)}`
|
||||
if(selected.value?.kind==='track') return 'Audio'
|
||||
if(selected.value) return 'Still'
|
||||
return '—'
|
||||
})
|
||||
|
||||
function formatTime(sec:number){
|
||||
const s=Math.max(0,Number(sec)||0)
|
||||
const m=Math.floor(s/60)
|
||||
const r=s-m*60
|
||||
const whole=Math.floor(r)
|
||||
const frac=Math.floor((r-whole)*100)
|
||||
return `${String(m).padStart(2,'0')}:${String(whole).padStart(2,'0')}.${String(frac).padStart(2,'0')}`
|
||||
}
|
||||
function onVideoTime(){
|
||||
const el=viewerVideo.value
|
||||
if(!el)return
|
||||
videoTime.value=el.currentTime||0
|
||||
videoDuration.value=el.duration||0
|
||||
}
|
||||
function toggleFullscreen(){
|
||||
const el=viewerVideo.value || document.querySelector('.viewer-frame')
|
||||
if(el && 'requestFullscreen' in el) void (el as HTMLElement).requestFullscreen()
|
||||
}
|
||||
function removeNegative(chip:string){form.imageStyles={...form.imageStyles,negative:(form.imageStyles.negative||[]).filter((c:string)=>c!==chip)}}
|
||||
function focusQueueCard(card:any){
|
||||
focusedJobId.value=card.id
|
||||
if(card.job?.outputs?.length)focusJob(card.job)
|
||||
}
|
||||
function isHidden(asset:any){
|
||||
if(!xaigen || !asset?.id)return false
|
||||
if(isClassic.value){
|
||||
@@ -377,6 +509,7 @@ function loadRevealed(folderId:string){
|
||||
}
|
||||
|
||||
watch(()=>sourceAsset.value?.id,id=>{const asset=sourceAsset.value;if(!import.meta.client||!asset||measured[id])return;const media=asset.id===form.startClipId?document.createElement('video'):new Image();if(media.tagName==='VIDEO')(media as HTMLVideoElement).preload='metadata';const done=()=>{measured[asset.id]={width:(media as HTMLVideoElement).videoWidth||(media as HTMLImageElement).naturalWidth,height:(media as HTMLVideoElement).videoHeight||(media as HTMLImageElement).naturalHeight}};media.addEventListener(media.tagName==='VIDEO'?'loadedmetadata':'load',done,{once:true});media.src=media.tagName==='VIDEO'?`/api/library/clips/${asset.id}/video`:stillUrl(asset.id)})
|
||||
watch(()=>selected.value?.id,()=>{videoTime.value=0;videoDuration.value=0})
|
||||
|
||||
function chooseTask(task:string){
|
||||
if(task==='music'){form.mode='music';return}
|
||||
@@ -391,12 +524,13 @@ function chooseTask(task:string){
|
||||
return
|
||||
}
|
||||
const was=video.value;form.mode=task;form.startClipId='';if(video.value!==was){form.engine=video.value?(xaigen && form.engine==='ltx'?'ltx':'minimax'):'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4}
|
||||
if(selected.value?.kind==='track' && task!=='music') selected.value=family.value.find((a:any)=>a.kind!=='track') || null
|
||||
}
|
||||
function onTaskSelect(event:Event){chooseTask((event.target as HTMLSelectElement).value)}
|
||||
function chooseClip(id:string){if(!id){form.startClipId='';return}const clip=clips.value.find((c:any)=>c.id===id);if(clip){try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}catch(e:any){error.value=e?.message||'Could not copy clip settings.';form.startClipId=clip.id;form.mode='extend';form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'}}}
|
||||
function selectOutput(item:any){selected.value=item;if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)}
|
||||
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
|
||||
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null)}
|
||||
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null);focusedJobId.value=job.id}
|
||||
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
|
||||
function pickAsset(asset:any){const key=picker.value;if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||'';else if(key==='startClipId')chooseClip(asset?.id||'');else form[key]=asset?.id||'';picker.value=''}
|
||||
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()}
|
||||
@@ -454,8 +588,12 @@ async function refresh(){
|
||||
for(const job of nextJobs)for(const output of job.outputs||[])seenOutputs.add(output.id)
|
||||
const existingFresh=family.value.find(a=>a.id===fresh?.id)
|
||||
const newestTrack=(library.value.tracks||[]).filter((t:any)=>t.folderId===form.folderId).sort((a:any,b:any)=>(b.createdAt||0)-(a.createdAt||0))[0]
|
||||
if(newestTrack && !seenOutputs.has(newestTrack.id)){seenOutputs.add(newestTrack.id);selected.value={...newestTrack,kind:'track'};return}
|
||||
selected.value=existingFresh || reconcileSelection(selected.value,family.value)
|
||||
// Only auto-select new tracks on Music — never leave a waveform mounted over Generate/Klein/Video.
|
||||
if(form.mode==='music' && newestTrack && !seenOutputs.has(newestTrack.id)){seenOutputs.add(newestTrack.id);selected.value={...newestTrack,kind:'track'};return}
|
||||
if(newestTrack) seenOutputs.add(newestTrack.id)
|
||||
let next=existingFresh || reconcileSelection(selected.value,family.value)
|
||||
if(form.mode!=='music' && next?.kind==='track') next=family.value.find((a:any)=>a.kind!=='track') || null
|
||||
selected.value=next
|
||||
}
|
||||
|
||||
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.startClipId='';loadRevealed(id);if(import.meta.client)form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
|
||||
@@ -473,4 +611,55 @@ onMounted(async()=>{
|
||||
const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()
|
||||
})
|
||||
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
|
||||
|
||||
function randomizeSeed(){form.settings.seedMode='random';form.settings.seed=randomSeed()}
|
||||
function setPicker(key:string){picker.value=key}
|
||||
|
||||
const mobileCtx=computed(()=>({
|
||||
instanceName,
|
||||
xaigen,
|
||||
form,
|
||||
music,
|
||||
library:library.value,
|
||||
stills:stills.value,
|
||||
tasks,
|
||||
engineOptions:engineOptions.value,
|
||||
gpu:gpu.value,
|
||||
gpuChipLabel:gpuChipLabel.value,
|
||||
queueCount:queueCount.value,
|
||||
queueCards:queueCards.value,
|
||||
focusedJobId:focusedJobId.value,
|
||||
canClearFinished:canClearFinished.value,
|
||||
selected:selected.value,
|
||||
family:family.value,
|
||||
sourceSlot:sourceSlot.value,
|
||||
video:video.value,
|
||||
submitting:submitting.value,
|
||||
uploading:uploading.value,
|
||||
uploadMessage:uploadMessage.value,
|
||||
error:error.value,
|
||||
batchText:batchText.value,
|
||||
negativeChips:negativeChips.value,
|
||||
pickerLoras:pickerLoras.value,
|
||||
currentLoraUse:currentLoraUse.value,
|
||||
loraAliases:{...loraNames.value.aliases,...library.value.loraAliases},
|
||||
loraTriggers:{...loraNames.value.triggers,...library.value.loraTriggers},
|
||||
aspectOptions,
|
||||
stillUrl,
|
||||
isHidden,
|
||||
slotHidden,
|
||||
toggleReveal,
|
||||
chooseTask,
|
||||
selectOutput,
|
||||
deleteOutput,
|
||||
queue,
|
||||
upload,
|
||||
cancel,
|
||||
focusQueueCard,
|
||||
clearFinished,
|
||||
removeNegative,
|
||||
randomizeSeed,
|
||||
setPicker,
|
||||
setBatchText:(v:string)=>{batchText.value=v},
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<div class="m-shell" data-mobile="1">
|
||||
<header class="m-header">
|
||||
<span class="m-brand">{{ ctx.instanceName }}</span>
|
||||
<label class="m-crumb">
|
||||
<select v-model="ctx.form.folderId" aria-label="Folder">
|
||||
<option v-for="f in ctx.library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="m-grow" />
|
||||
<span class="m-gpu" :data-tone="ctx.gpu.tone" :title="ctx.gpu.detail">● {{ ctx.gpuChipLabel }}</span>
|
||||
<button type="button" class="m-chip" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
|
||||
<details class="m-more">
|
||||
<summary aria-label="More">⋯</summary>
|
||||
<div class="m-more-menu">
|
||||
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
|
||||
<NuxtLink to="/queue" @click="closeMenus">Queue</NuxtLink>
|
||||
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
|
||||
<div class="m-chips" role="tablist" aria-label="Tasks">
|
||||
<button
|
||||
v-for="task in ctx.tasks"
|
||||
:key="task"
|
||||
type="button"
|
||||
class="m-chip capitalize"
|
||||
:class="{ on: ctx.form.mode===task }"
|
||||
:aria-pressed="ctx.form.mode===task"
|
||||
@click="ctx.chooseTask(task)"
|
||||
>{{ task }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ctx.form.mode!=='music'" class="m-chips" role="group" aria-label="Model">
|
||||
<button
|
||||
v-for="opt in ctx.engineOptions"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="m-chip"
|
||||
:class="{ on: ctx.form.engine===opt.value }"
|
||||
:disabled="opt.disabled"
|
||||
:title="opt.hint || undefined"
|
||||
:aria-pressed="ctx.form.engine===opt.value"
|
||||
@click="!opt.disabled && (ctx.form.engine=opt.value)"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
<div v-else class="m-chips"><span class="m-chip on">YuE2</span></div>
|
||||
|
||||
<div class="m-stage">
|
||||
<button
|
||||
v-if="ctx.selected && ctx.xaigen"
|
||||
type="button"
|
||||
class="m-eye"
|
||||
:aria-label="ctx.isHidden(ctx.selected) ? 'Show preview' : 'Hide preview'"
|
||||
@click.stop="ctx.toggleReveal(ctx.selected.id)"
|
||||
>
|
||||
<img :src="ctx.isHidden(ctx.selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
|
||||
</button>
|
||||
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
|
||||
<p class="m-muted">{{ ctx.selected.kind==='track' ? 'Track loaded · preview hidden' : 'Preview hidden' }}</p>
|
||||
</template>
|
||||
<template v-else-if="ctx.selected?.kind==='track'">
|
||||
<div class="m-audio">
|
||||
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
|
||||
</div>
|
||||
</template>
|
||||
<video
|
||||
v-else-if="ctx.selected?.kind==='clip'"
|
||||
:key="ctx.selected.id"
|
||||
:src="`/api/library/clips/${ctx.selected.id}/video`"
|
||||
controls
|
||||
playsinline
|
||||
/>
|
||||
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still">
|
||||
<p v-else class="m-muted">Select a still or clip</p>
|
||||
</div>
|
||||
|
||||
<div class="m-strip" aria-label="Family outputs">
|
||||
<div
|
||||
v-for="o in ctx.family"
|
||||
:key="o.id"
|
||||
class="m-tile"
|
||||
:class="{ on: ctx.selected?.id===o.id }"
|
||||
>
|
||||
<button type="button" class="m-tile-pick" @click="ctx.selectOutput(o)">
|
||||
<span v-if="ctx.isHidden(o)" class="m-tile-hid">Hidden</span>
|
||||
<span v-else-if="o.kind==='track'" class="m-tile-hid">Audio</span>
|
||||
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : ctx.stillUrl(o.id)" alt="">
|
||||
</button>
|
||||
<button
|
||||
v-if="ctx.xaigen"
|
||||
type="button"
|
||||
class="m-tile-eye"
|
||||
:aria-label="ctx.isHidden(o) ? 'Show tile' : 'Hide tile'"
|
||||
@click.stop="ctx.toggleReveal(o.id)"
|
||||
>
|
||||
<img :src="ctx.isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
|
||||
</button>
|
||||
<button
|
||||
v-if="ctx.selected?.id===o.id"
|
||||
type="button"
|
||||
class="m-tile-trash"
|
||||
aria-label="Delete selected"
|
||||
@click.stop="ctx.deleteOutput"
|
||||
>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-dock">
|
||||
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Slots</button>
|
||||
<button type="button" :class="{ on: sheet==='prompt' }" @click="toggleSheet('prompt')">Prompt</button>
|
||||
<button type="button" :class="{ on: sheet==='look' }" @click="toggleSheet('look')">Look</button>
|
||||
<button type="button" :class="{ on: sheet==='gen' }" @click="toggleSheet('gen')">Gen</button>
|
||||
<button type="button" class="m-cta" :disabled="ctx.submitting" @click="ctx.queue">{{ ctx.submitting ? '…' : 'Generate' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Sheets -->
|
||||
<div v-if="sheet" class="m-sheet-backdrop" @click="sheet=''" />
|
||||
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Slots">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Slots</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<template v-if="ctx.form.mode==='music'">
|
||||
<p class="m-muted">Music uses tags + lyrics in Prompt / Gen — no hero slot.</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Studio2AssetSlot size="hero" label="Hero still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.identityStillId)" :hidden="ctx.slotHidden(ctx.form.identityStillId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker('identityStillId')" @upload="ctx.upload($event,'hero')" />
|
||||
<Studio2AssetSlot :label="ctx.form.mode==='extend'?'Previous clip':'Start still'" :asset="ctx.sourceSlot" :hidden="ctx.slotHidden(ctx.form.mode==='extend'?ctx.form.startClipId:ctx.form.imageAId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker(ctx.form.mode==='extend'?'startClipId':'imageAId')" @upload="ctx.upload($event,'source')" />
|
||||
<Studio2AssetSlot v-if="ctx.form.mode==='compose'" label="Second still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.imageBId)" :hidden="ctx.slotHidden(ctx.form.imageBId)" @pick="ctx.setPicker('imageBId')" />
|
||||
<Studio2AssetSlot v-if="ctx.form.mode==='refine'" label="Mask" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.maskId)" :hidden="ctx.slotHidden(ctx.form.maskId)" @pick="ctx.setPicker('maskId')" />
|
||||
<Studio2AssetSlot v-if="ctx.video" label="End still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.endStillId)" :hidden="ctx.slotHidden(ctx.form.endStillId)" @pick="ctx.setPicker('endStillId')" />
|
||||
<label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label>
|
||||
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label>
|
||||
<p class="m-hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
||||
<p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-if="sheet==='prompt'" class="m-sheet" role="dialog" aria-label="Prompt">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Prompt</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<template v-if="ctx.form.mode==='music'">
|
||||
<label class="m-label">Genre / style tags<textarea v-model="ctx.music.tags" rows="3" class="m-area" /></label>
|
||||
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label>
|
||||
<div>
|
||||
<span class="m-label">Negative prompt</span>
|
||||
<div class="m-neg">
|
||||
<span v-for="chip in ctx.negativeChips" :key="chip" class="m-neg-chip">
|
||||
{{ chip }}
|
||||
<button type="button" :aria-label="'Remove '+chip" @click="ctx.removeNegative(chip)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Studio2StyleChips v-if="!ctx.video" v-model="ctx.form.imageStyles" />
|
||||
</template>
|
||||
<p v-if="ctx.error" class="m-err">{{ ctx.error }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-if="sheet==='look'" class="m-sheet" role="dialog" aria-label="Look">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Look</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<template v-if="ctx.form.mode==='music'">
|
||||
<label class="m-label">Seed<input v-model="ctx.music.seed" class="m-input" placeholder="random"></label>
|
||||
<label class="m-label">Track name<input v-model="ctx.music.name" class="m-input" placeholder="Optional"></label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<span class="m-label">LoRA</span>
|
||||
<LoraSelect v-model="ctx.form.settings.loraStack" compact :names="ctx.pickerLoras" :use="ctx.currentLoraUse" :clip-strength="!ctx.video" filename-subtitle :aliases="ctx.loraAliases" :triggers="ctx.loraTriggers" missing-label="None" @insert-trigger="ctx.form.promptSections.action += ' '+$event" />
|
||||
</div>
|
||||
<label class="m-label">Seed
|
||||
<div class="m-seed">
|
||||
<input
|
||||
:value="ctx.form.settings.seed ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="2147483647"
|
||||
class="m-input"
|
||||
@input="ctx.form.settings.seed=Number(($event.target as HTMLInputElement).value);ctx.form.settings.seedMode='locked'"
|
||||
>
|
||||
<button type="button" class="m-dice" aria-label="Random seed" @click="ctx.randomizeSeed">🎲</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="m-label">Aspect
|
||||
<select v-model="ctx.form.settings.aspect" class="m-input">
|
||||
<option v-for="opt in ctx.aspectOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="m-label">Steps<input v-model.number="ctx.form.settings.steps" type="number" class="m-input"></label>
|
||||
<label class="m-label">CFG<input v-model.number="ctx.form.settings.cfg" type="number" class="m-input"></label>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-if="sheet==='gen'" class="m-sheet" role="dialog" aria-label="Gen extras">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Gen</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<template v-if="ctx.form.mode==='music'">
|
||||
<label class="m-label">Target length (sec)<input v-model.number="ctx.music.duration" type="number" min="15" max="150" class="m-input"></label>
|
||||
<p class="m-hint">Caps semantic tokens (~25/s). Actual length follows the model end token.</p>
|
||||
</template>
|
||||
<template v-else-if="ctx.video">
|
||||
<label class="m-label">Duration (sec)<input v-model.number="ctx.form.settings.duration" type="number" class="m-input"></label>
|
||||
<label class="m-label">FPS<input v-model.number="ctx.form.settings.fps" type="number" class="m-input"></label>
|
||||
<label class="m-check"><input v-model="ctx.form.settings.turbo" type="checkbox"> Turbo</label>
|
||||
<Studio2PromptControls v-model="ctx.form.promptSections" />
|
||||
<details class="m-details">
|
||||
<summary>Queue more shots</summary>
|
||||
<textarea :value="ctx.batchText" rows="4" class="m-area" placeholder="First additional prompt Next" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
|
||||
</details>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="m-muted">Image extras live in Look (steps / CFG / seed / aspect).</p>
|
||||
<details v-if="ctx.form.mode==='edit'" class="m-details">
|
||||
<summary>Queue extra edit passes</summary>
|
||||
<textarea :value="ctx.batchText" rows="4" class="m-area" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-if="sheet==='queue'" class="m-sheet" role="dialog" aria-label="Queue">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Queue</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<div
|
||||
v-for="card in ctx.queueCards"
|
||||
:key="card.id"
|
||||
class="m-job"
|
||||
:class="{ on: ctx.focusedJobId===card.id }"
|
||||
@click="ctx.focusQueueCard(card)"
|
||||
>
|
||||
<div class="m-job-id">{{ card.code }}</div>
|
||||
<div class="m-job-title">{{ card.title }}</div>
|
||||
<div class="m-muted">{{ card.meta }}</div>
|
||||
<div class="m-job-status">
|
||||
<span>● {{ card.statusLabel }}</span>
|
||||
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
|
||||
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export type Studio2MobileCtx = Record<string, any>
|
||||
|
||||
const props = defineProps<{ ctx: Studio2MobileCtx }>()
|
||||
const sheet = ref('')
|
||||
|
||||
function toggleSheet(id: string) {
|
||||
sheet.value = sheet.value === id ? '' : id
|
||||
}
|
||||
function closeMenus() {
|
||||
sheet.value = ''
|
||||
}
|
||||
watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.m-shell {
|
||||
--bg: #0b0b0d;
|
||||
--panel: #141416;
|
||||
--line: #2a2a2e;
|
||||
--amber: #f5a524;
|
||||
--amber-on: #1a1204;
|
||||
--text: #f3f1ea;
|
||||
--muted: #9a9588;
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.m-header {
|
||||
display: flex;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 12px;
|
||||
}
|
||||
.m-brand { font-weight: 700; }
|
||||
.m-crumb select {
|
||||
max-width: 7rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-grow { flex: 1; }
|
||||
.m-gpu {
|
||||
border: 1px solid #2d4a2a;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
color: #b8e0a8;
|
||||
font-size: 11px;
|
||||
}
|
||||
.m-gpu[data-tone='busy'] { border-color: color-mix(in srgb, var(--amber) 40%, transparent); color: var(--amber); }
|
||||
.m-chips {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.m-chip {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #1a1a1d;
|
||||
padding: 6px 10px;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
}
|
||||
.m-chip.on, .m-dock button.on {
|
||||
border-color: var(--amber);
|
||||
background: var(--amber);
|
||||
color: var(--amber-on);
|
||||
font-weight: 700;
|
||||
}
|
||||
.m-chip:disabled { opacity: 0.45; }
|
||||
.m-more { position: relative; }
|
||||
.m-more summary { list-style: none; cursor: pointer; padding: 4px 8px; color: var(--muted); }
|
||||
.m-more-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
min-width: 8rem;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
padding: 8px;
|
||||
}
|
||||
.m-more-menu a { color: var(--text); text-decoration: none; padding: 6px 8px; }
|
||||
.m-stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #080808;
|
||||
}
|
||||
.m-stage > img,
|
||||
.m-stage > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
|
||||
.m-eye {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #000a;
|
||||
}
|
||||
.m-strip {
|
||||
display: flex;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.m-tile {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #222;
|
||||
}
|
||||
.m-tile.on { border-color: var(--amber); }
|
||||
.m-tile-pick { display: block; width: 100%; height: 100%; border: 0; padding: 0; background: transparent; }
|
||||
.m-tile img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.m-tile-hid { display: grid; height: 100%; place-items: center; color: var(--muted); font-size: 10px; }
|
||||
.m-tile-eye, .m-tile-trash {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
display: grid;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: #000a;
|
||||
font-size: 10px;
|
||||
}
|
||||
.m-tile-trash { right: 20px; }
|
||||
.m-dock {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr) 120px;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.m-dock button {
|
||||
height: 48px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #1a1a1d;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
.m-cta {
|
||||
background: var(--amber) !important;
|
||||
color: var(--amber-on) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
.m-cta:disabled { opacity: 0.5; }
|
||||
.m-sheet-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
background: #0008;
|
||||
}
|
||||
.m-sheet {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 31;
|
||||
display: flex;
|
||||
max-height: min(85vh, 100%);
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: var(--panel);
|
||||
padding: 10px 14px 20px;
|
||||
}
|
||||
.m-handle {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
margin: 4px auto 12px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #444;
|
||||
}
|
||||
.m-sheet-head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.m-sheet-head button { border: 0; background: transparent; color: var(--amber); }
|
||||
.m-sheet-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.m-label { display: block; font-size: 11px; color: var(--muted); }
|
||||
.m-area, .m-input {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #1a1a1d;
|
||||
padding: 8px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
.m-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
|
||||
.m-hint, .m-muted { color: var(--muted); font-size: 12px; }
|
||||
.m-amber { color: var(--amber); font-size: 12px; }
|
||||
.m-err { color: #fca5a5; font-size: 12px; }
|
||||
.m-neg { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||
.m-neg-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid #5a4a28;
|
||||
border-radius: 999px;
|
||||
background: #2a2418;
|
||||
padding: 4px 10px;
|
||||
color: #e8c37a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-neg-chip button { border: 0; background: transparent; color: inherit; }
|
||||
.m-seed { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.m-seed .m-input { margin-top: 0; flex: 1; }
|
||||
.m-dice {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #1a1a1d;
|
||||
}
|
||||
.m-details { border: 1px solid var(--line); border-radius: 8px; padding: 8px; }
|
||||
.m-job {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m-job.on { border-color: var(--amber); border-width: 2px; }
|
||||
.m-job-id { color: var(--muted); font-size: 12px; }
|
||||
.m-job-title { font-weight: 600; }
|
||||
.m-job-status { display: flex; justify-content: space-between; margin-top: 6px; font-size: 12px; }
|
||||
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
|
||||
</style>
|
||||
@@ -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,42 @@
|
||||
# Qwen Image 2.1 (engine `qwen21`) — host weights + Comfy graph
|
||||
|
||||
## Host paths (this RTX 5080 box)
|
||||
|
||||
| Role | Resolved path |
|
||||
|---|---|
|
||||
| ComfyUI root (Klein / host agent `ComfyUI (1)`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI` |
|
||||
| Models root (Desktop Shared via `shared_model_paths.yaml`) | `C:\Users\ianjm\AppData\Local\Comfy-Desktop\ComfyUI-Shared\models` |
|
||||
|
||||
Weights must land under Shared so every Desktop instance sees them:
|
||||
|
||||
- `diffusion_models\qwen-image-2.1-Q8_0.gguf` (~7.59 GiB) — from `abenzerps/Qwen-Image-2.1-Uncensored-GGUF`
|
||||
- `text_encoders\qwen3vl_8b_int8_convrot.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (INT8; BF16 8B VL OOMs on 16 GB)
|
||||
- `vae\qwen_image_2.1_vae_bf16.safetensors` — from `Comfy-Org/Qwen-Image-2.1` (**not** the old Qwen-Image 1.0 VAE)
|
||||
|
||||
Custom node: `custom_nodes\ComfyUI-GGUF` (`UnetLoaderGGUF`). Do not install a second GGUF pack.
|
||||
|
||||
## Setup
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||
```
|
||||
|
||||
DiT download uses the exact host invocation:
|
||||
|
||||
```powershell
|
||||
hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||
```
|
||||
|
||||
That lands in the Hugging Face hub cache; the setup script copies it into Shared `diffusion_models`. The abenzerps GGUF ships with `kv_count=0` (no `general.architecture`) **and** Q8_0-quantized 1D RMSNorm weights (logical 128 → packed 136), which breaks Comfy’s fused `rms_rope`. `scripts/tag-qwen21-gguf.py` rewrites the file with `general.architecture=qwen_image` and promotes small/1D tensors to F32 so city96 `UnetLoaderGGUF` can load it.
|
||||
|
||||
Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false`). Confirm `object_info` lists `UnetLoaderGGUF` and the three filenames.
|
||||
|
||||
## App
|
||||
|
||||
- Engine key: `qwen21` · UI label: **Qwen 2.1**
|
||||
- Generate (T2I) only in this build. Edit / Compose / Iterate / Video disable with: “Qwen 2.1 is T2I in this build”.
|
||||
- Sampler defaults: euler / simple / cfg **1** / steps **25** · `ModelSamplingAuraFlow` shift **3.1**
|
||||
- Default canvas **1024×1024** (aspect 16:9 / 9:16 / 1:1 → long-edge square via `TextEncodeQwenImage21` resolution; multiples of 32). Drop to 768 if VRAM errors.
|
||||
- No hero / locks / Klein LoRA stack on this engine.
|
||||
|
||||
Graph: `server/assets/studio2_qwen21_t2i.json`.
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f3f1ea" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 3l18 18"/>
|
||||
<path d="M10.6 10.6A3 3 0 0 0 12 15a3 3 0 0 0 2.4-4.4"/>
|
||||
<path d="M9.9 5.1A11 11 0 0 1 12 5c6.5 0 10 7 10 7a18 18 0 0 1-3.2 3.8"/>
|
||||
<path d="M6.1 6.1C3.7 7.8 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.2-.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 441 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f3f1ea" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||
<path d="M8 11V8a4 4 0 0 1 8 0v3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1,225 @@
|
||||
# Download Qwen Image 2.1 GGUF + companions into the live Comfy Desktop Shared models tree.
|
||||
# Also ensures city96/ComfyUI-GGUF is present on the ComfyUI (1) install.
|
||||
#
|
||||
# Example:
|
||||
# powershell -ExecutionPolicy Bypass -File scripts\setup-qwen21.ps1
|
||||
# powershell -File scripts\setup-qwen21.ps1 -SkipDownload
|
||||
# powershell -File scripts\setup-qwen21.ps1 -ComfyRoot "C:\...\ComfyUI (1)\ComfyUI"
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ComfyRoot = '',
|
||||
[string]$ModelsRoot = '',
|
||||
[switch]$SkipDownload,
|
||||
[switch]$SkipNode,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'Continue'
|
||||
|
||||
function Write-Step([string]$Message) {
|
||||
Write-Host ""
|
||||
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Write-Ok([string]$Message) {
|
||||
Write-Host " $Message" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Write-Warn([string]$Message) {
|
||||
Write-Host " warn $Message" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
function Resolve-ComfyRoot([string]$Hint) {
|
||||
$candidates = @(
|
||||
$Hint,
|
||||
$env:COMFY_ROOT,
|
||||
$env:COMFYUI_ROOT,
|
||||
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI (1)\ComfyUI'),
|
||||
(Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Installs\ComfyUI\ComfyUI'),
|
||||
'C:\ComfyUI',
|
||||
(Join-Path $env:USERPROFILE 'ComfyUI')
|
||||
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||
|
||||
foreach ($path in $candidates) {
|
||||
if (Test-Path (Join-Path $path 'main.py')) { return (Resolve-Path $path).Path }
|
||||
if (Test-Path (Join-Path $path 'ComfyUI\main.py')) { return (Resolve-Path (Join-Path $path 'ComfyUI')).Path }
|
||||
}
|
||||
throw "Could not find ComfyUI root (main.py). Pass -ComfyRoot."
|
||||
}
|
||||
|
||||
function Resolve-ModelsRoot([string]$Hint, [string]$ComfyRootPath) {
|
||||
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
|
||||
$candidates = @(
|
||||
$Hint,
|
||||
$env:COMFY_MODELS_ROOT,
|
||||
$shared,
|
||||
(Join-Path $ComfyRootPath 'models')
|
||||
) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\', '/') }
|
||||
|
||||
foreach ($path in $candidates) {
|
||||
if (Test-Path $path) { return (Resolve-Path $path).Path }
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $shared | Out-Null
|
||||
return (Resolve-Path $shared).Path
|
||||
}
|
||||
|
||||
function Find-HfCli {
|
||||
foreach ($name in @('hf', 'huggingface-cli')) {
|
||||
$cmd = Get-Command $name -ErrorAction SilentlyContinue
|
||||
if ($cmd) { return $cmd.Source }
|
||||
}
|
||||
throw "Hugging Face CLI not found (hf / huggingface-cli). Install with: pip install -U huggingface_hub"
|
||||
}
|
||||
|
||||
function Ensure-Dir([string]$Path) {
|
||||
if (-not (Test-Path $Path)) {
|
||||
New-Item -ItemType Directory -Force -Path $Path | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ModelFile([string]$Path, [long]$MinBytes) {
|
||||
if (-not (Test-Path $Path)) { throw "Missing model file: $Path" }
|
||||
$item = Get-Item $Path
|
||||
if ($item.Length -lt $MinBytes) {
|
||||
throw "Suspicious size for $Path ($($item.Length) bytes). Expected >= $MinBytes. Delete and re-run."
|
||||
}
|
||||
Write-Ok ("{0} {1:N0} bytes" -f $item.FullName, $item.Length)
|
||||
}
|
||||
|
||||
function Flatten-IfNested([string]$DestDir, [string]$FileName) {
|
||||
$nested = Get-ChildItem $DestDir -Recurse -File -Filter $FileName -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DirectoryName -ne $DestDir } |
|
||||
Select-Object -First 1
|
||||
if ($nested) {
|
||||
$target = Join-Path $DestDir $FileName
|
||||
if ((Test-Path $target) -and -not $Force) {
|
||||
Write-Warn "Nested copy found but destination already exists: $target"
|
||||
return
|
||||
}
|
||||
Move-Item -Force $nested.FullName $target
|
||||
Write-Ok "Flattened $($nested.FullName) -> $target"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-HfFile {
|
||||
param(
|
||||
[string]$Cli,
|
||||
[string]$Repo,
|
||||
[string]$File,
|
||||
[string]$LocalDir
|
||||
)
|
||||
Ensure-Dir $LocalDir
|
||||
$dest = Join-Path $LocalDir (Split-Path $File -Leaf)
|
||||
if ((Test-Path $dest) -and -not $Force) {
|
||||
$len = (Get-Item $dest).Length
|
||||
if ($len -gt 1MB) {
|
||||
Write-Ok "Already present: $dest ($len bytes)"
|
||||
return
|
||||
}
|
||||
}
|
||||
Write-Step "hf download $Repo $File"
|
||||
& $Cli download $Repo $File --local-dir $LocalDir
|
||||
if ($LASTEXITCODE -ne 0) { throw "hf download failed for $Repo / $File (exit $LASTEXITCODE)" }
|
||||
Flatten-IfNested -DestDir $LocalDir -FileName (Split-Path $File -Leaf)
|
||||
}
|
||||
|
||||
# --- resolve paths ---
|
||||
Write-Step "Resolve ComfyUI root"
|
||||
$root = Resolve-ComfyRoot $ComfyRoot
|
||||
Write-Ok $root
|
||||
|
||||
Write-Step "Resolve models root (Desktop Shared preferred)"
|
||||
$models = Resolve-ModelsRoot $ModelsRoot $root
|
||||
Write-Ok $models
|
||||
|
||||
$Diff = Join-Path $models 'diffusion_models'
|
||||
$TE = Join-Path $models 'text_encoders'
|
||||
$VAE = Join-Path $models 'vae'
|
||||
Ensure-Dir $Diff
|
||||
Ensure-Dir $TE
|
||||
Ensure-Dir $VAE
|
||||
|
||||
if (-not $SkipDownload) {
|
||||
$cli = Find-HfCli
|
||||
Write-Ok "HF CLI: $cli"
|
||||
|
||||
# DiT GGUF only (no Q6/Q5/Q4, no full bf16 DiT)
|
||||
# Exact invocation required on this host:
|
||||
# hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf
|
||||
# That form writes into the Hugging Face hub cache (not --local-dir).
|
||||
Write-Step "hf download hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/qwen-image-2.1-Q8_0.gguf"
|
||||
$ditName = 'qwen-image-2.1-Q8_0.gguf'
|
||||
$ditDest = Join-Path $Diff $ditName
|
||||
if ((Test-Path $ditDest) -and -not $Force -and (Get-Item $ditDest).Length -gt 1MB) {
|
||||
Write-Ok "Already present: $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||
} else {
|
||||
& $cli download "hf://abenzerps/Qwen-Image-2.1-Uncensored-GGUF/$ditName"
|
||||
if ($LASTEXITCODE -ne 0) { throw "hf download failed for DiT GGUF (exit $LASTEXITCODE)" }
|
||||
$cacheRoot = Join-Path $env:USERPROFILE '.cache\huggingface\hub\models--abenzerps--Qwen-Image-2.1-Uncensored-GGUF'
|
||||
$found = Get-ChildItem $cacheRoot -Recurse -File -Filter $ditName -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $found) { throw "hf download finished but $ditName was not found under $cacheRoot" }
|
||||
Ensure-Dir $Diff
|
||||
Copy-Item -Force $found.FullName $ditDest
|
||||
Write-Ok "Copied $($found.FullName) -> $ditDest ($((Get-Item $ditDest).Length) bytes)"
|
||||
}
|
||||
|
||||
# abenzerps ships kv_count=0 + Q8_0 1D RMSNorms (packed 136 vs logical 128).
|
||||
# Tag architecture=qwen_image and promote small/1D tensors to F32 for ComfyUI-GGUF.
|
||||
Write-Step "Tag DiT GGUF (architecture + F32 norms)"
|
||||
$tagger = Join-Path $PSScriptRoot 'tag-qwen21-gguf.py'
|
||||
$pyCandidates = @(
|
||||
(Join-Path $root '.venv\Scripts\python.exe'),
|
||||
(Join-Path (Split-Path $root) 'standalone-env\python.exe'),
|
||||
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)
|
||||
) | Where-Object { $_ -and (Test-Path $_) }
|
||||
$py = $pyCandidates | Select-Object -First 1
|
||||
if (-not $py) { throw "No Python found to run $tagger" }
|
||||
$fixed = Join-Path $Diff 'qwen-image-2.1-Q8_0.fixed.gguf'
|
||||
& $py $tagger $ditDest $fixed
|
||||
if ($LASTEXITCODE -ne 0) { throw "tag-qwen21-gguf.py failed (exit $LASTEXITCODE)" }
|
||||
Copy-Item -Force $fixed $ditDest
|
||||
Write-Ok "Installed tagged GGUF at $ditDest"
|
||||
|
||||
# TE + VAE companions from Comfy-Org (INT8 TE for 16 GB VRAM)
|
||||
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'text_encoders/qwen3vl_8b_int8_convrot.safetensors' -LocalDir $TE
|
||||
Invoke-HfFile -Cli $cli -Repo 'Comfy-Org/Qwen-Image-2.1' -File 'vae/qwen_image_2.1_vae_bf16.safetensors' -LocalDir $VAE
|
||||
}
|
||||
|
||||
Write-Step "Verify weights"
|
||||
Assert-ModelFile (Join-Path $Diff 'qwen-image-2.1-Q8_0.gguf') 7000000000
|
||||
Assert-ModelFile (Join-Path $TE 'qwen3vl_8b_int8_convrot.safetensors') 1000000000
|
||||
Assert-ModelFile (Join-Path $VAE 'qwen_image_2.1_vae_bf16.safetensors') 100000000
|
||||
|
||||
if (-not $SkipNode) {
|
||||
Write-Step "city96 ComfyUI-GGUF"
|
||||
$nodes = Join-Path $root 'custom_nodes\ComfyUI-GGUF'
|
||||
if (-not (Test-Path $nodes)) {
|
||||
git clone https://github.com/city96/ComfyUI-GGUF $nodes
|
||||
if ($LASTEXITCODE -ne 0) { throw "git clone ComfyUI-GGUF failed" }
|
||||
Write-Ok "Cloned $nodes"
|
||||
} else {
|
||||
Write-Ok "Already present: $nodes"
|
||||
}
|
||||
|
||||
$py = Join-Path $root '.venv\Scripts\python.exe'
|
||||
if (-not (Test-Path $py)) {
|
||||
$py = Join-Path (Split-Path $root) 'standalone-env\python.exe'
|
||||
}
|
||||
if (Test-Path $py) {
|
||||
Write-Step "Ensure gguf package in Comfy python ($py)"
|
||||
& $py -m pip install -q gguf
|
||||
if ($LASTEXITCODE -ne 0) { Write-Warn "pip install gguf returned $LASTEXITCODE" }
|
||||
else { Write-Ok "gguf importable" }
|
||||
} else {
|
||||
Write-Warn "Comfy python not found; install gguf into the interpreter that runs main.py"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Done. Restart Comfy only when /status shows gpu.busy=false." -ForegroundColor Cyan
|
||||
Write-Host "ComfyRoot=$root"
|
||||
Write-Host "ModelsRoot=$models"
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Make abenzerps Qwen Image 2.1 DiT GGUF loadable in city96 ComfyUI-GGUF.
|
||||
|
||||
abenzerps/Qwen-Image-2.1-Uncensored-GGUF ships Q8_0 with:
|
||||
- kv_count=0 (no general.architecture)
|
||||
- 1D RMSNorm weights quantized to Q8_0 (logical 128 -> packed 136),
|
||||
which breaks Comfy's fused rms_rope path
|
||||
|
||||
This rewrite:
|
||||
1. Adds general.architecture=qwen_image
|
||||
2. Dequantizes small / 1D tensors to F32 (city96 convert keeps them hiprec)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Match city96 ComfyUI-GGUF/tools/convert.py QUANTIZATION_THRESHOLD
|
||||
QUANTIZATION_THRESHOLD = 1024
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("src", type=Path)
|
||||
ap.add_argument("dst", type=Path, nargs="?", default=None)
|
||||
ap.add_argument("--arch", default="qwen_image")
|
||||
ap.add_argument("--name", default="qwen-image-2.1")
|
||||
ap.add_argument("--inplace", action="store_true", help="Replace src after a successful tag")
|
||||
args = ap.parse_args()
|
||||
|
||||
import gguf
|
||||
import numpy as np
|
||||
|
||||
src = args.src.resolve()
|
||||
if not src.is_file():
|
||||
print(f"missing source: {src}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
dst = (args.dst.resolve() if args.dst else src.with_name(src.stem + ".tagged.gguf"))
|
||||
|
||||
reader = gguf.GGUFReader(str(src))
|
||||
|
||||
def get_field(name: str):
|
||||
field = reader.fields.get(name)
|
||||
if field is None:
|
||||
return None
|
||||
try:
|
||||
return field.contents()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if dst == src:
|
||||
dst = src.with_name(src.stem + ".retag.gguf")
|
||||
|
||||
f32 = gguf.GGMLQuantizationType.F32
|
||||
compat = {gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16, gguf.GGMLQuantizationType.BF16}
|
||||
|
||||
print(f"rewriting {src} -> {dst} (arch={args.arch}, tensors={len(reader.tensors)})")
|
||||
writer = gguf.GGUFWriter(str(dst), arch=args.arch, use_temp_file=True)
|
||||
writer.add_name(args.name)
|
||||
try:
|
||||
writer.add_type("model")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
promoted = 0
|
||||
for tensor in reader.tensors:
|
||||
logical = tuple(int(x) for x in tensor.shape)
|
||||
# GGUF stores dims reversed vs torch; logical numel is what matters
|
||||
numel = 1
|
||||
for d in logical:
|
||||
numel *= d
|
||||
qtype = tensor.tensor_type
|
||||
data = tensor.data
|
||||
if hasattr(data, "copy"):
|
||||
data = data.copy()
|
||||
|
||||
needs_f32 = qtype not in compat and (len(logical) <= 1 or numel <= QUANTIZATION_THRESHOLD)
|
||||
if needs_f32:
|
||||
# Dequantize packed blocks -> float32 with logical shape (GGUF order)
|
||||
dequant = gguf.quants.dequantize(np.asarray(data), qtype).astype(np.float32, copy=False)
|
||||
expected = numel
|
||||
if dequant.size != expected:
|
||||
print(
|
||||
f"dequant size mismatch {tensor.name}: got {dequant.size} want {expected} "
|
||||
f"shape={logical} qtype={qtype}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
data = dequant.reshape(logical)
|
||||
qtype = f32
|
||||
promoted += 1
|
||||
|
||||
writer.add_tensor(tensor.name, data, raw_dtype=qtype)
|
||||
|
||||
writer.write_header_to_file()
|
||||
writer.write_kv_data_to_file()
|
||||
writer.write_tensors_to_file(progress=True)
|
||||
writer.close()
|
||||
|
||||
check = gguf.GGUFReader(str(dst))
|
||||
field = check.fields.get("general.architecture")
|
||||
got = field.contents() if field is not None else None
|
||||
print(
|
||||
f"verified architecture={got!r} tensors={len(check.tensors)} "
|
||||
f"promoted_f32={promoted} size={dst.stat().st_size}"
|
||||
)
|
||||
if got != args.arch or len(check.tensors) != len(reader.tensors):
|
||||
print("tag failed", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Spot-check a known norm weight is F32 @ 128
|
||||
sample = next((t for t in check.tensors if t.name.endswith("attn.norm_q.weight")), None)
|
||||
if sample is not None:
|
||||
print(
|
||||
f"sample {sample.name}: shape={tuple(int(x) for x in sample.shape)} "
|
||||
f"type={sample.tensor_type.name} data_shape={tuple(sample.data.shape)}"
|
||||
)
|
||||
|
||||
if args.inplace:
|
||||
bak = src.with_suffix(src.suffix + ".untagged.bak")
|
||||
if bak.exists():
|
||||
bak.unlink()
|
||||
src.replace(bak)
|
||||
dst.replace(src)
|
||||
print(f"inplace: {src} (backup {bak})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -4,19 +4,21 @@ import { resolveRequestSize } from '../../utils/studio2/size';
|
||||
import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras';
|
||||
import { saveRecord, readRecord } from '../../utils/studio2/store';
|
||||
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '../../utils/studioQueue';
|
||||
import { isXaigenStudio } from '~/utils/videoModels';
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event), raw = await readBody(event);
|
||||
const xaigen = isXaigenStudio();
|
||||
let request: any;
|
||||
try {
|
||||
request = validateRequest(raw, useRuntimeConfig().public.studio === 'xaigen');
|
||||
request = validateRequest(raw, xaigen);
|
||||
}
|
||||
catch (e: any) {
|
||||
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message });
|
||||
}
|
||||
const video = ['video', 'extend'].includes(request.mode);
|
||||
request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
|
||||
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen');
|
||||
if (!video)
|
||||
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, xaigen);
|
||||
if (!video && request.engine !== 'qwen21')
|
||||
assertImageV2LoraStack(request.settings.loraStack, request.engine);
|
||||
assertFolderAccess(event, request.folderId);
|
||||
for (const id of [request.identityStillId, request.imageAId, request.imageBId, request.maskId, request.endStillId, ...request.guides.map((g: any) => g.stillId)].filter(Boolean))
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"4": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen-image-2.1-Q8_0.gguf"
|
||||
},
|
||||
"class_type": "UnetLoaderGGUF",
|
||||
"_meta": { "title": "Load Qwen 2.1 GGUF" }
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"shift": 3.1,
|
||||
"model": ["4", 0]
|
||||
},
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"_meta": { "title": "Qwen 2.1 shift" }
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": { "title": "Load Qwen 2.1 CLIP" }
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": { "title": "Load Qwen 2.1 VAE" }
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"clip": ["5", 0],
|
||||
"prompt": "",
|
||||
"negative_prompt": "",
|
||||
"resolution": 1024
|
||||
},
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"_meta": { "title": "Text Encode Qwen Image 2.1" }
|
||||
},
|
||||
"15": {
|
||||
"inputs": {
|
||||
"seed": 1,
|
||||
"steps": 25,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": ["3", 0],
|
||||
"positive": ["9", 0],
|
||||
"negative": ["9", 1],
|
||||
"latent_image": ["9", 2]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"20": {
|
||||
"inputs": {
|
||||
"samples": ["15", 0],
|
||||
"vae": ["6", 0]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"21": {
|
||||
"inputs": {
|
||||
"filename_prefix": "studio2-qwen21",
|
||||
"images": ["20", 0]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": { "title": "Save Image" }
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import { queueSeeds } from '~/shared/studio2/seed.mjs';
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import template from '../../assets/studio2_minimax_native.json';
|
||||
import qwen21Template from '../../assets/studio2_qwen21_t2i.json';
|
||||
import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~/shared/studio2/graphs.mjs';
|
||||
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
||||
import { resolveQwen21Size } from '~/shared/studio2/input.mjs';
|
||||
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
||||
import { markStudioLive, onLiveVideoSettled, type StudioJob } from '../studioQueue';
|
||||
import { readRecord, saveRecord, studio2Root, records } from './store';
|
||||
@@ -126,6 +128,28 @@ async function prepareGraph(r: any) {
|
||||
r.graphId = 'studio2_minimax_native.json';
|
||||
}
|
||||
}
|
||||
else if (q.engine === 'qwen21') {
|
||||
if (q.mode !== 'generate')
|
||||
throw new Error('Qwen 2.1 is T2I in this build.');
|
||||
const size = resolveQwen21Size(s.aspect);
|
||||
s.width = size.width;
|
||||
s.height = size.height;
|
||||
graph = structuredClone(qwen21Template);
|
||||
graph['9'].inputs.prompt = q.compiledPrompt;
|
||||
graph['9'].inputs.negative_prompt = stylePrompt(q.imageStyles, true);
|
||||
// TextEncodeQwenImage21 builds the 64-ch empty latent from resolution (square T2I).
|
||||
graph['9'].inputs.resolution = Math.max(size.width, size.height);
|
||||
graph['15'].inputs.seed = s.seed;
|
||||
graph['15'].inputs.steps = s.steps || 25;
|
||||
graph['15'].inputs.cfg = s.cfg ?? 1;
|
||||
graph['15'].inputs.sampler_name = 'euler';
|
||||
graph['15'].inputs.scheduler = 'simple';
|
||||
graph['21'].inputs.filename_prefix = prefix + '/image';
|
||||
r.sampleLatent = 'qwen21 textencode latent';
|
||||
r.sampleDenoise = null;
|
||||
r.heroReferenceAttached = false;
|
||||
r.graphId = 'studio2_qwen21_t2i.json';
|
||||
}
|
||||
else {
|
||||
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
||||
const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null;
|
||||
|
||||
@@ -21,5 +21,5 @@ export async function resolveRequestSize(owner: string, request: any) {
|
||||
}
|
||||
}
|
||||
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
|
||||
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions))
|
||||
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions,undefined,request.engine))
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function appearanceLock({lockFace = true, lockOutfit = true} = {}) {
|
||||
export function compilePrompt(value, video = false, locks = {}) {
|
||||
if (video) return [compileSections(value),appearanceLock(locks)].filter(Boolean).join('\n\n')
|
||||
const hasImage=!!(locks.imageAId || locks.identityStillId)
|
||||
const attached=hasImage && locks.engine !== 'krea'
|
||||
const attached=hasImage && locks.engine !== 'krea' && locks.engine !== 'qwen21'
|
||||
const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference'
|
||||
return [sectionsFrom(value).action, stylePrompt(locks.imageStyles),
|
||||
attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '',
|
||||
@@ -31,9 +31,10 @@ export function validateRequest(raw, xaigen = false) {
|
||||
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
||||
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
||||
const video = ['video','extend'].includes(mode)
|
||||
if (!(video ? ['minimax','ltx'] : ['flux','krea']).includes(engine)) fail('Engine does not support this task.')
|
||||
if (!(video ? ['minimax','ltx'] : ['flux','krea','qwen21']).includes(engine)) fail('Engine does not support this task.')
|
||||
if (engine === 'ltx' && !xaigen) fail('LTX is available only in xAIGen.')
|
||||
if (engine === 'krea' && ['edit','compose','iterate'].includes(mode)) fail('Sticky hero edits require Klein. Krea remains available for Generate and masked Refine; no engine fallback is performed.')
|
||||
if (engine === 'qwen21' && mode !== 'generate') fail('Qwen 2.1 is T2I in this build.')
|
||||
if (Array.isArray(raw.batch) && raw.batch.length>30) fail('A batch supports up to 31 shots including the first prompt.')
|
||||
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
||||
if (!raw.folderId) fail('Choose a folder.')
|
||||
@@ -46,8 +47,13 @@ export function validateRequest(raw, xaigen = false) {
|
||||
const promptSections = video ? allSections : {action:allSections.action}
|
||||
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
||||
const number=(key, fallback, min, max) => { const n=Number(raw.settings?.[key] ?? fallback); if (!Number.isFinite(n)||n<min||n>max) fail(`Invalid ${key}.`); return n }
|
||||
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?24:1,0,2147483647))) fail(`Invalid ${key}.`)
|
||||
for (const key of ['width','height']) if (number(key,key==='width'?1344:768,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
||||
const qwen = engine === 'qwen21'
|
||||
const defaultSteps = video ? 8 : qwen ? 25 : 24
|
||||
const defaultCfg = video ? 1.5 : qwen ? 1 : 4
|
||||
const defaultWidth = qwen ? 1024 : 1344
|
||||
const defaultHeight = qwen ? 1024 : 768
|
||||
for (const key of ['steps']) if (!Number.isInteger(number(key,key==='steps'?defaultSteps:1,0,2147483647))) fail(`Invalid ${key}.`)
|
||||
for (const key of ['width','height']) if (number(key,key==='width'?defaultWidth:defaultHeight,256,4096)%32) fail('Dimensions must be multiples of 32.')
|
||||
const duration=number('duration',5,1,15)
|
||||
if (video) {
|
||||
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
|
||||
@@ -55,10 +61,10 @@ export function validateRequest(raw, xaigen = false) {
|
||||
}
|
||||
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
|
||||
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
|
||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:qwen ? false : raw.lockFace !== false, lockOutfit:qwen ? false : raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:qwen ? '' : (raw.identityStillId || ''), imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,raw),
|
||||
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
|
||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
|
||||
settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',defaultWidth,256,4096),height:number('height',defaultHeight,256,4096),steps:number('steps',defaultSteps,1,100),cfg:number('cfg',defaultCfg,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:qwen?[]:(Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[]) },
|
||||
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
|
||||
batch: (Array.isArray(raw.batch)?raw.batch:[]).slice(0,30).map(s=>{const next=sectionsFrom({...promptSections,...(typeof s==='string'?{action:s}:s)});if(!next.action.trim())fail('Every shot needs an action prompt.');return video?next:{action:next.action}}) }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import {restoreLoras,loraUse} from './lora-memory.mjs'
|
||||
import { restoreInput } from './contracts.mjs'
|
||||
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
|
||||
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) {
|
||||
export function resolveQwen21Size(aspect) {
|
||||
const presets = {'16:9':[1024,576],'9:16':[576,1024],'1:1':[1024,1024]}
|
||||
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
||||
return {width:1024,height:1024}
|
||||
}
|
||||
export function resolveSize(aspect, source, fallback = {width:1344,height:768}, engine = '') {
|
||||
if (engine === 'qwen21') return resolveQwen21Size(aspect)
|
||||
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
||||
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
||||
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
|
||||
@@ -35,7 +41,7 @@ export function restoreAsset(form, item, xaigen = false, memory) {
|
||||
const restored = restoreInput(item)
|
||||
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
|
||||
next.settings.seedMode='random'
|
||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
||||
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||
next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit'
|
||||
next.imageAId=item.id
|
||||
next.startClipId=''
|
||||
@@ -62,7 +68,7 @@ export function restoreAll(form,item,xaigen=false,memory) {
|
||||
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
|
||||
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
|
||||
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
|
||||
next.engine=restored.engine==='krea'?'krea':'flux'
|
||||
next.engine=restored.engine==='qwen21'?'qwen21':restored.engine==='krea'?'krea':'flux'
|
||||
next.imageAId=item.id
|
||||
next.startClipId=''
|
||||
return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
|
||||
|
||||
@@ -2,7 +2,9 @@ import {filterLoraStackForUse} from '../../utils/loras.ts'
|
||||
|
||||
export function loraUse(form) {
|
||||
const video=['video','extend'].includes(form.mode)
|
||||
return video ? (form.engine==='ltx'?'ltx':'minimax') : (form.engine==='krea'?'krea':'flux')
|
||||
if (video) return form.engine==='ltx'?'ltx':'minimax'
|
||||
if (form.engine==='qwen21') return 'qwen21'
|
||||
return form.engine==='krea'?'krea':'flux'
|
||||
}
|
||||
|
||||
// One controller per bench. Storage is optional (SSR/private browsing still works).
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const styleGroups={Render:['Photorealistic','Pixar','3D Disney','Comic','Watercolor','Oil painting'],Quality:['Realistic skin','Skin pores','Fine detail','Natural texture'],Lighting:['Golden hour','Soft studio','Window light','Overcast','Cinematic lighting']}
|
||||
export function normalizeStyles(value={}) { const known=Object.values(styleGroups).flat();const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))];return {positive,negative:[...new Set((value.negative||[]).filter(s=>known.includes(s)&&!positive.includes(s)))]} }
|
||||
export const defaultNegatives=['blurry','deformed','low quality','text','watermark']
|
||||
export function normalizeStyles(value={}) { const known=[...Object.values(styleGroups).flat(),...defaultNegatives];const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))];return {positive,negative:[...new Set((value.negative||[]).filter(s=>known.includes(s)&&!positive.includes(s)))]} }
|
||||
export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next }
|
||||
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
|
||||
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
|
||||
|
||||
|
||||
@@ -74,3 +74,10 @@ test('image chips compile positive and negative independently; video chips do no
|
||||
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
||||
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
||||
})
|
||||
test('qwen21 is generate-only T2I with 25/1 defaults and no locks',()=>{
|
||||
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})
|
||||
assert.equal(q.engine,'qwen21');assert.equal(q.settings.steps,25);assert.equal(q.settings.cfg,1)
|
||||
assert.equal(q.settings.width,1024);assert.equal(q.settings.height,1024)
|
||||
assert.equal(q.lockFace,false);assert.equal(q.lockOutfit,false);assert.deepEqual(q.settings.loraStack,[])
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/T2I in this build/)
|
||||
})
|
||||
|
||||
@@ -283,7 +283,7 @@ export function kleinLoraOnKreaMessage(name: string) {
|
||||
return imageLoraEngineMismatchMessage(name, 'krea')
|
||||
}
|
||||
|
||||
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea'
|
||||
export type LoraUse = 'minimax' | 'ltx' | 'flux' | 'krea' | 'qwen21'
|
||||
|
||||
export function isSystemGraphLora(name: string) {
|
||||
const id = loraIdentityKey(name)
|
||||
@@ -301,16 +301,19 @@ export function inferLoraUse(name: string): LoraUse[] | null {
|
||||
if (/ltx/.test(value)) uses.push('ltx')
|
||||
if (/klein|flux/.test(value)) uses.push('flux')
|
||||
if (/krea/.test(value)) uses.push('krea')
|
||||
if (/qwen/.test(value)) uses.push('qwen21')
|
||||
return uses.length ? uses : null
|
||||
}
|
||||
|
||||
export function loraMatchesUse(name: string, use: LoraUse) {
|
||||
if (use === 'qwen21') return false
|
||||
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
|
||||
}
|
||||
|
||||
/** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
|
||||
export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
|
||||
if (use === 'ltx' && !xaigen) return []
|
||||
if (use === 'qwen21') return []
|
||||
return filterLoraStackForStudio(stack, xaigen).filter(item => {
|
||||
const uses = inferLoraUse(item.name)
|
||||
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
|
||||
@@ -318,6 +321,7 @@ export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: bool
|
||||
}
|
||||
|
||||
export function filterLorasForUse(names: string[], use: LoraUse) {
|
||||
if (use === 'qwen21') return []
|
||||
return names.filter(name => loraMatchesUse(name, use))
|
||||
}
|
||||
|
||||
|
||||