Compare commits
34
Commits
58c5458c87
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e97cc1c3 | ||
|
|
b6eef6a511 | ||
|
|
d803d57a78 | ||
|
|
a8ae61704e | ||
|
|
7799529788 | ||
|
|
790c33fcf0 | ||
|
|
1bf97568bf | ||
|
|
5788d74001 | ||
|
|
4f40641050 | ||
|
|
14dcd234f3 | ||
|
|
15ef891b26 | ||
|
|
9481791ac5 | ||
|
|
17bb42bea4 | ||
|
|
ccf852cb0c | ||
|
|
4ec8ffa8d6 | ||
|
|
a465dd68ec | ||
|
|
cfabfd37ee | ||
|
|
ffdaa7ce42 | ||
|
|
aa0aa871e4 | ||
|
|
845ee5bbf6 | ||
|
|
ba83520923 | ||
|
|
322e5a276e | ||
|
|
5f6a7aa9bc | ||
|
|
930bbf7c7b | ||
|
|
c4e74628ee | ||
|
|
fe560d7e40 | ||
|
|
d61f99d6fd | ||
|
|
f9903ed76e | ||
|
|
a33cadb6c2 | ||
|
|
5b0d7370ed | ||
|
|
7242d0faef | ||
|
|
e06afe4a17 | ||
|
|
50319e03a9 | ||
|
|
5c2248a3fd |
@@ -30,11 +30,13 @@ Mobile is a **viewer-first phone app** with sheets for the inspector and the que
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ STAGE (contain, full remaining height) │
|
||||
│ │
|
||||
├ filmstrip scroll-x, tiles 72px ───────────────────────────┤
|
||||
│ Library (stage chrome) · eye ≥48px top-right │
|
||||
├ [Slots] [Prompt] [Look] [Gen] Generate (amber, 48px) ─┤
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Library is a **stage button** that opens the Library modal/sheet (same tiles,
|
||||
filters, Use as photo / Copy / Load / Delete). The under-stage filmstrip row
|
||||
is revoked — recents live in the modal (newest first).```
|
||||
|
||||
Bottom **dock** is 4 tabs that open sheets, plus Generate always visible:
|
||||
|
||||
|
||||
+310
-41
@@ -332,7 +332,7 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Stage: fills center column above filmstrip + transport */
|
||||
/* Stage: fills center column above transport */
|
||||
.viewer-frame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -347,7 +347,8 @@
|
||||
|
||||
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */
|
||||
.viewer-frame > img,
|
||||
.viewer-frame > video {
|
||||
.viewer-frame > video,
|
||||
.viewer-still {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
@@ -356,31 +357,234 @@
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.eye-viewer {
|
||||
.viewer-still--veiled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-frame--veiled .viewer-veil-label {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 5;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Top chrome is a sibling of the media pane — never absolute over pixels */
|
||||
.stage-chrome {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #0b0b0d;
|
||||
}
|
||||
|
||||
.stage-chrome > .eye-viewer {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.stage-library {
|
||||
display: inline-flex;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #141416;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-library:hover {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.stage-library-count {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stage-actions > button,
|
||||
.stage-split-trigger {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #141416;
|
||||
color: var(--text);
|
||||
padding: 0 10px;
|
||||
font: inherit;
|
||||
font-size: var(--type-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-actions > button:hover,
|
||||
.stage-split-trigger:hover {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.stage-actions .stage-action-danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.stage-split {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stage-split-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stage-split-trigger::after {
|
||||
content: '▾';
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stage-split-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
min-width: 160px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #141416;
|
||||
padding: 4px;
|
||||
box-shadow: 0 8px 24px #0008;
|
||||
}
|
||||
|
||||
.stage-split-menu button {
|
||||
min-height: 36px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 0 10px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: var(--type-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-split-menu button:hover {
|
||||
background: #ffffff10;
|
||||
}
|
||||
|
||||
.eye-viewer {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #141416;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eye-viewer:hover {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.stage-media-row {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.stage-nav {
|
||||
display: grid;
|
||||
width: 44px;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
align-self: stretch;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #0b0b0d;
|
||||
color: var(--text);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-nav:hover {
|
||||
color: var(--amber);
|
||||
background: #121214;
|
||||
}
|
||||
|
||||
.stage-nav-prev {
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.stage-nav-next {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.photos-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.photos-inputs-toggle {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #000a;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.photos-inputs-toggle:hover {
|
||||
border-color: var(--amber);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.photos-collapsed {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.photos-add-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--amber);
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: var(--type-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filmstrip {
|
||||
display: 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);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tile {
|
||||
@@ -396,7 +600,6 @@
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.tile { width: 120px; height: 80px; }
|
||||
.filmstrip { height: 96px; }
|
||||
}
|
||||
|
||||
.tile[data-active='true'] {
|
||||
@@ -438,28 +641,7 @@
|
||||
.tile .tile-trash { right: 22px; }
|
||||
|
||||
.strip-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 10px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.strip-actions button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 4px 10px;
|
||||
font-size: var(--type-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.strip-actions button:hover {
|
||||
border-color: var(--amber);
|
||||
color: var(--amber);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.neg-add-row {
|
||||
@@ -504,6 +686,10 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.queue-col[data-collapsed='1'] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
@@ -513,6 +699,24 @@
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.queue-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.queue-toggle-mark {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.queue-badge {
|
||||
display: inline-grid;
|
||||
min-width: 1.25rem;
|
||||
@@ -657,6 +861,14 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.queue-foot-error {
|
||||
margin: 8px 0 0;
|
||||
color: #fca5a5;
|
||||
font-size: var(--type-sm);
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.queue-foot .tiny {
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
@@ -718,6 +930,63 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.photos-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.photos-add {
|
||||
align-self: flex-start;
|
||||
color: var(--amber);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.photo-card {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.photo-card .slot-thumb--slot { height: 88px; }
|
||||
.photo-card .slot-thumb--empty { height: 88px; }
|
||||
|
||||
.photo-card-body {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.photo-role-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.photo-role-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slot-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.slot-actions button,
|
||||
.slot-token-btn {
|
||||
color: var(--amber);
|
||||
font-size: var(--type-sm);
|
||||
}
|
||||
|
||||
.slot-token-btn {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.placeholder-still {
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
|
||||
@@ -55,6 +55,13 @@
|
||||
role="menuitem"
|
||||
@click="menuOpen = false"
|
||||
>Director</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/prompt-builder"
|
||||
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
||||
:class="current === 'prompt-builder' ? 'text-amber-100' : 'text-zinc-200'"
|
||||
role="menuitem"
|
||||
@click="menuOpen = false"
|
||||
>Prompt Builder</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/music"
|
||||
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
||||
@@ -131,7 +138,7 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
subtitle: string
|
||||
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music'
|
||||
current?: 'studio' | 'library' | 'director' | 'prompt-builder' | 'queue' | 'settings' | 'music'
|
||||
wide?: boolean
|
||||
queueCount?: number
|
||||
}>(), {
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
>
|
||||
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
:src="`/api/library/stills/${still.id}?thumb=1`"
|
||||
:src="`/api/library/stills/${still.id}/thumb`"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -1,24 +1,60 @@
|
||||
<template>
|
||||
<div class="slot-block">
|
||||
<span class="field-label">{{ label }}</span>
|
||||
<div class="photo-card">
|
||||
<button
|
||||
type="button"
|
||||
class="slot-thumb"
|
||||
:class="asset ? (size==='hero' ? 'slot-thumb--hero' : 'slot-thumb--slot') : 'slot-thumb--empty'"
|
||||
:class="asset ? 'slot-thumb--slot' : 'slot-thumb--empty'"
|
||||
:aria-label="asset ? 'Replace photo' : 'Choose a photo'"
|
||||
@click="$emit('pick')"
|
||||
>
|
||||
<span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">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>
|
||||
<span v-if="asset && hidden" class="p-2 text-center text-[var(--type-sm)] text-[var(--muted)]">Photo loaded</span>
|
||||
<img v-else-if="asset" :src="thumbSrc" alt="">
|
||||
<span v-else class="inline-flex flex-col items-center gap-1 px-2 text-center">
|
||||
<span>Choose a photo</span>
|
||||
</span>
|
||||
</button>
|
||||
<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)">
|
||||
<div class="photo-card-body">
|
||||
<label class="photo-role-label">
|
||||
<span class="field-label">This photo is:</span>
|
||||
<select
|
||||
class="field-control photo-role-select"
|
||||
:value="role"
|
||||
:disabled="readonly"
|
||||
@change="$emit('update:role', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="opt in roleOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="slot-actions">
|
||||
<button
|
||||
v-if="!readonly && asset"
|
||||
type="button"
|
||||
class="slot-token-btn"
|
||||
title="Mention this photo in the prompt"
|
||||
@click.stop="$emit('mention')"
|
||||
>Mention in prompt</button>
|
||||
<button
|
||||
v-if="!readonly && asset"
|
||||
type="button"
|
||||
@click.stop="$emit('pick')"
|
||||
>Replace</button>
|
||||
<button v-if="!readonly" type="button" @click.stop="$emit('remove')">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {assetLabel} from '~/shared/studio2/viewer.mjs'
|
||||
withDefaults(defineProps<{label:string;asset?:any;hidden?:boolean;upload?:boolean;disabled?:boolean;size?:'hero'|'slot'}>(),{size:'slot'})
|
||||
defineEmits(['pick','upload'])
|
||||
const props = withDefaults(defineProps<{
|
||||
asset?: any
|
||||
hidden?: boolean
|
||||
role: string
|
||||
roleOptions: { id: string; label: string }[]
|
||||
readonly?: boolean
|
||||
}>(), { readonly: false, hidden: false })
|
||||
defineEmits(['pick', 'remove', 'mention', 'update:role'])
|
||||
const thumbSrc = computed(() => {
|
||||
if (!props.asset) return ''
|
||||
if (props.asset.kind === 'clip') return `/api/library/clips/${props.asset.id}/thumb`
|
||||
return `/api/library/stills/${props.asset.id}/thumb`
|
||||
})
|
||||
</script>
|
||||
|
||||
+1362
-228
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,18 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const props=defineProps<{metadata?:any}>()
|
||||
function promptRows(m:any){
|
||||
const used=String(m.prompt || m.compiledPrompt || '').trim()
|
||||
const typed=String(m.promptRaw || '').trim()
|
||||
if(typed && typed!==used){
|
||||
return [
|
||||
{label:'Typed',value:typed},
|
||||
...(used?[{label:'Used',value:used}]:[])
|
||||
]
|
||||
}
|
||||
if(used) return [{label:'Prompt',value:used}]
|
||||
return []
|
||||
}
|
||||
const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=(v:any)=>v?new Date(v).toLocaleString():null;return [
|
||||
{label:'Kind / mode',value:[m.kind,m.mode].filter(Boolean).join(' / ')},
|
||||
{label:'Engine',value:m.engine},{label:'Graph ID',value:m.graphId},
|
||||
@@ -19,8 +31,7 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time=
|
||||
{label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'},
|
||||
{label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'},
|
||||
{label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'},
|
||||
{label:'Prompt',value:m.prompt || m.compiledPrompt},
|
||||
{label:'Typed',value:m.promptRaw || null},
|
||||
...promptRows(m),
|
||||
{label:'Task',value:m.task || m.mode || null},
|
||||
{label:'Enhance prompt',value:m.enhancePrompt==null?null:m.enhancePrompt?'On':'Off'},
|
||||
{label:'Enhance',value:m.enhance?.refused?'Enhance skipped (model refused) — used your prompt.':m.enhance?.skippedAsDescribe?'Enhance skipped (describe rewrite) — keep stanza + typed only.':m.enhance?.used?'Applied':m.enhance?.parse_ok==null?null:m.enhance.parse_ok?'Applied':'Parse incomplete'},
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="lib-modal-root"
|
||||
role="presentation"
|
||||
@keydown.esc.prevent="close"
|
||||
>
|
||||
<div class="lib-modal-backdrop" @click="close" />
|
||||
<section
|
||||
ref="dialogEl"
|
||||
class="lib-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="lib-modal-title"
|
||||
tabindex="-1"
|
||||
@keydown.tab="onTab"
|
||||
>
|
||||
<header class="lib-modal-head">
|
||||
<h2 id="lib-modal-title">Library</h2>
|
||||
<div class="lib-modal-head-actions">
|
||||
<NuxtLink
|
||||
class="lib-modal-full"
|
||||
:to="{ path: '/library', query: activeFolderId && activeFolderId !== 'all' ? { folder: activeFolderId } : {} }"
|
||||
@click="close"
|
||||
>Full library</NuxtLink>
|
||||
<button type="button" class="lib-modal-x" aria-label="Close" @click="close">×</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="lib-modal-toolbar">
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
class="lib-modal-search"
|
||||
placeholder="Name or prompt"
|
||||
aria-label="Search library"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="lib-modal-preview-all"
|
||||
@click="$emit('toggle-preview')"
|
||||
>{{ previewHidden ? 'Show all previews' : 'Hide all previews' }}</button>
|
||||
<div class="lib-modal-filters" role="group" aria-label="Type">
|
||||
<button
|
||||
v-for="opt in filterOptions"
|
||||
:key="opt.id"
|
||||
type="button"
|
||||
class="lib-modal-chip"
|
||||
:data-on="filter === opt.id ? 'true' : undefined"
|
||||
@click="filter = opt.id"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-modal-body">
|
||||
<div ref="gridScrollEl" class="lib-modal-grid" aria-label="Folder assets">
|
||||
<p v-if="loading && !entries.length" class="lib-modal-empty">Loading library…</p>
|
||||
<p v-else-if="loadError" class="lib-modal-empty">{{ loadError }}</p>
|
||||
<p v-else-if="!entries.length" class="lib-modal-empty">No items in this folder yet.</p>
|
||||
<article
|
||||
v-for="entry in entries"
|
||||
:key="entry.key"
|
||||
class="lib-modal-tile"
|
||||
:data-thumb-key="entry.key"
|
||||
:data-active="entry.key === focusedKey ? 'true' : undefined"
|
||||
@click="pickEntry(entry)"
|
||||
>
|
||||
<div class="lib-modal-media">
|
||||
<div
|
||||
v-if="entry.kind === 'track'"
|
||||
class="lib-modal-track"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
v-for="n in 16"
|
||||
:key="n"
|
||||
class="lib-modal-bar"
|
||||
:style="{ height: `${20 + ((n * 17) % 60)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="!previewHidden && liveThumbs.has(entry.key) && thumbSrc(entry)"
|
||||
:src="thumbSrc(entry)"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
<span v-else class="lib-modal-fallback">{{ previewHidden ? 'Hidden' : '' }}</span>
|
||||
<span class="lib-modal-kind">
|
||||
{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="lib-modal-caption">
|
||||
<p class="lib-modal-name">{{ entry.name }}</p>
|
||||
<p class="lib-modal-meta">{{ cardMeta(entry) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<aside v-if="detailAsset" class="lib-modal-detail">
|
||||
<p class="lib-modal-detail-name">{{ detailLabel }}</p>
|
||||
<div v-if="detailAsset.kind !== 'track'" class="lib-modal-actions">
|
||||
<button type="button" @click="$emit('use-image', detailAsset)">Use image</button>
|
||||
<button type="button" @click="$emit('use-prompt', detailAsset)">Use prompt</button>
|
||||
<button type="button" @click="$emit('copy-prompt', detailAsset)">Copy prompt</button>
|
||||
<button type="button" @click="$emit('copy-raw-prompt', detailAsset)">Copy raw prompt</button>
|
||||
<button type="button" @click="$emit('load-input', detailAsset)">Load input</button>
|
||||
<button type="button" @click="$emit('load-input-raw', detailAsset)">Load input raw</button>
|
||||
<button type="button" @click="$emit('toggle-preview')">{{ previewHidden ? 'Show output' : 'Hide output' }}</button>
|
||||
<button type="button" class="danger" @click="onDeleteClick">Delete</button>
|
||||
<button type="button" class="primary" @click="useAndClose">Use and close</button>
|
||||
</div>
|
||||
<LibrarySettingsDetails :rows="detailRows" empty="No generator settings were saved with this item." />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
buildLibraryEntries,
|
||||
formatClipDuration,
|
||||
formatWhen,
|
||||
type LibraryClip,
|
||||
type LibraryEntry,
|
||||
type LibraryFilter,
|
||||
type LibraryFolder,
|
||||
type LibraryStill,
|
||||
type LibraryTrack
|
||||
} from '~/utils/libraryBrowse'
|
||||
import { clipSettingsRows, stillSettingsRows, trackSettingsRows } from '~/utils/libraryGeneration'
|
||||
import { assetLabel } from '~/shared/studio2/viewer.mjs'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
assets?: any[]
|
||||
selected?: any
|
||||
folderId?: string
|
||||
previewHidden?: boolean
|
||||
isHidden?: (asset: any) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
select: [asset: any]
|
||||
'use-image': [asset: any]
|
||||
'use-prompt': [asset: any]
|
||||
'copy-prompt': [asset: any]
|
||||
'copy-raw-prompt': [asset: any]
|
||||
'load-input': [asset: any]
|
||||
'load-input-raw': [asset: any]
|
||||
delete: [asset: any]
|
||||
'toggle-preview': []
|
||||
}>()
|
||||
|
||||
const filterOptions = [
|
||||
{ id: 'all' as const, label: 'All' },
|
||||
{ id: 'images' as const, label: 'Images' },
|
||||
{ id: 'videos' as const, label: 'Videos' },
|
||||
{ id: 'music' as const, label: 'Music' }
|
||||
]
|
||||
|
||||
const query = ref('')
|
||||
const filter = ref<LibraryFilter>('all')
|
||||
const dialogEl = ref<HTMLElement | null>(null)
|
||||
const gridScrollEl = ref<HTMLElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const folders = ref<LibraryFolder[]>([])
|
||||
const clips = ref<LibraryClip[]>([])
|
||||
const stills = ref<LibraryStill[]>([])
|
||||
const tracks = ref<LibraryTrack[]>([])
|
||||
const fetchedAt = ref(0)
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const liveThumbs = ref(new Set<string>())
|
||||
let thumbObserver: IntersectionObserver | null = null
|
||||
let scrollLoadBound = false
|
||||
|
||||
const activeFolderId = computed(() => props.folderId || folders.value[0]?.id || 'all')
|
||||
|
||||
const entries = computed(() => buildLibraryEntries({
|
||||
clips: clips.value,
|
||||
stills: stills.value,
|
||||
tracks: tracks.value,
|
||||
folderId: activeFolderId.value,
|
||||
filter: filter.value,
|
||||
search: query.value,
|
||||
sort: 'date-desc'
|
||||
}))
|
||||
|
||||
const focusedKey = computed(() => {
|
||||
const id = props.selected?.id
|
||||
if (!id) return ''
|
||||
const hit = entries.value.find(entry => assetFromEntry(entry)?.id === id)
|
||||
return hit?.key || ''
|
||||
})
|
||||
|
||||
const detailAsset = computed(() => {
|
||||
if (props.selected) return props.selected
|
||||
const first = entries.value[0]
|
||||
return first ? assetFromEntry(first) : null
|
||||
})
|
||||
|
||||
const detailLabel = computed(() => assetLabel(detailAsset.value))
|
||||
|
||||
const detailRows = computed(() => {
|
||||
const item = detailAsset.value
|
||||
if (!item) return []
|
||||
if (item.kind === 'clip') return clipSettingsRows(item)
|
||||
if (item.kind === 'track') return trackSettingsRows(item)
|
||||
return stillSettingsRows(item)
|
||||
})
|
||||
|
||||
function thumbSrc(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
|
||||
if (entry.kind === 'track') return ''
|
||||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}/thumb`
|
||||
return ''
|
||||
}
|
||||
|
||||
function cardMeta(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) {
|
||||
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
|
||||
}
|
||||
if (entry.kind === 'track' && entry.track) {
|
||||
return [formatClipDuration(entry.track.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ')
|
||||
}
|
||||
return formatWhen(entry.createdAt)
|
||||
}
|
||||
|
||||
function assetFromEntry(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) return { ...entry.group.head, kind: 'clip' as const }
|
||||
if (entry.kind === 'track' && entry.track) return { ...entry.track, kind: 'track' as const }
|
||||
if (entry.stillGroup) return { ...entry.stillGroup.head, kind: 'still' as const }
|
||||
return null
|
||||
}
|
||||
|
||||
function pickEntry(entry: LibraryEntry) {
|
||||
const asset = assetFromEntry(entry)
|
||||
if (!asset) return
|
||||
const fromFamily = (props.assets || []).find((a: any) => a.id === asset.id)
|
||||
emit('select', fromFamily ? { ...asset, ...fromFamily, kind: asset.kind } : asset)
|
||||
}
|
||||
|
||||
function close() { emit('close') }
|
||||
|
||||
function useAndClose() {
|
||||
if (detailAsset.value) emit('use-image', detailAsset.value)
|
||||
close()
|
||||
}
|
||||
|
||||
function removeLocalAsset(asset: any) {
|
||||
if (!asset?.id) return
|
||||
stills.value = stills.value.filter(s => s.id !== asset.id)
|
||||
clips.value = clips.value.filter(c => c.id !== asset.id)
|
||||
tracks.value = tracks.value.filter(t => t.id !== asset.id)
|
||||
}
|
||||
|
||||
function onDeleteClick() {
|
||||
const asset = detailAsset.value
|
||||
if (!asset) return
|
||||
emit('delete', asset)
|
||||
removeLocalAsset(asset)
|
||||
window.setTimeout(() => { void fetchLibrary(true) }, 500)
|
||||
}
|
||||
|
||||
function mergeIds(prev: { id: string }[], next: { id: string }[]) {
|
||||
const map = new Map(prev.map(item => [item.id, item]))
|
||||
for (const item of next) map.set(item.id, item)
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
function applyLibraryPage(data: any, replace: boolean) {
|
||||
folders.value = data?.folders || folders.value
|
||||
if (replace) {
|
||||
clips.value = data?.clips || []
|
||||
stills.value = data?.stills || []
|
||||
tracks.value = data?.tracks || []
|
||||
} else {
|
||||
clips.value = mergeIds(clips.value, data?.clips || []) as LibraryClip[]
|
||||
stills.value = mergeIds(stills.value, data?.stills || []) as LibraryStill[]
|
||||
tracks.value = mergeIds(tracks.value, data?.tracks || []) as LibraryTrack[]
|
||||
}
|
||||
nextCursor.value = data?.nextCursor || null
|
||||
fetchedAt.value = Date.now()
|
||||
}
|
||||
|
||||
async function fetchLibraryPage(cursor: string | null) {
|
||||
const folder = activeFolderId.value && activeFolderId.value !== 'all' ? activeFolderId.value : ''
|
||||
const type = filter.value === 'all' ? 'all' : filter.value === 'images' ? 'images' : filter.value === 'videos' ? 'videos' : 'music'
|
||||
const qs = new URLSearchParams({ limit: '50' })
|
||||
if (folder) qs.set('folderId', folder)
|
||||
if (type !== 'all') qs.set('type', type)
|
||||
if (cursor) qs.set('cursor', cursor)
|
||||
return await $fetch<any>(`/api/library?${qs.toString()}`)
|
||||
}
|
||||
|
||||
let fetchGen = 0
|
||||
|
||||
async function fetchLibrary(force = false) {
|
||||
const stale = !fetchedAt.value || Date.now() - fetchedAt.value > 15_000
|
||||
const empty = !stills.value.length && !clips.value.length && !tracks.value.length
|
||||
if (!force && !empty && !stale) return
|
||||
const gen = ++fetchGen
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
liveThumbs.value = new Set()
|
||||
try {
|
||||
const first = await fetchLibraryPage(null)
|
||||
if (gen !== fetchGen) return
|
||||
applyLibraryPage(first, true)
|
||||
loading.value = false
|
||||
nextTick(() => observeThumbs())
|
||||
// Keep fetching until the folder is exhausted — 50 is a page size, not a cap
|
||||
let cursor = first?.nextCursor || null
|
||||
while (cursor && gen === fetchGen) {
|
||||
const more = await fetchLibraryPage(cursor)
|
||||
if (gen !== fetchGen) return
|
||||
applyLibraryPage(more, false)
|
||||
cursor = more?.nextCursor || null
|
||||
nextTick(() => observeThumbs())
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (gen !== fetchGen) return
|
||||
loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library'
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function observeThumbs() {
|
||||
if (!import.meta.client) return
|
||||
const root = gridScrollEl.value
|
||||
if (!root) return
|
||||
if (thumbObserver) thumbObserver.disconnect()
|
||||
thumbObserver = new IntersectionObserver((entries) => {
|
||||
let changed = false
|
||||
const next = new Set(liveThumbs.value)
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
const key = (entry.target as HTMLElement).dataset.thumbKey
|
||||
if (!key || next.has(key)) continue
|
||||
next.add(key)
|
||||
changed = true
|
||||
}
|
||||
if (changed) liveThumbs.value = next
|
||||
}, { root, rootMargin: '200px 0px', threshold: 0.01 })
|
||||
root.querySelectorAll<HTMLElement>('[data-thumb-key]').forEach((el) => thumbObserver!.observe(el))
|
||||
}
|
||||
|
||||
function onGridScroll() {
|
||||
const el = gridScrollEl.value
|
||||
if (!el || !nextCursor.value || loading.value) return
|
||||
if (el.scrollTop + el.clientHeight < el.scrollHeight - 240) return
|
||||
void fetchMore()
|
||||
}
|
||||
|
||||
async function fetchMore() {
|
||||
if (!nextCursor.value || loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const more = await fetchLibraryPage(nextCursor.value)
|
||||
applyLibraryPage(more, false)
|
||||
nextTick(() => observeThumbs())
|
||||
} catch { /* keep what we have */ }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onTab(e: KeyboardEvent) {
|
||||
const root = dialogEl.value
|
||||
if (!root) return
|
||||
const focusable = [...root.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)].filter(el => el.offsetParent !== null || el === document.activeElement)
|
||||
if (!focusable.length) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, (on) => {
|
||||
if (!import.meta.client) return
|
||||
if (on) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
void fetchLibrary(true)
|
||||
nextTick(() => {
|
||||
dialogEl.value?.focus()
|
||||
if (gridScrollEl.value) {
|
||||
gridScrollEl.value.scrollTop = 0
|
||||
if (!scrollLoadBound) {
|
||||
gridScrollEl.value.addEventListener('scroll', onGridScroll, { passive: true })
|
||||
scrollLoadBound = true
|
||||
}
|
||||
observeThumbs()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
thumbObserver?.disconnect()
|
||||
thumbObserver = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(entries, () => nextTick(() => observeThumbs()))
|
||||
|
||||
watch([filter, activeFolderId], () => {
|
||||
if (!props.open) return
|
||||
void fetchLibrary(true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (import.meta.client) {
|
||||
document.body.style.overflow = ''
|
||||
gridScrollEl.value?.removeEventListener('scroll', onGridScroll)
|
||||
thumbObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lib-modal-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
.lib-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #000a;
|
||||
}
|
||||
.lib-modal {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: min(1100px, 100%);
|
||||
max-height: min(92vh, 900px);
|
||||
height: min(92vh, 900px);
|
||||
margin: auto;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #2a2a2e;
|
||||
border-radius: 12px;
|
||||
background: #141416;
|
||||
color: #f3f1ea;
|
||||
outline: none;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.lib-modal-root { align-items: flex-end; }
|
||||
.lib-modal {
|
||||
width: 100%;
|
||||
max-height: 100dvh;
|
||||
height: 100dvh;
|
||||
margin: 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
}
|
||||
.lib-modal-head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #2a2a2e;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.lib-modal-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lib-modal-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.lib-modal-full {
|
||||
color: #f5a524;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.lib-modal-x {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border: 1px solid #2a2a2e;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lib-modal-toolbar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #2a2a2e;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.lib-modal-search {
|
||||
min-width: 12rem;
|
||||
flex: 1;
|
||||
border: 1px solid #2a2a2e;
|
||||
border-radius: 8px;
|
||||
background: #0b0b0d;
|
||||
padding: 8px 10px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.lib-modal-preview-all {
|
||||
border: 1px solid #2a2a2e;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lib-modal-preview-all:hover {
|
||||
border-color: #f5a524;
|
||||
color: #f5a524;
|
||||
}
|
||||
.lib-modal-filters { display: inline-flex; gap: 4px; }
|
||||
.lib-modal-chip {
|
||||
border: 1px solid #2a2a2e;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #9a9588;
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lib-modal-chip[data-on='true'] {
|
||||
border-color: #f5a524;
|
||||
background: #f5a52422;
|
||||
color: #f5a524;
|
||||
}
|
||||
.lib-modal-body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.lib-modal-body { flex-direction: column; }
|
||||
}
|
||||
.lib-modal-grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.lib-modal-tile {
|
||||
display: flex;
|
||||
min-height: 120px;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 2px solid #2a2a2e;
|
||||
border-radius: 8px;
|
||||
background: #0b0b0d;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lib-modal-tile[data-active='true'] {
|
||||
border-color: #f5a524;
|
||||
box-shadow: 0 0 0 2px #f5a524;
|
||||
}
|
||||
.lib-modal-media {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: 120px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: #18181b;
|
||||
}
|
||||
.lib-modal-media > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.lib-modal-fallback {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #27272a;
|
||||
}
|
||||
.lib-modal-track {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
padding: 16px;
|
||||
}
|
||||
.lib-modal-bar {
|
||||
flex: 1;
|
||||
border-radius: 999px;
|
||||
background: rgba(245, 165, 36, 0.7);
|
||||
}
|
||||
.lib-modal-kind {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
border-radius: 4px;
|
||||
background: #000a;
|
||||
padding: 2px 6px;
|
||||
color: #e4e4e7;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.lib-modal-caption {
|
||||
padding: 6px 8px 8px;
|
||||
}
|
||||
.lib-modal-name {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #f4f4f5;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lib-modal-meta {
|
||||
margin: 2px 0 0;
|
||||
overflow: hidden;
|
||||
color: #71717a;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lib-modal-empty {
|
||||
grid-column: 1 / -1;
|
||||
color: #9a9588;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.lib-modal-detail {
|
||||
flex: 0 0 min(320px, 38%);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-left: 1px solid #2a2a2e;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.lib-modal-detail {
|
||||
flex: 0 0 auto;
|
||||
max-height: 40%;
|
||||
border-left: 0;
|
||||
border-top: 1px solid #2a2a2e;
|
||||
}
|
||||
}
|
||||
.lib-modal-detail-name {
|
||||
margin: 0 0 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lib-modal-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.lib-modal-actions button {
|
||||
border: 1px solid #2a2a2e;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lib-modal-actions button.primary {
|
||||
border-color: #f5a524;
|
||||
background: #f5a524;
|
||||
color: #1a1204;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lib-modal-actions button.danger { color: #fca5a5; }
|
||||
</style>
|
||||
+424
-128
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="m-shell" data-mobile="1">
|
||||
<div class="m-shell" data-mobile="1" :data-queue-collapsed="queueRailCollapsed ? '1' : undefined">
|
||||
<header class="m-header">
|
||||
<span class="m-brand">{{ ctx.instanceName }}</span>
|
||||
<label class="m-crumb">
|
||||
@@ -9,11 +9,15 @@
|
||||
</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>
|
||||
<button type="button" class="m-chip m-queue-chip-portrait" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
|
||||
<button type="button" class="m-chip m-queue-chip-landscape" :aria-expanded="queueRailCollapsed ? 'false' : 'true'" @click="queueRailCollapsed=!queueRailCollapsed">
|
||||
Queue {{ ctx.queueCount }} {{ queueRailCollapsed ? '▸' : '▾' }}
|
||||
</button>
|
||||
<details class="m-more">
|
||||
<summary aria-label="More">⋯</summary>
|
||||
<div class="m-more-menu">
|
||||
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
|
||||
<NuxtLink to="/prompt-builder" @click="closeMenus">Prompt Builder</NuxtLink>
|
||||
<NuxtLink to="/queue" @click="closeMenus">Queue</NuxtLink>
|
||||
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
|
||||
</div>
|
||||
@@ -47,76 +51,113 @@
|
||||
</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)"
|
||||
<div
|
||||
class="m-stage"
|
||||
:class="{ 'm-stage--veiled': ctx.selected && ctx.isHidden(ctx.selected) }"
|
||||
@touchstart.passive="onStageTouchStart"
|
||||
@touchend.passive="onStageTouchEnd"
|
||||
>
|
||||
<img :src="ctx.isHidden(ctx.selected) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="16" height="16">
|
||||
<div class="m-stage-chrome" aria-label="Stage controls">
|
||||
<button
|
||||
type="button"
|
||||
class="m-icon-btn m-library"
|
||||
:aria-expanded="ctx.libraryOpen ? 'true' : 'false'"
|
||||
aria-label="Library"
|
||||
@click="ctx.openLibrary()"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="3" y="4" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="4" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="13" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="13" width="7" height="7" rx="1.5" />
|
||||
</svg>
|
||||
<span v-if="ctx.familyStillCount" class="m-library-badge">{{ ctx.familyStillCount > 99 ? '99+' : ctx.familyStillCount }}</span>
|
||||
</button>
|
||||
<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">
|
||||
<div class="m-stage-chrome-end">
|
||||
<button
|
||||
v-if="ctx.selected && ctx.selected.kind!=='track'"
|
||||
type="button"
|
||||
class="m-icon-btn"
|
||||
aria-label="More"
|
||||
:aria-expanded="sheet==='actions' ? 'true' : 'false'"
|
||||
@click.stop="toggleSheet('actions')"
|
||||
>•••</button>
|
||||
<button
|
||||
v-if="ctx.selected"
|
||||
type="button"
|
||||
class="m-icon-btn m-eye"
|
||||
:aria-label="ctx.eyeOpen ? 'Hide output' : 'Show output'"
|
||||
@click.stop="ctx.toggleReveal()"
|
||||
>
|
||||
<img :src="ctx.eyeOpen ? '/icons/display/eye.svg' : '/icons/display/eye-off.svg'" alt="" width="20" height="20">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="(ctx.family || []).length > 1"
|
||||
type="button"
|
||||
class="m-nav m-nav-prev"
|
||||
aria-label="Previous"
|
||||
@click.stop="ctx.stepFamily(-1)"
|
||||
>‹</button>
|
||||
<button
|
||||
v-if="(ctx.family || []).length > 1"
|
||||
type="button"
|
||||
class="m-nav m-nav-next"
|
||||
aria-label="Next"
|
||||
@click.stop="ctx.stepFamily(1)"
|
||||
>›</button>
|
||||
<p
|
||||
v-show="ctx.selected && ctx.isHidden(ctx.selected)"
|
||||
class="m-hidden-note"
|
||||
>{{ ctx.selected?.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
|
||||
<template v-if="ctx.selected?.kind==='track'">
|
||||
<div v-show="!ctx.isHidden(ctx.selected)" class="m-audio">
|
||||
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
|
||||
</div>
|
||||
</template>
|
||||
<video
|
||||
v-else-if="ctx.selected?.kind==='clip'"
|
||||
v-show="!ctx.isHidden(ctx.selected)"
|
||||
:key="ctx.selected.id"
|
||||
:src="`/api/library/clips/${ctx.selected.id}/video`"
|
||||
controls
|
||||
playsinline
|
||||
/>
|
||||
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still">
|
||||
<p v-else class="m-muted">Select a still or clip</p>
|
||||
<img
|
||||
v-else-if="ctx.selected"
|
||||
v-show="!ctx.isHidden(ctx.selected)"
|
||||
:key="ctx.selected.id+'-'+(ctx.stagePreviewNonce||0)"
|
||||
class="m-stage-still"
|
||||
:src="ctx.stagePreviewSrc || ctx.stillPreviewUrl(ctx.selected.id)"
|
||||
alt="Selected still"
|
||||
decoding="async"
|
||||
@error="ctx.onStagePreviewError?.()"
|
||||
>
|
||||
<p v-else class="m-muted">Open Library to pick a still or clip</p>
|
||||
</div>
|
||||
|
||||
<div class="m-strip" aria-label="Family outputs">
|
||||
<div
|
||||
v-for="o in ctx.family"
|
||||
:key="o.id"
|
||||
class="m-tile"
|
||||
:class="{ on: ctx.selected?.id===o.id }"
|
||||
>
|
||||
<button type="button" class="m-tile-pick" @click="ctx.selectOutput(o)">
|
||||
<span v-if="ctx.isHidden(o)" class="m-tile-hid">Hidden</span>
|
||||
<span v-else-if="o.kind==='track'" class="m-tile-hid">Audio</span>
|
||||
<img v-else :src="o.kind==='clip' ? `/api/library/clips/${o.id}/thumb` : ctx.stillUrl(o.id)" alt="">
|
||||
</button>
|
||||
<button
|
||||
v-if="ctx.xaigen"
|
||||
type="button"
|
||||
class="m-tile-eye"
|
||||
:aria-label="ctx.isHidden(o) ? 'Show tile' : 'Hide tile'"
|
||||
@click.stop="ctx.toggleReveal(o.id)"
|
||||
>
|
||||
<img :src="ctx.isHidden(o) ? '/icons/display/eye-off.svg' : '/icons/display/eye.svg'" alt="" width="12" height="12">
|
||||
</button>
|
||||
<button
|
||||
v-if="ctx.selected?.id===o.id"
|
||||
type="button"
|
||||
class="m-tile-trash"
|
||||
aria-label="Delete selected"
|
||||
@click.stop="ctx.deleteOutput"
|
||||
>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ctx.selected && ctx.selected.kind!=='track'" class="m-strip-actions">
|
||||
<button type="button" @click="ctx.applyUseAsSource">Use as start</button>
|
||||
<button type="button" @click="ctx.applyUseAsHero">Use as hero</button>
|
||||
<button type="button" @click="ctx.copySelectedPrompt">Copy prompt</button>
|
||||
<button type="button" @click="ctx.loadSelectedJob">Load</button>
|
||||
<button type="button" @click="ctx.deleteOutput">Delete</button>
|
||||
</div>
|
||||
<Studio2LibraryModal
|
||||
:open="!!ctx.libraryOpen"
|
||||
:assets="ctx.family || []"
|
||||
:selected="ctx.selected"
|
||||
:folder-id="ctx.form?.folderId"
|
||||
:preview-hidden="!ctx.eyeOpen"
|
||||
:is-hidden="ctx.isHidden"
|
||||
@close="ctx.closeLibrary()"
|
||||
@select="ctx.selectOutput"
|
||||
@use-image="ctx.onLibraryUseImage"
|
||||
@use-prompt="ctx.onLibraryUsePrompt"
|
||||
@copy-prompt="ctx.onLibraryCopyPrompt"
|
||||
@copy-raw-prompt="ctx.onLibraryCopyRawPrompt"
|
||||
@load-input="ctx.onLibraryLoadInput"
|
||||
@load-input-raw="ctx.onLibraryLoadInputRaw"
|
||||
@delete="ctx.onLibraryDelete"
|
||||
@toggle-preview="ctx.toggleReveal()"
|
||||
/>
|
||||
|
||||
<div class="m-dock">
|
||||
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Slots</button>
|
||||
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Photos</button>
|
||||
<button type="button" :class="{ on: sheet==='prompt' }" @click="toggleSheet('prompt')">Prompt</button>
|
||||
<button type="button" :class="{ on: sheet==='look' }" @click="toggleSheet('look')">Look</button>
|
||||
<button type="button" :class="{ on: sheet==='gen' }" @click="toggleSheet('gen')">Gen</button>
|
||||
@@ -125,24 +166,54 @@
|
||||
|
||||
<!-- Sheets -->
|
||||
<div v-if="sheet" class="m-sheet-backdrop" @click="sheet=''" />
|
||||
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Slots">
|
||||
<aside v-if="sheet==='actions'" class="m-sheet m-sheet-actions" role="dialog" aria-label="Still actions">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Slots</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-head"><strong>Actions</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body m-actions-list">
|
||||
<button type="button" @click="runAction('use-image')">Use image</button>
|
||||
<button type="button" @click="runAction('use-prompt')">Use prompt</button>
|
||||
<button type="button" @click="runAction('copy')">Copy prompt</button>
|
||||
<button type="button" @click="runAction('copy-raw')">Copy raw prompt</button>
|
||||
<button type="button" @click="runAction('load-input')">Load input</button>
|
||||
<button type="button" @click="runAction('load-input-raw')">Load input raw</button>
|
||||
<button type="button" class="m-danger" @click="runAction('delete')">Delete</button>
|
||||
</div>
|
||||
</aside>
|
||||
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Photos">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Photos</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
<template v-if="ctx.form.mode==='music'">
|
||||
<p class="m-muted">Music uses tags + lyrics in Prompt / Gen — no hero slot.</p>
|
||||
<template v-if="ctx.form.mode==='music' || !ctx.photosPlanState?.show">
|
||||
<p class="m-muted">No photos for this task.</p>
|
||||
</template>
|
||||
<template v-else-if="ctx.photosPlanState.handoffReadonly">
|
||||
<p class="m-muted">Continues from last frame</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Studio2AssetSlot size="hero" :label="ctx.heroSlotLabel" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.identityStillId)" :hidden="ctx.slotHidden(ctx.form.identityStillId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker('identityStillId')" @upload="ctx.upload($event,'hero')" />
|
||||
<Studio2AssetSlot :label="ctx.sourceSlotLabel" :asset="ctx.sourceSlot" :hidden="ctx.slotHidden(ctx.form.mode==='extend'?ctx.form.startClipId:ctx.form.imageAId)" upload :disabled="ctx.uploading" @pick="ctx.setPicker(ctx.form.mode==='extend'?'startClipId':'imageAId')" @upload="ctx.upload($event,'source')" />
|
||||
<p v-if="ctx.editSourceRequired && !ctx.form.imageAId" class="m-hint">Required for Edit</p>
|
||||
<p v-if="ctx.slotPairHint" class="m-hint">{{ ctx.slotPairHint }}</p>
|
||||
<Studio2AssetSlot v-if="ctx.form.mode==='compose'" label="Second still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.imageBId)" :hidden="ctx.slotHidden(ctx.form.imageBId)" @pick="ctx.setPicker('imageBId')" />
|
||||
<Studio2AssetSlot v-if="ctx.form.mode==='refine'" label="Mask" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.maskId)" :hidden="ctx.slotHidden(ctx.form.maskId)" @pick="ctx.setPicker('maskId')" />
|
||||
<Studio2AssetSlot v-if="ctx.video" label="End still" :asset="ctx.stills.find((a:any)=>a.id===ctx.form.endStillId)" :hidden="ctx.slotHidden(ctx.form.endStillId)" @pick="ctx.setPicker('endStillId')" />
|
||||
<div class="m-photos-head">
|
||||
<button type="button" class="m-link" @click="ctx.toggleHideInputs()">
|
||||
{{ ctx.hideInputs ? 'Show inputs' : 'Hide inputs' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!ctx.form.photos?.length && ctx.photosPlanState.emptyHint" class="m-muted">{{ ctx.photosPlanState.emptyHint }}</p>
|
||||
<Studio2AssetSlot
|
||||
v-for="(card, index) in ctx.form.photos"
|
||||
:key="'m-photo-'+index+'-'+card.stillId"
|
||||
:asset="ctx.stillById(card.stillId)"
|
||||
:hidden="!!ctx.hideInputs"
|
||||
:role="card.role"
|
||||
:role-options="ctx.photoRoleOptions"
|
||||
@pick="ctx.setPicker('photo:'+index)"
|
||||
@remove="ctx.removePhoto(index)"
|
||||
@mention="ctx.mentionPhoto(index)"
|
||||
@update:role="ctx.setPhotoRole(index, $event)"
|
||||
/>
|
||||
<button v-if="(ctx.form.photos?.length||0) < ctx.photosPlanState.max" type="button" class="m-amber" @click="ctx.addPhoto">+ Add photo</button>
|
||||
<p v-if="ctx.photoRoleWarning" class="m-hint">{{ ctx.photoRoleWarning }}</p>
|
||||
<template v-if="ctx.showIdentityLocks">
|
||||
<label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label>
|
||||
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label>
|
||||
<p class="m-hint">hero encode + lock text — not a MiniMax face/clothes split on stills</p>
|
||||
</template>
|
||||
<p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p>
|
||||
</template>
|
||||
</div>
|
||||
@@ -157,7 +228,18 @@
|
||||
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label>
|
||||
<label class="m-label">
|
||||
<span class="m-label-row">Prompt<button type="button" class="m-link" @click.stop="ctx.clearPrompt()">Clear</button></span>
|
||||
<textarea
|
||||
:value="ctx.form.promptSections.action"
|
||||
rows="5"
|
||||
class="m-area"
|
||||
placeholder="Describe the image or the shot."
|
||||
readonly
|
||||
@focus="openPromptEditor"
|
||||
@click="openPromptEditor"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label>
|
||||
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label>
|
||||
<p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
|
||||
@@ -198,7 +280,7 @@
|
||||
<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" />
|
||||
<LoraSelect v-model="ctx.form.settings.loraStack" compact :names="ctx.pickerLoras" :use="ctx.currentLoraUse" :clip-strength="ctx.form.engine!=='qwen21' && !ctx.video" filename-subtitle :aliases="ctx.loraAliases" :triggers="ctx.loraTriggers" missing-label="None" @insert-trigger="ctx.form.promptSections.action += ' '+$event" />
|
||||
</div>
|
||||
<label class="m-label">Seed
|
||||
<div class="m-seed">
|
||||
@@ -252,7 +334,7 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-if="sheet==='queue'" class="m-sheet" role="dialog" aria-label="Queue">
|
||||
<aside v-if="sheet==='queue'" class="m-sheet m-queue-sheet-portrait" role="dialog" aria-label="Queue">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Queue</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
@@ -269,6 +351,12 @@
|
||||
<div class="m-job-status">
|
||||
<span>● {{ card.statusLabel }}</span>
|
||||
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
|
||||
<button v-else-if="card.dismissable" type="button" @click.stop="ctx.dismissFinishedCard(card)">Dismiss</button>
|
||||
</div>
|
||||
<p v-if="card.resultText" class="m-muted" style="white-space:pre-wrap">{{ card.resultText }}</p>
|
||||
<div v-if="card.resultText" class="m-job-status">
|
||||
<button type="button" @click.stop="ctx.copyCaption(card.resultText)">Copy</button>
|
||||
<button type="button" @click.stop="ctx.useCaptionAsPrompt(card.resultText)">Use as prompt</button>
|
||||
</div>
|
||||
<div v-if="card.progress" class="m-job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
|
||||
<progress
|
||||
@@ -283,6 +371,52 @@
|
||||
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-show="!queueRailCollapsed" class="m-queue-rail" aria-label="Queue">
|
||||
<div class="m-sheet-head">
|
||||
<strong>Queue</strong>
|
||||
<button type="button" @click="queueRailCollapsed=true">Hide</button>
|
||||
</div>
|
||||
<div class="m-sheet-body">
|
||||
<div
|
||||
v-for="card in ctx.queueCards"
|
||||
:key="'rail-'+card.id"
|
||||
class="m-job"
|
||||
:class="{ on: ctx.focusedJobId===card.id }"
|
||||
@click="ctx.focusQueueCard(card)"
|
||||
>
|
||||
<div class="m-job-id">{{ card.code }}</div>
|
||||
<div class="m-job-title">{{ card.title }}</div>
|
||||
<div class="m-muted">{{ card.meta }}</div>
|
||||
<div class="m-job-status">
|
||||
<span>● {{ card.statusLabel }}</span>
|
||||
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
|
||||
<button v-else-if="card.dismissable" type="button" @click.stop="ctx.dismissFinishedCard(card)">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
|
||||
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
v-if="promptEditorOpen"
|
||||
class="m-prompt-editor"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Edit prompt"
|
||||
>
|
||||
<header class="m-prompt-editor-head">
|
||||
<strong>Prompt</strong>
|
||||
<button type="button" @click="closePromptEditor">Done</button>
|
||||
</header>
|
||||
<textarea
|
||||
ref="promptEditorEl"
|
||||
v-model="promptEditorDraft"
|
||||
class="m-prompt-editor-area"
|
||||
placeholder="Describe the image or the shot."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -291,12 +425,60 @@ export type Studio2MobileCtx = Record<string, any>
|
||||
|
||||
const props = defineProps<{ ctx: Studio2MobileCtx }>()
|
||||
const sheet = ref('')
|
||||
const queueRailCollapsed = ref(false)
|
||||
const touchStart = ref<{ x: number; y: number } | null>(null)
|
||||
const promptEditorOpen = ref(false)
|
||||
const promptEditorDraft = ref('')
|
||||
const promptEditorEl = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function openPromptEditor() {
|
||||
promptEditorDraft.value = String(props.ctx.form?.promptSections?.action || '')
|
||||
promptEditorOpen.value = true
|
||||
nextTick(() => {
|
||||
promptEditorEl.value?.focus()
|
||||
const el = promptEditorEl.value
|
||||
if (el) {
|
||||
const n = el.value.length
|
||||
el.setSelectionRange(n, n)
|
||||
}
|
||||
})
|
||||
}
|
||||
function closePromptEditor() {
|
||||
if (props.ctx.form?.promptSections) props.ctx.form.promptSections.action = promptEditorDraft.value
|
||||
promptEditorOpen.value = false
|
||||
}
|
||||
|
||||
function toggleSheet(id: string) {
|
||||
sheet.value = sheet.value === id ? '' : id
|
||||
}
|
||||
function closeMenus() {
|
||||
sheet.value = ''
|
||||
if (promptEditorOpen.value) closePromptEditor()
|
||||
}
|
||||
function runAction(kind: 'use-image' | 'use-prompt' | 'copy' | 'copy-raw' | 'load-input' | 'load-input-raw' | 'delete') {
|
||||
sheet.value = ''
|
||||
if (kind === 'use-image') props.ctx.applyUseImage?.()
|
||||
else if (kind === 'use-prompt') props.ctx.applyUsePrompt?.()
|
||||
else if (kind === 'copy') props.ctx.copySelectedPrompt?.()
|
||||
else if (kind === 'copy-raw') props.ctx.copySelectedRawPrompt?.()
|
||||
else if (kind === 'load-input') props.ctx.loadInputUsed?.()
|
||||
else if (kind === 'load-input-raw') props.ctx.loadInputRaw?.()
|
||||
else if (kind === 'delete') props.ctx.deleteOutput?.()
|
||||
}
|
||||
function onStageTouchStart(e: TouchEvent) {
|
||||
const t = e.changedTouches[0]
|
||||
if (!t) return
|
||||
touchStart.value = { x: t.clientX, y: t.clientY }
|
||||
}
|
||||
function onStageTouchEnd(e: TouchEvent) {
|
||||
const start = touchStart.value
|
||||
touchStart.value = null
|
||||
const t = e.changedTouches[0]
|
||||
if (!start || !t || (props.ctx.family || []).length < 2) return
|
||||
const dx = t.clientX - start.x
|
||||
const dy = t.clientY - start.y
|
||||
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.4) return
|
||||
props.ctx.stepFamily?.(dx < 0 ? 1 : -1)
|
||||
}
|
||||
watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||
</script>
|
||||
@@ -397,64 +579,116 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||
background: #080808;
|
||||
}
|
||||
.m-stage > img,
|
||||
.m-stage > video {
|
||||
.m-stage > video,
|
||||
.m-stage-still {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
|
||||
.m-eye {
|
||||
.m-stage-still--veiled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.m-hidden-note {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
z-index: 3;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
|
||||
.m-stage-chrome {
|
||||
position: absolute;
|
||||
top: calc(8px + env(safe-area-inset-top, 0px));
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.m-stage-chrome > *,
|
||||
.m-stage-chrome-end {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.m-stage-chrome-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.m-icon-btn {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background: #000a;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.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 {
|
||||
.m-library-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
display: grid;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: #000a;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--amber);
|
||||
color: var(--amber-on);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.m-tile-trash { right: 20px; }
|
||||
.m-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #000a;
|
||||
color: var(--text);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.m-nav-prev { left: 8px; }
|
||||
.m-nav-next { right: 8px; }
|
||||
.m-photos-head { margin-bottom: 8px; }
|
||||
.m-sheet-actions {
|
||||
max-height: min(50vh, 100%);
|
||||
}
|
||||
.m-actions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.m-actions-list button {
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #1a1a1d;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
padding: 0 14px;
|
||||
}
|
||||
.m-actions-list .m-danger { color: #fca5a5; }
|
||||
.m-dock {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr) 120px;
|
||||
@@ -524,6 +758,7 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||
gap: 10px;
|
||||
}
|
||||
.m-label { display: block; font-size: 11px; color: var(--muted); }
|
||||
.m-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
|
||||
.m-area, .m-input {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
@@ -553,22 +788,6 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||
.m-neg-chip button { border: 0; background: transparent; color: inherit; }
|
||||
.m-neg-add { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.m-neg-add .m-input { flex: 1; margin-top: 0; }
|
||||
.m-strip-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.m-strip-actions button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-seed { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.m-seed .m-input { margin-top: 0; flex: 1; }
|
||||
.m-dice {
|
||||
@@ -592,4 +811,81 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||
.m-job-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||
.m-job-progress-bar { flex: 1; height: 4px; accent-color: var(--amber); }
|
||||
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
|
||||
.m-prompt-editor {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0e0e10;
|
||||
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0);
|
||||
}
|
||||
.m-prompt-editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.m-prompt-editor-head button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--amber);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.m-queue-chip-landscape { display: none; }
|
||||
.m-queue-rail { display: none; }
|
||||
@media (orientation: landscape) {
|
||||
.m-queue-chip-portrait,
|
||||
.m-queue-sheet-portrait { display: none !important; }
|
||||
.m-queue-chip-landscape { display: inline-flex; align-items: center; }
|
||||
.m-shell { position: relative; }
|
||||
.m-queue-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
width: min(42vw, 16rem);
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.m-shell[data-queue-collapsed='1'] .m-queue-rail { display: none; }
|
||||
.m-queue-rail .m-sheet-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.m-queue-rail .m-sheet-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.m-stage { padding-right: 0; }
|
||||
.m-shell:not([data-queue-collapsed='1']) .m-stage,
|
||||
.m-shell:not([data-queue-collapsed='1']) .m-dock,
|
||||
.m-shell:not([data-queue-collapsed='1']) .m-chips {
|
||||
margin-right: min(42vw, 16rem);
|
||||
}
|
||||
}
|
||||
.m-prompt-editor-area {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border: 0;
|
||||
resize: none;
|
||||
background: #0e0e10;
|
||||
color: #f3f1ea;
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
line-height: 1.45;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<div class="flex flex-wrap gap-2"><button v-for="(beat,i) in model.cameraBeats" :key="i" type="button" class="rounded-lg border px-3 py-2" :class="active===i?'border-amber-300 text-amber-200':'border-white/20'" @click="active=i">{{ i+1 }} · {{ beat.moves.join(' + ') }} · {{ beat.durationSec == null ? 'to end' : `${beat.durationSec}s` }}</button></div>
|
||||
<div v-if="current" class="space-y-3 rounded-xl border border-white/15 p-3">
|
||||
<div class="flex flex-wrap gap-2"><button v-for="move in cameraMoves" :key="move" type="button" :aria-pressed="current.moves.includes(move)" class="rounded-full border px-3 py-1 text-sm" :class="current.moves.includes(move)?'border-amber-300 bg-amber-300/10 text-amber-200':'border-white/15'" @click="model.cameraBeats[active]=toggleMove(current,move)">{{ move.replaceAll('-',' ') }}</button></div>
|
||||
<div class="flex flex-wrap gap-3"><label>Amplitude<select v-model="current.amplitude" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['subtle','moderate','large']" :key="v">{{ v }}</option></select></label><label>Speed<select v-model="current.speed" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['slow','medium','fast']" :key="v">{{ v }}</option></select></label><label>Duration<select v-model="current.durationSec" class="block rounded bg-zinc-800 p-2"><option :value="null">To end</option><option v-for="n in 30" :key="n" :value="n/2">{{ n/2 }}s</option></select></label><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
|
||||
<div v-if="!isHold" class="flex flex-wrap gap-3"><label>Amplitude<select v-model="current.amplitude" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['subtle','moderate','large']" :key="v">{{ v }}</option></select></label><label>Speed<select v-model="current.speed" class="block rounded bg-zinc-800 p-2"><option v-for="v in ['slow','medium','fast']" :key="v">{{ v }}</option></select></label><label>Duration<select v-model="current.durationSec" class="block rounded bg-zinc-800 p-2"><option :value="null">To end</option><option v-for="n in 30" :key="n" :value="n/2">{{ n/2 }}s</option></select></label><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
|
||||
<div v-else class="flex flex-wrap gap-3"><button @click="model.cameraBeats.splice(active,1);active=Math.max(0,active-1)">Remove beat</button></div>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm text-amber-200"><button @click="addBeat">+ Camera beat</button><button @click="model.cameraBeats=[];model.camera='';active=0">Clear camera</button></div>
|
||||
</fieldset>
|
||||
@@ -17,6 +18,7 @@
|
||||
import {cameraMoves,soundscapeOptions,normalizeBeat,toggleMove} from '~/shared/studio2/prompt.mjs'
|
||||
const model=defineModel<any>({required:true}),active=ref(0)
|
||||
const current=computed(()=>model.value.cameraBeats[active.value])
|
||||
const isHold=computed(()=>!!current.value?.moves?.includes?.('hold'))
|
||||
function addBeat(){const last=model.value.cameraBeats.at(-1);if(last?.durationSec==null&&last)last.durationSec=2;model.value.cameraBeats.push(normalizeBeat());active.value=model.value.cameraBeats.length-1}
|
||||
function toggleSound(sound:string){const chips=model.value.soundscapeChips;model.value.soundscapeChips=chips.includes(sound)?chips.filter((s:string)=>s!==sound):[...chips,sound]}
|
||||
</script>
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ Restart Comfy **only when idle** (`COMFY_CONTROL_URL/status` → `gpu.busy=false
|
||||
|
||||
- Engine key: `qwen21` · UI label: **Qwen 2.1**
|
||||
- **Generate** (T2I) and **Edit** (same checkpoint, second graph). Compose / Iterate / Video / Extend / Music stay disabled unless a graph exists.
|
||||
- Edit requires Start still → `images.image_1`; prompt slots use `<image1>` / `<image2>`. If the user omits `<image1>`, the runner prepends `Keep the identity, face, body, and pose of the person in <image1>. Apply only this change:` then their text. Hero is Klein-only and is ignored (never image_1).
|
||||
- Edit maps Photos roles → graph sockets: Photo to change → `images.image_1`, Outfit / object or Extra → `image_2`. The inspector never shows `<image1>`; the runner injects tags into the TextEncode string on submit (and expands Mention phrases like “this photo” / “the outfit photo”). If `<image1>` is still missing, prepend the keep-identity stanza. Klein hero-ref is off for Qwen.
|
||||
- Face / outfit locks stay Klein semantics — they do not drive Qwen slots.
|
||||
- Sampler defaults: euler / simple / cfg **1** / steps **25**. Edit uses `QwenImage21Cache` (device auto, dtype int8). T2I uses `ModelSamplingAuraFlow` shift **3.1**.
|
||||
- Default Generate canvas follows the bench Aspect control on the Qwen-safe ~1 MP grid (`EmptyLatentImage`): 1:1 → 1024×1024, 16:9 → 1536×864, 9:16 → 864×1536 (and the other table rows). PE `wh_ratio` is advisory only and never sizes the canvas. Do not use native 2K bins on 16 GB.
|
||||
|
||||
+57
-34
@@ -634,24 +634,6 @@ const allCount = computed(() => {
|
||||
sort: 'date-desc'
|
||||
}).length
|
||||
})
|
||||
const folderChipCounts = computed(() => {
|
||||
const map: Record<string, { videos: number; images: number; music: number }> = {}
|
||||
for (const clip of clips.value) {
|
||||
if (clip.hasVideo === false) continue
|
||||
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0, music: 0 })
|
||||
row.videos += 1
|
||||
}
|
||||
for (const still of stills.value) {
|
||||
if (still.role !== 'output') continue
|
||||
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0, music: 0 })
|
||||
row.images += 1
|
||||
}
|
||||
for (const track of tracks.value) {
|
||||
const row = map[track.folderId] || (map[track.folderId] = { videos: 0, images: 0, music: 0 })
|
||||
row.music += 1
|
||||
}
|
||||
return map
|
||||
})
|
||||
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
|
||||
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
|
||||
const focusedStill = computed(() => {
|
||||
@@ -771,7 +753,8 @@ function toast(message: string) {
|
||||
}, 4200)
|
||||
}
|
||||
|
||||
function applyLibrary(data: unknown) {
|
||||
function applyLibrary(data: unknown, opts?: { replace?: boolean }) {
|
||||
const replace = opts?.replace !== false
|
||||
const payload = (data || {}) as {
|
||||
id?: string
|
||||
hideInput?: boolean
|
||||
@@ -780,6 +763,8 @@ function applyLibrary(data: unknown) {
|
||||
clips?: LibraryClip[]
|
||||
stills?: LibraryStill[]
|
||||
tracks?: LibraryTrack[]
|
||||
nextCursor?: string | null
|
||||
hasMore?: boolean
|
||||
}
|
||||
if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
|
||||
if (typeof payload.hideInput === 'boolean') {
|
||||
@@ -792,27 +777,62 @@ function applyLibrary(data: unknown) {
|
||||
}
|
||||
return
|
||||
}
|
||||
folders.value = payload.folders || []
|
||||
if (payload.folders?.length) folders.value = payload.folders
|
||||
if (replace) {
|
||||
clips.value = payload.clips || []
|
||||
stills.value = payload.stills || []
|
||||
tracks.value = payload.tracks || []
|
||||
} else {
|
||||
const merge = <T extends { id: string }>(prev: T[], next: T[]) => {
|
||||
const map = new Map<string, T>()
|
||||
for (const item of prev) map.set(item.id, item)
|
||||
for (const item of next) map.set(item.id, item)
|
||||
return [...map.values()]
|
||||
}
|
||||
clips.value = merge(clips.value, payload.clips || [])
|
||||
stills.value = merge(stills.value, payload.stills || [])
|
||||
tracks.value = merge(tracks.value, payload.tracks || [])
|
||||
}
|
||||
libraryNextCursor.value = payload.nextCursor || null
|
||||
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
|
||||
folderId.value = folders.value[0]?.id || 'all'
|
||||
}
|
||||
}
|
||||
|
||||
const libraryNextCursor = ref<string | null>(null)
|
||||
let libraryFetchGen = 0
|
||||
|
||||
async function fetchLibraryPage(cursor: string | null) {
|
||||
const qs = new URLSearchParams({ limit: '50' })
|
||||
if (folderId.value && folderId.value !== 'all') qs.set('folderId', folderId.value)
|
||||
if (filter.value === 'images') qs.set('type', 'images')
|
||||
else if (filter.value === 'videos') qs.set('type', 'videos')
|
||||
else if (filter.value === 'music') qs.set('type', 'music')
|
||||
if (cursor) qs.set('cursor', cursor)
|
||||
return await $fetch<any>(`/api/library?${qs.toString()}`)
|
||||
}
|
||||
|
||||
async function loadLibrary() {
|
||||
applyLibrary(await $fetch('/api/library?light=1'))
|
||||
// First page paints immediately; remaining pages append in the background
|
||||
const gen = ++libraryFetchGen
|
||||
const first = await fetchLibraryPage(null)
|
||||
if (gen !== libraryFetchGen) return
|
||||
applyLibrary(first, { replace: true })
|
||||
let cursor = first?.nextCursor || null
|
||||
while (cursor && gen === libraryFetchGen) {
|
||||
const page = await fetchLibraryPage(cursor)
|
||||
if (gen !== libraryFetchGen) return
|
||||
applyLibrary(page, { replace: false })
|
||||
cursor = page?.nextCursor || null
|
||||
}
|
||||
}
|
||||
|
||||
function folderChipCount(folder: LibraryFolder) {
|
||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
||||
const row = folderChipCounts.value[folder.id]
|
||||
if (!row) return 0
|
||||
if (filter.value === 'videos') return row.videos
|
||||
if (filter.value === 'images') return row.images
|
||||
if (filter.value === 'music') return row.music
|
||||
return row.videos + row.images + row.music
|
||||
// Prefer server totals so chips match the full folder before pages finish appending
|
||||
if (filter.value === 'videos') return folder.clipCount || 0
|
||||
if (filter.value === 'images') return folder.imageCount || 0
|
||||
if (filter.value === 'music') return folder.trackCount || 0
|
||||
return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
||||
}
|
||||
|
||||
function folderChipClass(id: string) {
|
||||
@@ -881,7 +901,7 @@ async function toggleFocusedHidden() {
|
||||
function thumbSrc(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
|
||||
if (entry.kind === 'track') return ''
|
||||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}?thumb=1`
|
||||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}/thumb`
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -907,7 +927,7 @@ function partThumb(part: LibraryClip | LibraryStill) {
|
||||
// Prefer this clip’s first frame so siblings don’t all look like the Start thumb.
|
||||
return `/api/library/clips/${clip.id}/first-frame?thumb=1&v=${encodeURIComponent(clip.id)}`
|
||||
}
|
||||
return `/api/library/stills/${part.id}?thumb=1`
|
||||
return `/api/library/stills/${part.id}/thumb`
|
||||
}
|
||||
|
||||
function focusPart(id: string) {
|
||||
@@ -1278,6 +1298,10 @@ watch([folderId, filter], () => {
|
||||
watch(search, () => {
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
})
|
||||
watch([folderId, filter], () => {
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
void loadLibrary()
|
||||
})
|
||||
watch([cols, rowH], ([nextCols, nextRowH], [prevCols, prevRowH]) => {
|
||||
const el = scroller.value
|
||||
if (!el || !prevRowH) return
|
||||
@@ -1309,10 +1333,9 @@ let resizeObserver: ResizeObserver | null = null
|
||||
let observedScroller: HTMLElement | null = null
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onKey)
|
||||
const [me] = await Promise.all([
|
||||
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })),
|
||||
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
|
||||
])
|
||||
// Do not block chrome/grid on the full catalog — first page paints via loadLibrary's first apply
|
||||
void loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
|
||||
const me = await $fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
|
||||
instanceName.value = me.instanceName || instanceName.value
|
||||
await nextTick()
|
||||
measure()
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
<template>
|
||||
<div class="min-h-dvh min-w-0">
|
||||
<AppHeader subtitle="Prompt Builder" current="prompt-builder" />
|
||||
|
||||
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)]">
|
||||
<section class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-display text-2xl font-bold">Prompt Builder</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-zinc-400">
|
||||
Build multi-image prompt sequences. Copy the preview into Generate with Batch on — scenes split on the double blank line.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="startNew"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="busy || !name.trim()"
|
||||
@click="save"
|
||||
>
|
||||
{{ busy === 'save' ? 'Saving…' : selectedId ? 'Save' : 'Save sequence' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>
|
||||
<p v-else-if="notice" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">{{ notice }}</p>
|
||||
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium text-zinc-300">Sequence name</span>
|
||||
<input
|
||||
v-model="name"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="80"
|
||||
placeholder="e.g. Badge set A"
|
||||
>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">Save key only — never copied into the prompt text.</p>
|
||||
</label>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-zinc-300">Prompt order</span>
|
||||
<p class="text-[11px] text-zinc-500">Drag to reorder, or use the arrows. Copied text and the scene editor follow this order.</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(key, index) in sectionOrder"
|
||||
:key="key"
|
||||
class="flex items-center gap-2 rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2"
|
||||
draggable="true"
|
||||
@dragstart="onSectionDragStart(index, $event)"
|
||||
@dragover.prevent="onSectionDragOver($event)"
|
||||
@drop.prevent="onSectionDrop(index)"
|
||||
>
|
||||
<span class="cursor-grab select-none text-zinc-500 active:cursor-grabbing" title="Drag to reorder">⠿</span>
|
||||
<span class="min-w-0 flex-1 text-sm text-zinc-200">{{ sectionLabel(key) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Move up"
|
||||
:disabled="index === 0"
|
||||
@click="moveSection(index, -1)"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Move down"
|
||||
:disabled="index >= sectionOrder.length - 1"
|
||||
@click="moveSection(index, 1)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-zinc-300">Characters</span>
|
||||
<p class="text-[11px] text-zinc-500">Sequence-wide. Toggle and outfit per scene below.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="addCharacter"
|
||||
>
|
||||
Add character
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(character, cIndex) in characters"
|
||||
:key="character.id"
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[11px] uppercase tracking-wide text-zinc-500">Character {{ cIndex + 1 }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearCharacter(cIndex)">Clear</button>
|
||||
<button
|
||||
v-if="characters.length > 1"
|
||||
type="button"
|
||||
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
|
||||
@click="removeCharacter(cIndex)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="character.name"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="120"
|
||||
placeholder="Name"
|
||||
>
|
||||
<textarea
|
||||
v-model="character.description"
|
||||
rows="2"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Description"
|
||||
/>
|
||||
<div class="space-y-2 border-t border-white/5 pt-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Outfits</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="addOutfit(cIndex)"
|
||||
>
|
||||
Add outfit
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!character.outfits.length" class="text-[11px] text-zinc-600">No outfits yet.</p>
|
||||
<div
|
||||
v-for="(outfit, oIndex) in character.outfits"
|
||||
:key="outfit.id"
|
||||
class="rounded-xl border border-white/5 bg-zinc-950/50 p-2 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[11px] text-zinc-500">Outfit {{ oIndex + 1 }}</span>
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="removeOutfit(cIndex, oIndex)">Remove</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="outfit.name"
|
||||
class="w-full rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="120"
|
||||
placeholder="Outfit name"
|
||||
>
|
||||
<textarea
|
||||
v-model="outfit.description"
|
||||
rows="2"
|
||||
class="w-full rounded-lg border border-white/10 bg-zinc-950/70 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="What they are wearing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-zinc-300">Sets</span>
|
||||
<p class="text-[11px] text-zinc-500">Sequence-wide locations and props. Toggle and framing per scene below.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="addSet"
|
||||
>
|
||||
Add set
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!sets.length" class="text-[11px] text-zinc-600">No sets yet.</p>
|
||||
<div
|
||||
v-for="(set, sIndex) in sets"
|
||||
:key="set.id"
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[11px] uppercase tracking-wide text-zinc-500">Set {{ sIndex + 1 }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearSet(sIndex)">Clear</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
|
||||
@click="removeSet(sIndex)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="block text-sm">
|
||||
<input
|
||||
v-model="set.name"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="120"
|
||||
placeholder="Name"
|
||||
>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">Label only — not copied into the prompt.</p>
|
||||
</label>
|
||||
<div class="space-y-2 border-t border-white/5 pt-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Framings</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="addFraming(sIndex)"
|
||||
>
|
||||
Add framing
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(framing, fIndex) in set.framings"
|
||||
:key="framing.id"
|
||||
class="rounded-xl border border-white/5 bg-zinc-950/50 p-2 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[11px] text-zinc-500">Framing {{ fIndex + 1 }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearFraming(sIndex, fIndex)">Clear</button>
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="removeFraming(sIndex, fIndex)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="framing.label"
|
||||
class="w-full rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="120"
|
||||
:placeholder="framingLabelPlaceholder(fIndex)"
|
||||
>
|
||||
<textarea
|
||||
v-model="framing.text"
|
||||
rows="2"
|
||||
class="w-full rounded-lg border border-white/10 bg-zinc-950/70 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Place / location text copied into the prompt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-white/10 pb-2">
|
||||
<button
|
||||
v-for="(scene, index) in scenes"
|
||||
:key="scene.id"
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1.5 text-xs transition"
|
||||
:class="index === activeIndex
|
||||
? 'min-h-11 bg-amber-400/15 px-3 py-2 text-sm font-semibold text-amber-100 ring-1 ring-amber-300/40'
|
||||
: 'min-h-11 border border-white/25 px-3 py-2 text-sm font-semibold text-zinc-300 hover:bg-white/5 hover:text-zinc-100'"
|
||||
@click="activeIndex = index"
|
||||
>
|
||||
{{ scene.title || `Scene ${index + 1}` }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="addNextScene"
|
||||
>
|
||||
Next scene
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Move scene earlier"
|
||||
:disabled="activeIndex <= 0"
|
||||
@click="moveScene(activeIndex, -1)"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Move scene later"
|
||||
:disabled="activeIndex >= scenes.length - 1"
|
||||
@click="moveScene(activeIndex, 1)"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="scenes.length <= 1"
|
||||
@click="deleteScene(activeIndex)"
|
||||
>
|
||||
Delete scene
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeScene" class="space-y-4">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 flex items-center justify-between gap-2 font-medium text-zinc-300">
|
||||
Scene tab name
|
||||
<span class="text-[11px] font-normal text-zinc-500">optional</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="activeScene.title"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="60"
|
||||
:placeholder="`Scene ${activeIndex + 1}`"
|
||||
>
|
||||
</label>
|
||||
|
||||
<template v-for="sectionKey in sectionOrder" :key="'scene-section-' + sectionKey">
|
||||
<div v-if="sectionKey === 'style'" class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-zinc-300">Style</span>
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.style = ''">Clear</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="activeScene.style"
|
||||
rows="3"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Visual style for this image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="sectionKey === 'settings'" class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-zinc-300">Settings</span>
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.settings = ''">Clear</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="activeScene.settings"
|
||||
rows="3"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Lighting, framing, technical notes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="sectionKey === 'set' && sets.length" class="space-y-3">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-zinc-300">Sets in this scene</span>
|
||||
<p class="text-[11px] text-zinc-500">Toggle which set blocks appear. Pick a framing for each on set.</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="set in sets"
|
||||
:key="'scene-set-'+set.id"
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2.5 space-y-2"
|
||||
>
|
||||
<label class="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
||||
<span class="min-w-0 truncate">{{ set.name || 'Unnamed set' }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-amber-400"
|
||||
:checked="isSetOn(set.id)"
|
||||
@change="setSetOn(set.id, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
</label>
|
||||
<label v-if="isSetOn(set.id)" class="block text-[11px] text-zinc-500">
|
||||
Framing
|
||||
<select
|
||||
class="mt-1 w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"
|
||||
:value="activeSetFramingId(set.id) || ''"
|
||||
@change="setSetFraming(set.id, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option v-for="framing in set.framings" :key="framing.id" :value="framing.id">
|
||||
{{ framing.label || 'Untitled framing' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="sectionKey === 'characters'" class="space-y-3">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-zinc-300">In this scene</span>
|
||||
<p class="text-[11px] text-zinc-500">Toggle who appears. Pick an outfit for each on character.</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="character in characters"
|
||||
:key="'scene-'+character.id"
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2.5 space-y-2"
|
||||
>
|
||||
<label class="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
||||
<span class="min-w-0 truncate">{{ character.name || 'Unnamed character' }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-amber-400"
|
||||
:checked="isCharacterOn(character.id)"
|
||||
@change="setCharacterOn(character.id, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
</label>
|
||||
<label v-if="isCharacterOn(character.id)" class="block text-[11px] text-zinc-500">
|
||||
Outfit
|
||||
<select
|
||||
class="mt-1 w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"
|
||||
:value="activeOutfitId(character.id) || ''"
|
||||
@change="setCharacterOutfit(character.id, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option v-for="outfit in character.outfits" :key="outfit.id" :value="outfit.id">
|
||||
{{ outfit.name || 'Untitled outfit' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="sectionKey === 'prompt'" class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium text-zinc-300">Prompt</span>
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.prompt = ''">Clear</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="activeScene.prompt"
|
||||
rows="5"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="What happens in this image"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-zinc-950/30">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-zinc-200">Preview</h3>
|
||||
<p class="text-[11px] text-zinc-500">Assembled text ready to copy. Sequence name is omitted.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!activeSceneText"
|
||||
@click="copyScene"
|
||||
>
|
||||
{{ copiedScene ? 'Scene copied' : 'Copy scene' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!previewText"
|
||||
@click="copyPreview"
|
||||
>
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
@click="previewOpen = !previewOpen"
|
||||
>
|
||||
{{ previewOpen ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
v-if="previewOpen"
|
||||
class="max-h-[min(28rem,50vh)] overflow-auto whitespace-pre-wrap px-4 py-3 text-xs text-zinc-300 [overflow-wrap:anywhere]"
|
||||
>{{ previewText || 'Fill a scene to see the assembled prompt.' }}</pre>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="min-w-0 space-y-4">
|
||||
<section class="glass rounded-3xl p-5 sm:p-6">
|
||||
<h3 class="font-display text-lg font-bold">Saved sequences</h3>
|
||||
<p class="mt-1 text-xs text-zinc-500">Stored with your other library data. Open one to edit.</p>
|
||||
<p v-if="!sequences.length" class="mt-4 rounded-2xl border border-white/5 bg-zinc-950/40 px-4 py-6 text-center text-sm text-zinc-500">
|
||||
Nothing saved yet.
|
||||
</p>
|
||||
<div v-else class="mt-4 space-y-2">
|
||||
<button
|
||||
v-for="sequence in sequences"
|
||||
:key="sequence.id"
|
||||
type="button"
|
||||
class="w-full rounded-2xl border p-3 text-left transition"
|
||||
:class="sequence.id === selectedId
|
||||
? 'border-amber-300/30 bg-amber-400/5'
|
||||
: 'border-white/5 bg-zinc-950/40 hover:border-white/15'"
|
||||
@click="select(sequence)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-zinc-100">{{ sequence.name }}</p>
|
||||
<p class="mt-0.5 text-[11px] text-zinc-500">
|
||||
{{ sequence.scenes?.length || 0 }} scene{{ (sequence.scenes?.length || 0) === 1 ? '' : 's' }}
|
||||
· {{ sequence.characters?.length || 0 }} character{{ (sequence.characters?.length || 0) === 1 ? '' : 's' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"
|
||||
@click.stop="remove(sequence)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
PromptSequence,
|
||||
PromptSequenceCharacter,
|
||||
PromptSequenceScene,
|
||||
PromptSequenceSectionKey,
|
||||
PromptSequenceSet
|
||||
} from '~/utils/promptSequences'
|
||||
import {
|
||||
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||
PROMPT_SEQUENCE_FRAMING_SUGGESTIONS,
|
||||
assembleSceneText,
|
||||
assembleSequenceText,
|
||||
cloneSceneForNext,
|
||||
emptyCharacter,
|
||||
emptyFraming,
|
||||
emptyOutfit,
|
||||
emptyScene,
|
||||
emptySet,
|
||||
ensureSceneStatesForCharacters,
|
||||
ensureSceneStatesForSets,
|
||||
normalizePromptSequence,
|
||||
normalizeSectionOrder
|
||||
} from '~/utils/promptSequences'
|
||||
|
||||
useHead({ title: 'Prompt Builder' })
|
||||
|
||||
const SECTION_LABELS: Record<PromptSequenceSectionKey, string> = {
|
||||
style: 'Style',
|
||||
settings: 'Settings',
|
||||
set: 'Set',
|
||||
characters: 'Characters',
|
||||
prompt: 'Prompt'
|
||||
}
|
||||
|
||||
const sequences = ref<PromptSequence[]>([])
|
||||
const selectedId = ref('')
|
||||
const name = ref('')
|
||||
const sectionOrder = ref<PromptSequenceSectionKey[]>([...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
|
||||
const characters = ref<PromptSequenceCharacter[]>([emptyCharacter()])
|
||||
const sets = ref<PromptSequenceSet[]>([])
|
||||
const scenes = ref<PromptSequenceScene[]>([emptyScene(1, characters.value, sets.value)])
|
||||
const activeIndex = ref(0)
|
||||
const previewOpen = ref(true)
|
||||
const busy = ref('')
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
const copied = ref(false)
|
||||
const copiedScene = ref(false)
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let copySceneTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let dragSectionIndex: number | null = null
|
||||
|
||||
const activeScene = computed(() => scenes.value[activeIndex.value] || null)
|
||||
const previewText = computed(() => assembleSequenceText({
|
||||
sectionOrder: sectionOrder.value,
|
||||
characters: characters.value,
|
||||
sets: sets.value,
|
||||
scenes: scenes.value
|
||||
}))
|
||||
const activeSceneText = computed(() => {
|
||||
if (!activeScene.value) return ''
|
||||
return assembleSceneText(activeScene.value, characters.value, sets.value, sectionOrder.value)
|
||||
})
|
||||
|
||||
function sectionLabel(key: PromptSequenceSectionKey) {
|
||||
return SECTION_LABELS[key] || key
|
||||
}
|
||||
|
||||
function moveSection(index: number, delta: number) {
|
||||
const next = index + delta
|
||||
if (next < 0 || next >= sectionOrder.value.length) return
|
||||
const copy = [...sectionOrder.value]
|
||||
const [item] = copy.splice(index, 1)
|
||||
copy.splice(next, 0, item)
|
||||
sectionOrder.value = copy
|
||||
}
|
||||
|
||||
function onSectionDragStart(index: number, event: DragEvent) {
|
||||
dragSectionIndex = index
|
||||
event.dataTransfer?.setData('text/plain', String(index))
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function onSectionDragOver(event: DragEvent) {
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function onSectionDrop(index: number) {
|
||||
if (dragSectionIndex == null || dragSectionIndex === index) {
|
||||
dragSectionIndex = null
|
||||
return
|
||||
}
|
||||
const copy = [...sectionOrder.value]
|
||||
const [item] = copy.splice(dragSectionIndex, 1)
|
||||
copy.splice(index, 0, item)
|
||||
sectionOrder.value = copy
|
||||
dragSectionIndex = null
|
||||
}
|
||||
|
||||
function framingLabelPlaceholder(index: number) {
|
||||
const suggestion = PROMPT_SEQUENCE_FRAMING_SUGGESTIONS[index % PROMPT_SEQUENCE_FRAMING_SUGGESTIONS.length]
|
||||
return suggestion || 'wide / medium / narrow'
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
selectedId.value = ''
|
||||
name.value = ''
|
||||
sectionOrder.value = [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]
|
||||
characters.value = [emptyCharacter()]
|
||||
sets.value = []
|
||||
scenes.value = [emptyScene(1, characters.value, sets.value)]
|
||||
activeIndex.value = 0
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
}
|
||||
|
||||
function select(sequence: PromptSequence) {
|
||||
const normalized = normalizePromptSequence(sequence) || sequence
|
||||
selectedId.value = normalized.id
|
||||
name.value = normalized.name
|
||||
sectionOrder.value = normalizeSectionOrder(normalized.sectionOrder)
|
||||
characters.value = (normalized.characters?.length ? normalized.characters : [emptyCharacter()]).map(character => ({
|
||||
...emptyCharacter(),
|
||||
...character,
|
||||
outfits: (character.outfits || []).map(outfit => ({ ...emptyOutfit(), ...outfit }))
|
||||
}))
|
||||
sets.value = (normalized.sets || []).map(set => ({
|
||||
...emptySet(),
|
||||
...set,
|
||||
framings: (set.framings?.length ? set.framings : [emptyFraming()]).map(framing => ({ ...emptyFraming(), ...framing }))
|
||||
}))
|
||||
scenes.value = (normalized.scenes?.length ? normalized.scenes : [emptyScene(1, characters.value, sets.value)]).map((scene, index) => ({
|
||||
...emptyScene(index + 1, characters.value, sets.value),
|
||||
...scene,
|
||||
setStates: (scene.setStates || []).map(state => ({
|
||||
setId: state.setId,
|
||||
on: state.on !== false,
|
||||
framingId: state.framingId || null
|
||||
})),
|
||||
characterStates: (scene.characterStates || []).map(state => ({
|
||||
characterId: state.characterId,
|
||||
on: state.on !== false,
|
||||
outfitId: state.outfitId || null
|
||||
}))
|
||||
}))
|
||||
ensureSceneStatesForSets(scenes.value, sets.value)
|
||||
ensureSceneStatesForCharacters(scenes.value, characters.value)
|
||||
activeIndex.value = 0
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
}
|
||||
|
||||
function addNextScene() {
|
||||
const previous = scenes.value[scenes.value.length - 1] || emptyScene(1, characters.value, sets.value)
|
||||
const next = cloneSceneForNext(previous, scenes.value.length + 1)
|
||||
scenes.value.push(next)
|
||||
activeIndex.value = scenes.value.length - 1
|
||||
}
|
||||
|
||||
function moveScene(index: number, delta: number) {
|
||||
const newIndex = index + delta
|
||||
if (newIndex < 0 || newIndex >= scenes.value.length) return
|
||||
const [scene] = scenes.value.splice(index, 1)
|
||||
if (!scene) return
|
||||
scenes.value.splice(newIndex, 0, scene)
|
||||
if (activeIndex.value === index) {
|
||||
activeIndex.value = newIndex
|
||||
} else if (index < activeIndex.value && newIndex >= activeIndex.value) {
|
||||
activeIndex.value--
|
||||
} else if (index > activeIndex.value && newIndex <= activeIndex.value) {
|
||||
activeIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
function deleteScene(index: number) {
|
||||
if (scenes.value.length <= 1) return
|
||||
scenes.value.splice(index, 1)
|
||||
if (activeIndex.value >= scenes.value.length) {
|
||||
activeIndex.value = scenes.value.length - 1
|
||||
} else if (activeIndex.value > index) {
|
||||
activeIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
function addCharacter() {
|
||||
const character = emptyCharacter()
|
||||
characters.value.push(character)
|
||||
ensureSceneStatesForCharacters(scenes.value, characters.value, {
|
||||
newCharacterId: character.id,
|
||||
turnOnSceneIndex: activeIndex.value
|
||||
})
|
||||
}
|
||||
|
||||
function clearCharacter(index: number) {
|
||||
const character = characters.value[index]
|
||||
if (!character) return
|
||||
character.name = ''
|
||||
character.description = ''
|
||||
character.outfits = []
|
||||
for (const scene of scenes.value) {
|
||||
const state = scene.characterStates.find(item => item.characterId === character.id)
|
||||
if (state) state.outfitId = null
|
||||
}
|
||||
}
|
||||
|
||||
function removeCharacter(index: number) {
|
||||
if (characters.value.length <= 1) return
|
||||
const [removed] = characters.value.splice(index, 1)
|
||||
if (!removed) return
|
||||
for (const scene of scenes.value) {
|
||||
scene.characterStates = scene.characterStates.filter(state => state.characterId !== removed.id)
|
||||
}
|
||||
}
|
||||
|
||||
function addOutfit(characterIndex: number) {
|
||||
const character = characters.value[characterIndex]
|
||||
if (!character) return
|
||||
character.outfits.push(emptyOutfit())
|
||||
}
|
||||
|
||||
function removeOutfit(characterIndex: number, outfitIndex: number) {
|
||||
const character = characters.value[characterIndex]
|
||||
if (!character) return
|
||||
const [removed] = character.outfits.splice(outfitIndex, 1)
|
||||
if (!removed) return
|
||||
for (const scene of scenes.value) {
|
||||
const state = scene.characterStates.find(item => item.characterId === character.id)
|
||||
if (state?.outfitId === removed.id) state.outfitId = null
|
||||
}
|
||||
}
|
||||
|
||||
function addSet() {
|
||||
const set = emptySet()
|
||||
sets.value.push(set)
|
||||
ensureSceneStatesForSets(scenes.value, sets.value, {
|
||||
newSetId: set.id,
|
||||
turnOnSceneIndex: activeIndex.value
|
||||
})
|
||||
}
|
||||
|
||||
function clearSet(index: number) {
|
||||
const set = sets.value[index]
|
||||
if (!set) return
|
||||
set.name = ''
|
||||
set.framings = [emptyFraming()]
|
||||
for (const scene of scenes.value) {
|
||||
const state = scene.setStates.find(item => item.setId === set.id)
|
||||
if (state) state.framingId = null
|
||||
}
|
||||
}
|
||||
|
||||
function removeSet(index: number) {
|
||||
const [removed] = sets.value.splice(index, 1)
|
||||
if (!removed) return
|
||||
for (const scene of scenes.value) {
|
||||
scene.setStates = (scene.setStates || []).filter(state => state.setId !== removed.id)
|
||||
}
|
||||
}
|
||||
|
||||
function addFraming(setIndex: number) {
|
||||
const set = sets.value[setIndex]
|
||||
if (!set) return
|
||||
set.framings.push(emptyFraming())
|
||||
}
|
||||
|
||||
function removeFraming(setIndex: number, framingIndex: number) {
|
||||
const set = sets.value[setIndex]
|
||||
if (!set) return
|
||||
const [removed] = set.framings.splice(framingIndex, 1)
|
||||
if (!removed) return
|
||||
for (const scene of scenes.value) {
|
||||
const state = scene.setStates.find(item => item.setId === set.id)
|
||||
if (state?.framingId === removed.id) state.framingId = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearFraming(setIndex: number, framingIndex: number) {
|
||||
const framing = sets.value[setIndex]?.framings[framingIndex]
|
||||
if (!framing) return
|
||||
framing.label = ''
|
||||
framing.text = ''
|
||||
}
|
||||
|
||||
function sceneSetState(setId: string) {
|
||||
return activeScene.value?.setStates?.find(state => state.setId === setId)
|
||||
}
|
||||
|
||||
function isSetOn(setId: string) {
|
||||
const state = sceneSetState(setId)
|
||||
return state ? !!state.on : true
|
||||
}
|
||||
|
||||
function setSetOn(setId: string, on: boolean) {
|
||||
const state = sceneSetState(setId)
|
||||
if (!state) {
|
||||
activeScene.value?.setStates.push({ setId, on, framingId: null })
|
||||
return
|
||||
}
|
||||
state.on = on
|
||||
}
|
||||
|
||||
function activeSetFramingId(setId: string) {
|
||||
return sceneSetState(setId)?.framingId || ''
|
||||
}
|
||||
|
||||
function setSetFraming(setId: string, framingId: string) {
|
||||
const state = sceneSetState(setId)
|
||||
if (!state) {
|
||||
activeScene.value?.setStates.push({
|
||||
setId,
|
||||
on: true,
|
||||
framingId: framingId || null
|
||||
})
|
||||
return
|
||||
}
|
||||
state.framingId = framingId || null
|
||||
}
|
||||
|
||||
function sceneState(characterId: string) {
|
||||
return activeScene.value?.characterStates.find(state => state.characterId === characterId)
|
||||
}
|
||||
|
||||
function isCharacterOn(characterId: string) {
|
||||
const state = sceneState(characterId)
|
||||
return state ? !!state.on : true
|
||||
}
|
||||
|
||||
function setCharacterOn(characterId: string, on: boolean) {
|
||||
const state = sceneState(characterId)
|
||||
if (!state) {
|
||||
activeScene.value?.characterStates.push({ characterId, on, outfitId: null })
|
||||
return
|
||||
}
|
||||
state.on = on
|
||||
}
|
||||
|
||||
function activeOutfitId(characterId: string) {
|
||||
return sceneState(characterId)?.outfitId || ''
|
||||
}
|
||||
|
||||
function setCharacterOutfit(characterId: string, outfitId: string) {
|
||||
const state = sceneState(characterId)
|
||||
if (!state) {
|
||||
activeScene.value?.characterStates.push({
|
||||
characterId,
|
||||
on: true,
|
||||
outfitId: outfitId || null
|
||||
})
|
||||
return
|
||||
}
|
||||
state.outfitId = outfitId || null
|
||||
}
|
||||
|
||||
async function loadSequences() {
|
||||
const listed = await $fetch<{ sequences?: PromptSequence[] }>('/api/prompt-sequences')
|
||||
sequences.value = listed.sequences || []
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const nextName = name.value.trim()
|
||||
if (!nextName) return
|
||||
const existingByName = sequences.value.find(item => item.name.toLowerCase() === nextName.toLowerCase())
|
||||
const updateExisting = Boolean(selectedId.value) && (!existingByName || existingByName.id === selectedId.value)
|
||||
if (existingByName && existingByName.id !== selectedId.value && !window.confirm(`Overwrite sequence “${existingByName.name}”?`)) return
|
||||
busy.value = 'save'
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
const payload = {
|
||||
name: nextName,
|
||||
sectionOrder: sectionOrder.value,
|
||||
characters: characters.value,
|
||||
sets: sets.value,
|
||||
scenes: scenes.value
|
||||
}
|
||||
try {
|
||||
if (updateExisting && selectedId.value) {
|
||||
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>(`/api/prompt-sequences/${selectedId.value}`, {
|
||||
method: 'PATCH',
|
||||
body: payload
|
||||
})
|
||||
sequences.value = saved.sequences
|
||||
select(saved.sequence)
|
||||
notice.value = `Saved “${saved.sequence.name}”.`
|
||||
} else {
|
||||
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>('/api/prompt-sequences', {
|
||||
method: 'POST',
|
||||
body: { ...payload, overwrite: Boolean(existingByName) }
|
||||
})
|
||||
sequences.value = saved.sequences
|
||||
select(saved.sequence)
|
||||
notice.value = existingByName ? `Overwrote “${saved.sequence.name}”.` : `Saved “${saved.sequence.name}”.`
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not save that sequence'
|
||||
} finally {
|
||||
busy.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(sequence: PromptSequence) {
|
||||
if (!window.confirm(`Delete sequence “${sequence.name}”?`)) return
|
||||
busy.value = 'delete'
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
const saved = await $fetch<{ sequences: PromptSequence[] }>(`/api/prompt-sequences/${sequence.id}`, { method: 'DELETE' })
|
||||
sequences.value = saved.sequences
|
||||
if (selectedId.value === sequence.id) startNew()
|
||||
notice.value = `Deleted “${sequence.name}”.`
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not delete that sequence'
|
||||
} finally {
|
||||
busy.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string, kind: 'all' | 'scene') {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
if (kind === 'scene') {
|
||||
copiedScene.value = true
|
||||
if (copySceneTimer) clearTimeout(copySceneTimer)
|
||||
copySceneTimer = setTimeout(() => { copiedScene.value = false }, 1500)
|
||||
} else {
|
||||
copied.value = true
|
||||
if (copyTimer) clearTimeout(copyTimer)
|
||||
copyTimer = setTimeout(() => { copied.value = false }, 1500)
|
||||
}
|
||||
} catch {
|
||||
error.value = 'Could not copy to the clipboard'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPreview() {
|
||||
await writeClipboard(previewText.value, 'all')
|
||||
}
|
||||
|
||||
async function copyScene() {
|
||||
await writeClipboard(activeSceneText.value, 'scene')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadSequences()
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.statusMessage || err?.message || 'Could not load saved sequences'
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyTimer) clearTimeout(copyTimer)
|
||||
if (copySceneTimer) clearTimeout(copySceneTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,299 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, readdirSync, copyFileSync, createWriteStream } from 'node:fs'
|
||||
import { join, resolve, extname } from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { Transform } from 'node:stream'
|
||||
import { captionStylePrompt, CAPTION_STYLES } from '../shared/caption.mjs'
|
||||
|
||||
const MODEL_NAME = 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'
|
||||
const MMPROJ_NAME = 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
|
||||
|
||||
function defaultModelsDir() {
|
||||
const shared = process.env.COMFY_MODELS_ROOT
|
||||
|| join(process.env.LOCALAPPDATA || '', 'Comfy-Desktop', 'ComfyUI-Shared', 'models')
|
||||
return resolve(process.env.CAPTION_MODELS || join(shared, 'caption', 'qwen25vl-7b-nsfw-v4'))
|
||||
}
|
||||
|
||||
/** Prefer winget ggml.llamacpp; never download. Process exit after one caption = keep_alive 0. */
|
||||
function resolveLlamaServer(explicit) {
|
||||
if (explicit) return resolve(explicit)
|
||||
if (process.env.CAPTION_LLAMA_SERVER) return resolve(process.env.CAPTION_LLAMA_SERVER)
|
||||
const local = process.env.LOCALAPPDATA || ''
|
||||
const candidates = [
|
||||
join(local, 'Microsoft', 'WinGet', 'Packages', 'ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe', 'llama-server.exe'),
|
||||
join(local, 'llama.cpp', 'llama-server.exe'),
|
||||
join('C:\\', 'llama.cpp', 'llama-server.exe')
|
||||
]
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path
|
||||
}
|
||||
return 'llama-server'
|
||||
}
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolvePort, reject) => {
|
||||
const server = createServer()
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
server.close(error => error ? reject(error) : resolvePort(port))
|
||||
})
|
||||
server.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForServer(port, signal, timeoutMs = 120_000, fetchImpl = fetch) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (signal?.aborted) throw new Error('Caption cancelled while loading the model.')
|
||||
try {
|
||||
const response = await fetchImpl(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2000) })
|
||||
if (response.ok) return
|
||||
} catch { /* booting */ }
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error('llama-server did not become ready in time.')
|
||||
}
|
||||
|
||||
function mimeFor(path) {
|
||||
const ext = extname(path).toLowerCase()
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
if (ext === '.webp') return 'image/webp'
|
||||
if (ext === '.gif') return 'image/gif'
|
||||
return 'image/png'
|
||||
}
|
||||
|
||||
function killTree(child) {
|
||||
return new Promise(resolveKill => {
|
||||
if (!child?.pid) return resolveKill()
|
||||
let finished = false
|
||||
const done = () => { if (finished) return; finished = true; resolveKill() }
|
||||
child.once('close', done)
|
||||
try { if (typeof child.kill === 'function') child.kill() } catch { /* ignore */ }
|
||||
if (process.platform === 'win32' && child.pid > 0) {
|
||||
try {
|
||||
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
||||
killer.once('close', done)
|
||||
killer.once('error', done)
|
||||
} catch { done() }
|
||||
}
|
||||
setTimeout(done, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
export function validateCaptionHostRequest(body) {
|
||||
if (!/^[a-zA-Z0-9-]{12,80}$/.test(body?.id || '')) throw new Error('Invalid caption job ID.')
|
||||
const style = String(body.style || body.captionStyle || 'descriptive')
|
||||
if (!CAPTION_STYLES.includes(style)) throw new Error('Unknown caption style.')
|
||||
return { id: body.id, style, imagePath: body.imagePath ? String(body.imagePath) : '' }
|
||||
}
|
||||
|
||||
/** One llama-server process per caption. Process exit unloads VRAM (keep_alive 0). */
|
||||
export function createCaptionHost({
|
||||
prepare,
|
||||
leaseValid,
|
||||
spawnProcess = spawn,
|
||||
modelsDir,
|
||||
llamaServer,
|
||||
dataDir,
|
||||
fetchImpl = fetch,
|
||||
serverWaitMs = 120_000
|
||||
} = {}) {
|
||||
const models = resolve(modelsDir || defaultModelsDir())
|
||||
const executable = resolveLlamaServer(llamaServer)
|
||||
const data = resolve(dataDir || process.env.CAPTION_JOBS_DIR || join(models, 'aigen-jobs'))
|
||||
let active = null
|
||||
let holdUntil = 0
|
||||
if (existsSync(data)) {
|
||||
for (const id of readdirSync(data)) {
|
||||
try {
|
||||
const path = join(data, id, 'status.json')
|
||||
if (!existsSync(path)) continue
|
||||
const state = JSON.parse(readFileSync(path, 'utf8'))
|
||||
if (['running', 'starting', 'cancelling'].includes(state.status)) {
|
||||
holdUntil = Date.now() + 10000
|
||||
state.status = 'error'
|
||||
state.error = 'Caption host restarted. GPU model unloaded.'
|
||||
writeFileSync(path + '.tmp', JSON.stringify(state))
|
||||
renameSync(path + '.tmp', path)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
const dir = id => {
|
||||
if (!/^[a-zA-Z0-9-]{12,80}$/.test(id || '')) throw new Error('Invalid caption job ID.')
|
||||
return join(data, id)
|
||||
}
|
||||
const persist = job => {
|
||||
const target = join(dir(job.id), 'status.json')
|
||||
writeFileSync(target + '.tmp', JSON.stringify(job))
|
||||
renameSync(target + '.tmp', target)
|
||||
}
|
||||
const read = id => {
|
||||
if (active?.job.id === id) return { ...active.job }
|
||||
const path = join(dir(id), 'status.json')
|
||||
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null
|
||||
}
|
||||
const modelPath = () => join(models, MODEL_NAME)
|
||||
const mmprojPath = () => join(models, MMPROJ_NAME)
|
||||
const configured = () => {
|
||||
try {
|
||||
if (!existsSync(modelPath()) || !existsSync(mmprojPath())) return false
|
||||
if (executable.includes('\\') || executable.includes('/')) return existsSync(executable)
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
return {
|
||||
busy: () => Boolean(active) || Date.now() < holdUntil,
|
||||
configured,
|
||||
modelsDir: () => models,
|
||||
read,
|
||||
async upload(id, stream) {
|
||||
if (read(id)?.status === 'complete') throw new Error('This caption job has already finished.')
|
||||
const jobDir = dir(id)
|
||||
mkdirSync(jobDir, { recursive: true })
|
||||
const dest = join(jobDir, 'input.upload')
|
||||
let bytes = 0
|
||||
await pipeline(stream, new Transform({
|
||||
transform(chunk, _, callback) {
|
||||
bytes += chunk.length
|
||||
callback(bytes > 40 * 1024 * 1024 ? new Error('Image exceeds the 40 MB upload limit.') : null, chunk)
|
||||
}
|
||||
}), createWriteStream(dest, { flags: 'w' }))
|
||||
if (bytes < 32) throw new Error('Image file is empty.')
|
||||
renameSync(dest, join(jobDir, 'input.png'))
|
||||
},
|
||||
async start(body, lease) {
|
||||
const request = validateCaptionHostRequest(body)
|
||||
const previous = read(request.id)
|
||||
if (previous) return previous
|
||||
if (active || Date.now() < holdUntil) throw Object.assign(new Error('Caption is already running or releasing VRAM.'), { statusCode: 409 })
|
||||
if (!configured()) throw new Error(`Caption model missing under ${models}. Run scripts/setup-caption.ps1.`)
|
||||
if (!leaseValid(lease)) throw new Error('GPU reservation expired.')
|
||||
|
||||
let imagePath = request.imagePath
|
||||
if (imagePath) {
|
||||
if (!existsSync(imagePath)) throw new Error('imagePath does not exist on the GPU host.')
|
||||
mkdirSync(dir(request.id), { recursive: true })
|
||||
const dest = join(dir(request.id), `input${extname(imagePath) || '.png'}`)
|
||||
copyFileSync(imagePath, dest)
|
||||
imagePath = dest
|
||||
} else {
|
||||
imagePath = join(dir(request.id), 'input.png')
|
||||
if (!existsSync(imagePath)) {
|
||||
const alt = existsSync(dir(request.id)) && readdirSync(dir(request.id)).find(name => /^input\./i.test(name))
|
||||
if (!alt) throw new Error('Upload an image first or pass imagePath.')
|
||||
imagePath = join(dir(request.id), alt)
|
||||
}
|
||||
}
|
||||
|
||||
const job = {
|
||||
id: request.id, status: 'starting', message: 'Preparing GPU for caption', progress: 0,
|
||||
style: request.style, startedAt: Date.now(), checkedAt: Date.now()
|
||||
}
|
||||
active = { job, child: null, cancelled: false, abort: new AbortController() }
|
||||
const run = active
|
||||
mkdirSync(dir(job.id), { recursive: true })
|
||||
writeFileSync(join(dir(job.id), 'request.json'), JSON.stringify({ ...request, imagePath, prompt: captionStylePrompt(request.style) }))
|
||||
persist(job)
|
||||
|
||||
try {
|
||||
if (prepare) await prepare()
|
||||
if (run.cancelled || !leaseValid(lease)) throw new Error('Caption start cancelled or GPU reservation expired.')
|
||||
job.status = 'running'; job.message = 'Loading Qwen2.5-VL caption model'; job.progress = 5; persist(job)
|
||||
|
||||
const port = await freePort()
|
||||
// One caption per process. killTree after response unloads VRAM (keep_alive 0).
|
||||
const child = spawnProcess(executable, [
|
||||
'-m', modelPath(), '--mmproj', mmprojPath(),
|
||||
'--host', '127.0.0.1', '--port', String(port),
|
||||
'-ngl', String(process.env.CAPTION_NGL || '99'),
|
||||
'-c', String(process.env.CAPTION_CTX || '4096'),
|
||||
'--jinja'
|
||||
], { windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } })
|
||||
run.child = child
|
||||
const log = chunk => appendFileSync(join(dir(job.id), 'worker.log'), chunk)
|
||||
child.stdout.on('data', log)
|
||||
child.stderr.on('data', log)
|
||||
const watchdog = setInterval(() => {
|
||||
if (!leaseValid(lease)) { job.error = 'GPU reservation expired; caption stopped.'; run.abort.abort(); void killTree(child) }
|
||||
}, 5000)
|
||||
|
||||
try {
|
||||
await waitForServer(port, run.abort.signal, serverWaitMs, fetchImpl)
|
||||
if (run.cancelled) throw new Error('Cancelled')
|
||||
job.message = 'Captioning'; job.progress = 40; persist(job)
|
||||
const bytes = readFileSync(imagePath)
|
||||
const dataUrl = `data:${mimeFor(imagePath)};base64,${bytes.toString('base64')}`
|
||||
const response = await fetchImpl(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: run.abort.signal.aborted ? run.abort.signal : AbortSignal.timeout(Number(process.env.CAPTION_TIMEOUT_MS || 180_000)),
|
||||
body: JSON.stringify({
|
||||
temperature: 0.2,
|
||||
max_tokens: 512,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: captionStylePrompt(request.style) },
|
||||
{ type: 'image_url', image_url: { url: dataUrl } }
|
||||
]
|
||||
}]
|
||||
})
|
||||
})
|
||||
if (run.abort.signal.aborted) throw new Error('Cancelled')
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`llama-server caption failed (${response.status}): ${detail.slice(0, 400)}`)
|
||||
}
|
||||
const payload = await response.json()
|
||||
const text = String(payload?.choices?.[0]?.message?.content || '').trim()
|
||||
if (!text) throw new Error('Caption model returned empty text.')
|
||||
job.text = text
|
||||
job.status = run.cancelled ? 'cancelled' : 'complete'
|
||||
job.message = job.status === 'complete' ? 'Caption ready' : 'Cancelled'
|
||||
job.progress = 100
|
||||
job.checkedAt = Date.now()
|
||||
persist(job)
|
||||
return { ...job }
|
||||
} finally {
|
||||
clearInterval(watchdog)
|
||||
await killTree(child)
|
||||
holdUntil = Date.now() + 2000
|
||||
if (active === run) active = null
|
||||
}
|
||||
} catch (error) {
|
||||
job.status = run.cancelled ? 'cancelled' : 'error'
|
||||
job.error = error.message || String(error)
|
||||
job.message = job.error
|
||||
job.checkedAt = Date.now()
|
||||
persist(job)
|
||||
if (run.child) await killTree(run.child)
|
||||
if (active === run) active = null
|
||||
holdUntil = Date.now() + 2000
|
||||
throw error
|
||||
}
|
||||
},
|
||||
async cancel(id) {
|
||||
if (active?.job.id !== id) return read(id)
|
||||
const run = active
|
||||
run.cancelled = true
|
||||
run.job.status = 'cancelling'
|
||||
run.job.message = 'Cancelling'
|
||||
persist(run.job)
|
||||
run.abort.abort()
|
||||
if (run.child) await killTree(run.child)
|
||||
return read(id)
|
||||
},
|
||||
async captionOnce(body, lease) {
|
||||
const id = body.id || `caption-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
const result = await this.start({ ...body, id }, lease)
|
||||
if (result.status === 'complete') return { text: result.text, id: result.id, style: result.style }
|
||||
throw new Error(result.error || 'Caption failed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const captionModelFiles = { model: MODEL_NAME, mmproj: MMPROJ_NAME }
|
||||
@@ -4,6 +4,7 @@ import { stableMemoryArgs } from './comfy-memory-policy.mjs'
|
||||
import { createGpuReservation } from './gpu-reservation.mjs'
|
||||
import { createGpuProxy } from './gpu-proxy.mjs'
|
||||
import { createYue2Host } from './yue2-host.mjs'
|
||||
import { createCaptionHost } from './caption-host.mjs'
|
||||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
@@ -147,7 +148,7 @@ let proxyTarget = 0
|
||||
|
||||
function ensureProxyListening() {
|
||||
if (proxyServer) return
|
||||
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() })
|
||||
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() || caption.busy() })
|
||||
proxyServer.on('error', (error) => {
|
||||
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || error) }))
|
||||
})
|
||||
@@ -819,7 +820,7 @@ function purgeDesktopFiles(body) {
|
||||
}
|
||||
|
||||
const gpuReservation = createGpuReservation({ idle: async () => {
|
||||
if (yue2.busy() || upscale.busy()) return false
|
||||
if (yue2.busy() || upscale.busy() || caption.busy()) return false
|
||||
if ((await trainingLock()).busy) return false
|
||||
const healthy = await syncProxy()
|
||||
if (healthy) {
|
||||
@@ -830,29 +831,65 @@ const gpuReservation = createGpuReservation({ idle: async () => {
|
||||
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
||||
} })
|
||||
|
||||
const yue2 = createYue2Host({
|
||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||
prepare: async () => {
|
||||
async function prepareExclusiveGpu(label) {
|
||||
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
||||
if (label !== 'YuE2' && yue2.busy()) throw new Error('YuE2 is using the GPU.')
|
||||
if (label !== 'caption' && caption.busy()) throw new Error('Caption is using the GPU.')
|
||||
if (upscale.busy()) throw new Error('Local upscale is using the GPU.')
|
||||
const healthy = await syncProxy()
|
||||
if (healthy) {
|
||||
const queue = await fetchLocalQueue(healthy)
|
||||
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuE2 cannot start.')
|
||||
if (!queue.ok || queue.running || queue.pending) throw new Error(`Comfy is busy; ${label} cannot start.`)
|
||||
}
|
||||
// Stop Comfy and refuse to launch while its Python still owns VRAM.
|
||||
if (healthy || await processUp() || await pythonMainUp().catch(() => false)) {
|
||||
await stopComfyProcesses()
|
||||
markAsleep()
|
||||
}
|
||||
if (await pythonMainUp()) throw new Error('Comfy has not stopped; retry after the GPU is free.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const upscale = createUpscaleHost({ leaseValid: token => gpuReservation.isOwner(token) })
|
||||
|
||||
const yue2 = createYue2Host({
|
||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||
prepare: async () => prepareExclusiveGpu('YuE2')
|
||||
})
|
||||
|
||||
const caption = createCaptionHost({
|
||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||
prepare: async () => prepareExclusiveGpu('caption')
|
||||
})
|
||||
|
||||
async function handleControl(req, res) {
|
||||
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
|
||||
const url = new URL(req.url || '/', 'http://localhost')
|
||||
if (url.pathname.startsWith('/caption')) {
|
||||
const match = url.pathname.match(/^\/caption\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/cancel)?$/)
|
||||
if (req.method === 'GET' && url.pathname === '/caption/status') {
|
||||
return json(res, 200, { configured: caption.configured(), busy: caption.busy(), backend: 'llama.cpp', modelsDir: caption.modelsDir() })
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/caption') {
|
||||
try {
|
||||
const body = await readJson(req)
|
||||
return json(res, 200, await caption.captionOnce(body, String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||
} catch (error) {
|
||||
return json(res, error.statusCode || 400, { error: error.message || 'Caption failed' })
|
||||
}
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/caption/jobs') {
|
||||
return json(res, 200, await caption.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||
}
|
||||
if (match && req.method === 'PUT' && match[2] === '/input') {
|
||||
await caption.upload(match[1], req)
|
||||
return json(res, 200, { uploaded: true })
|
||||
}
|
||||
if (match && req.method === 'POST' && match[2] === '/cancel') return json(res, 200, await caption.cancel(match[1]))
|
||||
if (match && req.method === 'GET' && !match[2]) {
|
||||
const job = caption.read(match[1])
|
||||
return json(res, job ? 200 : 404, job || { error: 'Caption job not found' })
|
||||
}
|
||||
return json(res, 404, { error: 'Unknown caption endpoint' })
|
||||
}
|
||||
if (url.pathname.startsWith('/upscale/')) {
|
||||
const match = url.pathname.match(/^\/upscale\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/video|\/cancel)?$/)
|
||||
if (req.method === 'POST' && url.pathname === '/upscale/jobs') return json(res, 200, upscale.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||
@@ -909,12 +946,14 @@ async function handleControl(req, res) {
|
||||
gpu: gpuReservation.availability(),
|
||||
training: { busy: lastTraining.busy },
|
||||
yue2: { busy: yue2.busy(), configured: yue2.configured() },
|
||||
caption: { busy: caption.busy(), configured: caption.configured() },
|
||||
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
|
||||
})
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/start') {
|
||||
if (upscale.busy()) return json(res, 409, { message: 'Local upscale is using the GPU.' })
|
||||
if (yue2.busy()) return json(res, 409, { message: 'YuE2 is using the GPU.' })
|
||||
if (caption.busy()) return json(res, 409, { message: 'Caption is using the GPU.' })
|
||||
const training = await trainingLock()
|
||||
if (training.busy) {
|
||||
return json(res, 409, {
|
||||
@@ -1016,7 +1055,7 @@ const server = http.createServer(async (req, res) => {
|
||||
} else await handleControl(req, res)
|
||||
} catch (error) {
|
||||
req.resume()
|
||||
if ((String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Music host request failed' })
|
||||
if ((String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/') || String(req.url || '').startsWith('/caption')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Host request failed' })
|
||||
if (!res.headersSent) json(res, error.statusCode || 400, { ok: false, message: error.statusCode === 409 ? 'GPU is in use. Waiting for availability.' : 'GPU coordination request failed.' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Caption (Qwen2.5-VL NSFW Caption V4 GGUF) — verify host files only.
|
||||
# Models are already on the 5080 host. Do not download. Do not search Hugging Face.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Resolve-ModelsRoot {
|
||||
if ($env:COMFY_MODELS_ROOT) { return $env:COMFY_MODELS_ROOT }
|
||||
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
|
||||
if (Test-Path $shared) { return $shared }
|
||||
throw 'Set COMFY_MODELS_ROOT or install Comfy Desktop Shared models.'
|
||||
}
|
||||
|
||||
$root = Join-Path (Resolve-ModelsRoot) 'caption\qwen25vl-7b-nsfw-v4'
|
||||
if (-not (Test-Path $root)) {
|
||||
throw "Caption models folder missing: $root"
|
||||
}
|
||||
|
||||
$files = @(
|
||||
'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf',
|
||||
'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
|
||||
)
|
||||
|
||||
foreach ($name in $files) {
|
||||
$path = Join-Path $root $name
|
||||
if (-not (Test-Path $path)) {
|
||||
throw "Missing $name under $root. Do not download; use the files already on this host."
|
||||
}
|
||||
Write-Host "OK $name"
|
||||
}
|
||||
|
||||
$llama = $null
|
||||
if ($env:CAPTION_LLAMA_SERVER -and (Test-Path $env:CAPTION_LLAMA_SERVER)) {
|
||||
$llama = Get-Item $env:CAPTION_LLAMA_SERVER
|
||||
} else {
|
||||
$winget = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe\llama-server.exe'
|
||||
if (Test-Path $winget) { $llama = Get-Item $winget }
|
||||
else { $llama = Get-Command llama-server -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
if (-not $llama) {
|
||||
throw 'llama-server not found. Install with: winget install ggml.llamacpp'
|
||||
}
|
||||
$llamaPath = if ($llama.PSObject.Properties['FullName'] -and $llama.FullName) { $llama.FullName } elseif ($llama.PSObject.Properties['Source'] -and $llama.Source) { $llama.Source } else { [string]$llama }
|
||||
Write-Host "llama-server: $llamaPath"
|
||||
|
||||
$ready = @{
|
||||
model = 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'
|
||||
mmproj = 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
|
||||
modelsDir = $root
|
||||
llamaServer = $llamaPath
|
||||
readyAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
} | ConvertTo-Json
|
||||
Set-Content -Path (Join-Path $root 'aigen-ready.json') -Value $ready -Encoding utf8
|
||||
Write-Host "Caption ready: $root"
|
||||
@@ -18,6 +18,12 @@ Get-NetTCPConnection -LocalPort 8199 -State Listen -ErrorAction SilentlyContinue
|
||||
|
||||
Start-Sleep -Milliseconds 400
|
||||
|
||||
# Caption VLM — point at existing host GGUFs (no download).
|
||||
$captionModels = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models\caption\qwen25vl-7b-nsfw-v4'
|
||||
$llamaServer = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe\llama-server.exe'
|
||||
if (Test-Path $captionModels) { $env:CAPTION_MODELS = $captionModels }
|
||||
if (Test-Path $llamaServer) { $env:CAPTION_LLAMA_SERVER = $llamaServer }
|
||||
|
||||
if ($Wait) {
|
||||
Set-Location $root
|
||||
& $node $agent
|
||||
@@ -31,4 +37,5 @@ $psi.WorkingDirectory = $root
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
||||
# CAPTION_* already set on this process; child inherits when UseShellExecute is false
|
||||
[void][System.Diagnostics.Process]::Start($psi)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { validateCaptionBody } from '~/shared/caption.mjs'
|
||||
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '~/server/utils/studioQueue'
|
||||
import { getStill } from '~/server/utils/library'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const raw = await readBody(event)
|
||||
let body: ReturnType<typeof validateCaptionBody>
|
||||
try {
|
||||
body = validateCaptionBody(raw)
|
||||
} catch (e: any) {
|
||||
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message })
|
||||
}
|
||||
assertFolderAccess(event, body.folderId)
|
||||
const still = getStill(owner, body.stillId)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
|
||||
const row = await addStudioJob({
|
||||
ownerKey: owner,
|
||||
kind: 'caption',
|
||||
familyId: crypto.randomUUID(),
|
||||
payload: {
|
||||
prompt: `Describe · ${body.captionStyle}`,
|
||||
name: `Describe · ${body.captionStyle}`,
|
||||
folderId: body.folderId,
|
||||
stillId: still.id,
|
||||
captionStyle: body.captionStyle,
|
||||
aspect: 'auto',
|
||||
width: 0,
|
||||
height: 0,
|
||||
steps: 1,
|
||||
turbo: true,
|
||||
seed: 0,
|
||||
cfg: 0,
|
||||
fps: 24,
|
||||
samplerName: 'euler',
|
||||
scheduler: 'simple',
|
||||
duration: 0,
|
||||
sound: false,
|
||||
workflow: 'minimax',
|
||||
useIdentityRefs: false,
|
||||
hideThumbnail: false,
|
||||
referenceStillIds: [],
|
||||
extensions: [],
|
||||
queueAutoRun: true
|
||||
} as StudioJobPayload
|
||||
})
|
||||
kickStudioQueue()
|
||||
return { id: row.id, liveJobId: row.liveJobId, captionStyle: body.captionStyle, stillId: still.id }
|
||||
})
|
||||
@@ -48,7 +48,7 @@ export default defineEventHandler(async () => {
|
||||
host: comfy.host || '',
|
||||
queueRunning: queue.running,
|
||||
queuePending: queue.pending,
|
||||
asleep: Boolean(control?.asleep) && !comfyReachable && !control?.yue2?.busy,
|
||||
asleep: Boolean(control?.asleep) && !comfyReachable && !control?.yue2?.busy && !control?.caption?.busy,
|
||||
processUp: Boolean(control?.process ?? control?.processUp),
|
||||
lastActivityAt: control?.lastActivityAt || null,
|
||||
stats: comfy.ok ? comfy.stats : undefined
|
||||
@@ -58,6 +58,11 @@ export default defineEventHandler(async () => {
|
||||
busy: Boolean(control?.yue2?.busy),
|
||||
backend: control?.yue2?.backend || 'yue2'
|
||||
},
|
||||
caption: {
|
||||
configured: Boolean(control?.caption?.configured),
|
||||
busy: Boolean(control?.caption?.busy),
|
||||
backend: control?.caption?.backend || 'llama.cpp'
|
||||
},
|
||||
imageComfy: {
|
||||
configured: imageConfigured,
|
||||
ok: imageOk,
|
||||
|
||||
@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
const etag = fileEtag(path)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', 'private, max-age=86400, stale-while-revalidate=604800')
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { publicLibrary, publicLibraryPage } from '~/server/utils/library'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const light = String(getQuery(event).light || '') === '1'
|
||||
const library = publicLibrary(event, { light })
|
||||
try {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const folderId = library.folders.find(folder => folder.unlocked)?.id
|
||||
void maintainLibrary(owner, folderId).catch(() => null)
|
||||
} catch { /* locked or anonymous — still return what we can */ }
|
||||
return library
|
||||
const query = getQuery(event)
|
||||
const light = String(query.light || '') === '1'
|
||||
const full = String(query.full || '') === '1'
|
||||
const limitRaw = query.limit
|
||||
const hasLimit = limitRaw != null && String(limitRaw) !== ''
|
||||
// Default GET /api/library → page helper (limit 50). Opt into full catalog with full=1 or legacy light=1.
|
||||
const wantsFull = full || (light && !hasLimit)
|
||||
|
||||
if (!wantsFull) {
|
||||
return publicLibraryPage(event, {
|
||||
limit: hasLimit ? Number(limitRaw) || 50 : 50,
|
||||
cursor: String(query.cursor || ''),
|
||||
folderId: String(query.folderId || query.folder || ''),
|
||||
type: String(query.type || query.filter || 'all')
|
||||
})
|
||||
}
|
||||
|
||||
// Full catalog path — no thumb prewarm on the request (thumbs build on /thumb hit)
|
||||
return publicLibrary(event, { light: true })
|
||||
})
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
import { existsSync, createReadStream } from 'node:fs'
|
||||
import { fileEtag, stillPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
|
||||
import { fileEtag, stillPath, existingStillThumbPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const still = getStill(owner, id)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
const wantThumb = String(getQuery(event).thumb || '') === '1'
|
||||
const path = wantThumb ? await stillThumbPath(owner, id) : stillPath(owner, still.id)
|
||||
if (!path || !existsSync(path)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
|
||||
}
|
||||
const original = stillPath(owner, still.id)
|
||||
|
||||
if (wantThumb) {
|
||||
const path = existingStillThumbPath(owner, id)
|
||||
if (path && path !== original && existsSync(path)) {
|
||||
const etag = fileEtag(path)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
}
|
||||
setHeader(event, 'Content-Type', wantThumb ? 'image/jpeg' : sniffImageType(path))
|
||||
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||
return sendStream(event, createReadStream(path))
|
||||
}
|
||||
void stillThumbPath(owner, id).catch(() => null)
|
||||
setHeader(event, 'Cache-Control', 'no-store')
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!existsSync(original)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
|
||||
}
|
||||
|
||||
const etag = fileEtag(original)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', 'private, max-age=3600')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
}
|
||||
setHeader(event, 'Content-Type', sniffImageType(original))
|
||||
return sendStream(event, createReadStream(original))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { existsSync, createReadStream } from 'node:fs'
|
||||
import { fileEtag, stillPath, stillPreviewPath, existingStillPreviewPath } from '~/server/utils/library'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const still = getStill(owner, id)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
|
||||
const path = existingStillPreviewPath(owner, id)
|
||||
const original = stillPath(owner, still.id)
|
||||
// Ready preview → stream. Never await ffmpeg; never stream the archive master.
|
||||
if (path && existsSync(path) && path !== original) {
|
||||
const etag = fileEtag(path)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
}
|
||||
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||
return sendStream(event, createReadStream(path))
|
||||
}
|
||||
|
||||
// Missing → answer now; build .preview.jpg in the background
|
||||
void stillPreviewPath(owner, id).catch(() => null)
|
||||
setHeader(event, 'Cache-Control', 'no-store')
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { existsSync, createReadStream } from 'node:fs'
|
||||
import { fileEtag, stillPath, stillThumbPath, existingStillThumbPath } from '~/server/utils/library'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const still = getStill(owner, id)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
|
||||
const path = existingStillThumbPath(owner, id)
|
||||
const original = stillPath(owner, still.id)
|
||||
// Ready thumb → stream. Never await ffmpeg on the request path.
|
||||
if (path && existsSync(path) && path !== original) {
|
||||
const etag = fileEtag(path)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
}
|
||||
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||
return sendStream(event, createReadStream(path))
|
||||
}
|
||||
|
||||
// Missing → answer now; build .thumb.jpg in the background (job save / upload also kick this)
|
||||
void stillThumbPath(owner, id).catch(() => null)
|
||||
setHeader(event, 'Cache-Control', 'no-store')
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { listPromptSequences } from '~/server/utils/promptSequences'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
return {
|
||||
sequences: listPromptSequences(owner)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { listPromptSequences, savePromptSequence } from '~/server/utils/promptSequences'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const body = await readBody<{
|
||||
name?: string
|
||||
sectionOrder?: unknown
|
||||
characters?: unknown
|
||||
sets?: unknown
|
||||
scenes?: unknown
|
||||
overwrite?: boolean
|
||||
}>(event).catch(() => ({}))
|
||||
const sequence = await savePromptSequence(owner, {
|
||||
name: body?.name || '',
|
||||
sectionOrder: body?.sectionOrder,
|
||||
characters: body?.characters,
|
||||
sets: body?.sets,
|
||||
scenes: body?.scenes,
|
||||
overwrite: body?.overwrite === true
|
||||
})
|
||||
return {
|
||||
sequence,
|
||||
sequences: listPromptSequences(owner)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { deletePromptSequence, listPromptSequences } from '~/server/utils/promptSequences'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
await deletePromptSequence(owner, id)
|
||||
return {
|
||||
ok: true,
|
||||
sequences: listPromptSequences(owner)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getPromptSequence } from '~/server/utils/promptSequences'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
return {
|
||||
sequence: getPromptSequence(owner, id)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { listPromptSequences, updatePromptSequence } from '~/server/utils/promptSequences'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const body = await readBody<{
|
||||
name?: string
|
||||
sectionOrder?: unknown
|
||||
characters?: unknown
|
||||
sets?: unknown
|
||||
scenes?: unknown
|
||||
}>(event).catch(() => ({}))
|
||||
const sequence = await updatePromptSequence(owner, id, {
|
||||
name: body?.name,
|
||||
sectionOrder: body?.sectionOrder,
|
||||
characters: body?.characters,
|
||||
sets: body?.sets,
|
||||
scenes: body?.scenes
|
||||
})
|
||||
return {
|
||||
sequence,
|
||||
sequences: listPromptSequences(owner)
|
||||
}
|
||||
})
|
||||
@@ -1,15 +1,40 @@
|
||||
import { records, saveRecord } from '../../utils/studio2/store';
|
||||
import { listStudioJobs } from '../../utils/studioQueue';
|
||||
export default defineEventHandler(event => {
|
||||
const { owner } = assertLibraryOwner(event), queue=listStudioJobs(owner);
|
||||
let cleared=0;
|
||||
import { records, saveRecord } from '../../utils/studio2/store'
|
||||
import { dismissFinishedStudioJobs, dismissStudioJob, listStudioJobs } from '../../utils/studioQueue'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const body = await readBody<{ id?: string }>(event).catch(() => ({} as { id?: string }))
|
||||
const queue = listStudioJobs(owner)
|
||||
let cleared = 0
|
||||
|
||||
if (body?.id) {
|
||||
const row = queue.find(job => job.id === body.id)
|
||||
if (!row) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||
try { assertFolderAccess(event, row.payload.folderId) } catch {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Folder locked' })
|
||||
}
|
||||
await dismissStudioJob(owner, body.id)
|
||||
return { cleared: 1 }
|
||||
}
|
||||
|
||||
for (const r of records(owner)) {
|
||||
try { assertFolderAccess(event,r.request.folderId); } catch { continue; }
|
||||
const status=queue.find(q=>q.id===r.queueId)?.status;
|
||||
if (['complete','failed','cancelled'].includes(r.state) || ['cancelled','error'].includes(status || '')) {
|
||||
r.dismissed=true; saveRecord(r); cleared++;
|
||||
try { assertFolderAccess(event, r.request.folderId) } catch { continue }
|
||||
const status = queue.find(q => q.id === r.queueId)?.status
|
||||
if (['complete', 'failed', 'cancelled'].includes(r.state) || ['cancelled', 'error'].includes(status || '')) {
|
||||
r.dismissed = true
|
||||
saveRecord(r)
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
// Retain internal manifests for cleanup retries; remove these records from the visible jobs list.
|
||||
return {cleared};
|
||||
});
|
||||
|
||||
// Caption (and other finished studio-queue) cards — hide from Queue, keep resultText on the row
|
||||
const finishedIds = queue
|
||||
.filter(job => !job.dismissed && ['complete', 'error', 'cancelled'].includes(job.status))
|
||||
.filter(job => {
|
||||
try { assertFolderAccess(event, job.payload.folderId); return true } catch { return false }
|
||||
})
|
||||
.map(job => job.id)
|
||||
cleared += await dismissFinishedStudioJobs(owner, finishedIds)
|
||||
|
||||
return { cleared }
|
||||
})
|
||||
|
||||
@@ -25,11 +25,18 @@ export default defineEventHandler(async (event) => {
|
||||
assertFolderAccess(event, getStill(owner, id).folderId);
|
||||
if (request.startClipId)
|
||||
assertFolderAccess(event, getClip(owner, request.startClipId).folderId);
|
||||
// Header-only size probe — never decode the archive PNG on Generate click
|
||||
await resolveRequestSize(owner,request);
|
||||
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
|
||||
saveRecord(record);
|
||||
const ack = { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
|
||||
// Enqueue + kick after the client has the id — do not block the response on the worker wake path
|
||||
void (async () => {
|
||||
try {
|
||||
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, studio2Request: structuredClone(request), prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
|
||||
saveRecord({ ...readRecord(owner, id), queueId: row.id });
|
||||
} catch { /* poll / resume will pick up waiting studio2 records */ }
|
||||
kickStudioQueue();
|
||||
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
|
||||
})();
|
||||
return ack;
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog';
|
||||
import { publicLibrary, stillPath, clipVideoPath } from '../../utils/library';
|
||||
export default defineEventHandler(event => {
|
||||
const { owner } = assertLibraryOwner(event);
|
||||
const library = publicLibrary(event);
|
||||
const catalog=studio2LoraCatalog(owner);
|
||||
return {...library,loraAliases:catalog.aliases,loraTriggers:catalog.triggers, stills:library.stills.filter(s=>existsSync(stillPath(owner,s.id))), clips:library.clips.filter(c=>existsSync(clipVideoPath(owner,c.id))), tracks:library.tracks || []};
|
||||
});
|
||||
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
|
||||
import { publicLibrary } from '../../utils/library'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
// light:true — metadata only; never existsSync every still/clip on the volume
|
||||
const library = publicLibrary(event, { light: true })
|
||||
const catalog = studio2LoraCatalog(owner)
|
||||
return {
|
||||
...library,
|
||||
loraAliases: catalog.aliases,
|
||||
loraTriggers: catalog.triggers,
|
||||
tracks: library.tracks || []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { readFileSync,writeFileSync,renameSync } from 'node:fs'
|
||||
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { cachedLoras } from '~/shared/studio2/lora-cache.mjs'
|
||||
import { listStudioLoras } from '../../utils/loras'
|
||||
import { studio2Root } from '../../utils/studio2/store'
|
||||
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const path = join(studio2Root(), `loras-${owner}.cache`)
|
||||
// Same live Comfy scan as Settings (/api/loras?fresh=1). Always fresh so CACHE_MS cannot freeze adds.
|
||||
const fresh = String(getQuery(event).fresh || '') === '1'
|
||||
const listed = await cachedLoras({
|
||||
read: () => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null } },
|
||||
write: value => { writeFileSync(path + '.tmp', JSON.stringify(value)); renameSync(path + '.tmp', path) },
|
||||
live: () => listStudioLoras({ fresh: true }),
|
||||
fresh: true
|
||||
fresh
|
||||
})
|
||||
return { ...listed, ...studio2LoraCatalog(owner) }
|
||||
})
|
||||
|
||||
@@ -5,18 +5,21 @@ export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const full = String(getQuery(event).full || '') === '1'
|
||||
const active = listStudioJobs(owner)
|
||||
.filter(job => !job.dismissed)
|
||||
.filter(job => (
|
||||
job.status === 'waiting'
|
||||
|| job.status === 'running'
|
||||
|| job.status === 'held'
|
||||
|| (job.status === 'error' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|
||||
|| (job.kind === 'caption' && job.status === 'complete' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|
||||
|| (job.kind === 'caption' && job.status === 'cancelled' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|
||||
))
|
||||
const claimed = new Set(active.map(job => job.shotQueueId).filter(Boolean) as string[])
|
||||
const queues = listShotQueues(owner)
|
||||
const byId = new Map(queues.map(queue => [queue.id, queue]))
|
||||
const jobs = active.map((job) => {
|
||||
const shots = job.shotQueueId ? byId.get(job.shotQueueId) || null : null
|
||||
const kind = job.kind === 'edit' ? 'edit' : job.kind === 'music' ? 'music' : 'video'
|
||||
const kind = job.kind === 'edit' ? 'edit' : job.kind === 'music' ? 'music' : job.kind === 'caption' ? 'caption' : 'video'
|
||||
const shotSummary = shots ? summarizeQueue(shots) : null
|
||||
const plannedShots = shotSummary
|
||||
? undefined
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { resumeCaptionJobs } from '~/server/utils/caption'
|
||||
|
||||
export default defineNitroPlugin(() => { resumeCaptionJobs() })
|
||||
@@ -0,0 +1,232 @@
|
||||
import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createJob, emitJob, getJob, restoreJob, type Job } from './jobs'
|
||||
import { getStill, stillPath } from './library'
|
||||
import { sharedGpuHeaders } from './sharedGpu'
|
||||
import { CAPTION_STYLES } from '~/shared/caption.mjs'
|
||||
|
||||
function settings() {
|
||||
const config = useRuntimeConfig()
|
||||
return {
|
||||
url: String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, ''),
|
||||
token: String(config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '')
|
||||
}
|
||||
}
|
||||
|
||||
export function captionConfigured() { return Boolean(settings().url) }
|
||||
|
||||
function pendingRoot() {
|
||||
return join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'caption-pending')
|
||||
}
|
||||
|
||||
export function captionPendingAlive(jobId: string) {
|
||||
return Boolean(jobId) && existsSync(join(pendingRoot(), `${jobId}.json`))
|
||||
}
|
||||
|
||||
async function request(path: string, method = 'GET', body?: unknown) {
|
||||
const { url, token } = settings()
|
||||
if (!url) throw new Error('Caption host is not configured. Set COMFY_CONTROL_URL.')
|
||||
const response = await fetch(`${url}/caption/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(method === 'GET' ? {} : sharedGpuHeaders()),
|
||||
...(method === 'POST' ? { 'Content-Type': 'application/json' } : {})
|
||||
},
|
||||
body: method === 'POST' ? JSON.stringify(body) : body as BodyInit | undefined,
|
||||
...(method === 'PUT' ? { duplex: 'half' as const } : {}),
|
||||
// Caption loads a 7B VLM; allow the host POST to finish or fall through to watch
|
||||
signal: AbortSignal.timeout(method === 'PUT' ? 120_000 : method === 'POST' ? 300_000 : 60_000)
|
||||
} as RequestInit)
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({})) as { message?: string; error?: string }
|
||||
throw Object.assign(new Error(detail.message || detail.error || `Caption host returned ${response.status}`), { statusCode: response.status })
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
function persist(record: Record<string, unknown>) {
|
||||
mkdirSync(pendingRoot(), { recursive: true })
|
||||
const path = join(pendingRoot(), `${record.id}.json`)
|
||||
writeFileSync(path + '.tmp', JSON.stringify(record))
|
||||
renameSync(path + '.tmp', path)
|
||||
}
|
||||
|
||||
function clearPending(id: string) {
|
||||
const path = join(pendingRoot(), `${id}.json`)
|
||||
if (existsSync(path)) unlinkSync(path)
|
||||
}
|
||||
|
||||
function writeSidecar(owner: string, stillId: string, text: string) {
|
||||
try {
|
||||
const image = stillPath(owner, stillId)
|
||||
if (!existsSync(image)) return
|
||||
writeFileSync(`${image}.txt`, text, 'utf8')
|
||||
} catch { /* optional */ }
|
||||
}
|
||||
|
||||
export async function cancelCaptionJob(job: Job) {
|
||||
await request(`jobs/${job.id}/cancel`, 'POST', {})
|
||||
job.status = 'cancelled'
|
||||
emitJob(job, { type: 'error', error: 'Cancelled', message: 'Cancelled' })
|
||||
}
|
||||
|
||||
async function settle(job: Job) {
|
||||
const { onLiveVideoSettled } = await import('./studioQueue')
|
||||
await onLiveVideoSettled(job)
|
||||
clearPending(job.id)
|
||||
}
|
||||
|
||||
async function watch(job: Job, record: {
|
||||
id: string
|
||||
owner: string
|
||||
stillId: string
|
||||
folderId: string
|
||||
captionStyle: string
|
||||
liveId: string
|
||||
}) {
|
||||
let failures = 0
|
||||
while (job.status !== 'cancelled') {
|
||||
try {
|
||||
const state = await (await request(`jobs/${job.id}`)).json() as {
|
||||
status: string; message?: string; error?: string; progress?: number; text?: string
|
||||
}
|
||||
if (job.status === 'cancelled') { await settle(job); return }
|
||||
if (state.status === 'error' || state.status === 'cancelled') {
|
||||
job.status = state.status === 'cancelled' ? 'cancelled' : 'error'
|
||||
job.error = state.error || state.message || 'Caption failed'
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
if (state.status === 'complete') {
|
||||
const text = String(state.text || '').trim()
|
||||
job.message = 'Caption ready'
|
||||
job.progress = 100
|
||||
job.status = 'complete'
|
||||
job.resultText = text
|
||||
writeSidecar(record.owner, record.stillId, text)
|
||||
persist({ ...record, resultText: text, status: 'complete' })
|
||||
emitJob(job, { type: 'complete', message: 'Caption ready', progress: 100 })
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
failures = 0
|
||||
job.status = 'running'
|
||||
emitJob(job, {
|
||||
type: 'progress',
|
||||
message: state.message || 'Captioning',
|
||||
progress: Math.min(95, Math.max(1, Number(state.progress || 10)))
|
||||
})
|
||||
} catch (error) {
|
||||
failures++
|
||||
emitJob(job, { type: 'status', message: `Caption host check failed; retrying: ${error instanceof Error ? error.message : String(error)}` })
|
||||
if (failures >= 10) {
|
||||
job.status = 'error'
|
||||
job.error = 'Caption host unreachable.'
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
}
|
||||
await settle(job)
|
||||
}
|
||||
|
||||
export async function startCaptionJob(params: {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
stillId: string
|
||||
captionStyle: string
|
||||
name?: string
|
||||
}) {
|
||||
if (!CAPTION_STYLES.includes(params.captionStyle)) throw new Error('Unknown caption style.')
|
||||
getStill(params.ownerKey, params.stillId)
|
||||
const job = createJob('caption')
|
||||
job.caption = true
|
||||
job.library = {
|
||||
ownerKey: params.ownerKey,
|
||||
folderId: params.folderId,
|
||||
hideThumbnail: false,
|
||||
name: params.name || `Describe · ${params.captionStyle}`,
|
||||
prompt: params.captionStyle,
|
||||
aspect: 'image',
|
||||
width: 0,
|
||||
height: 0,
|
||||
steps: 1,
|
||||
turbo: true,
|
||||
seed: 0,
|
||||
stillId: params.stillId,
|
||||
engine: 'caption'
|
||||
}
|
||||
const record = {
|
||||
id: job.id,
|
||||
liveId: job.id,
|
||||
owner: params.ownerKey,
|
||||
stillId: params.stillId,
|
||||
folderId: params.folderId,
|
||||
captionStyle: params.captionStyle,
|
||||
createdAt: Date.now(),
|
||||
status: 'running'
|
||||
}
|
||||
persist(record)
|
||||
void (async () => {
|
||||
try {
|
||||
if (job.status === 'cancelled') { await settle(job); return }
|
||||
await request(`jobs/${job.id}/input`, 'PUT', createReadStream(stillPath(params.ownerKey, params.stillId)))
|
||||
if (job.status === 'cancelled') { await settle(job); return }
|
||||
try {
|
||||
await request('jobs', 'POST', { id: job.id, style: params.captionStyle })
|
||||
} catch { /* confirm by ID on timeout */ }
|
||||
await watch(job, record)
|
||||
} catch (error) {
|
||||
const statusCode = (error as { statusCode?: number })?.statusCode
|
||||
if (statusCode && statusCode >= 400 && statusCode < 500) {
|
||||
job.status = 'error'
|
||||
job.error = error instanceof Error ? error.message : String(error)
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
emitJob(job, { type: 'status', message: `Checking caption submission: ${error instanceof Error ? error.message : String(error)}` })
|
||||
await watch(job, record)
|
||||
}
|
||||
})()
|
||||
return job
|
||||
}
|
||||
|
||||
export function resumeCaptionJobs() {
|
||||
if (!existsSync(pendingRoot())) return
|
||||
for (const file of readdirSync(pendingRoot()).filter(file => /^[a-zA-Z0-9-]+\.json$/.test(file))) {
|
||||
try {
|
||||
const record = JSON.parse(readFileSync(join(pendingRoot(), file), 'utf8'))
|
||||
if (getJob(record.id)) continue
|
||||
const job = restoreJob({
|
||||
id: record.liveId || record.id,
|
||||
clientId: record.clientId || crypto.randomUUID(),
|
||||
promptId: '',
|
||||
startedAt: record.createdAt || Date.now(),
|
||||
library: {
|
||||
ownerKey: record.owner,
|
||||
folderId: record.folderId,
|
||||
hideThumbnail: false,
|
||||
prompt: record.captionStyle || 'descriptive',
|
||||
aspect: 'image',
|
||||
width: 0,
|
||||
height: 0,
|
||||
steps: 1,
|
||||
turbo: true,
|
||||
seed: 0,
|
||||
stillId: record.stillId,
|
||||
engine: 'caption'
|
||||
}
|
||||
})
|
||||
job.caption = true
|
||||
job.kind = 'caption'
|
||||
if (record.resultText) job.resultText = record.resultText
|
||||
job.message = 'Reconnecting to caption host'
|
||||
void watch(job, record)
|
||||
} catch { /* keep bad records */ }
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,11 @@ export interface Job {
|
||||
upscale?: boolean
|
||||
yueGp?: boolean
|
||||
yue2?: boolean
|
||||
caption?: boolean
|
||||
resultText?: string
|
||||
musicActivity?: { checkedAt: number; running: boolean }
|
||||
id: string
|
||||
kind?: 'video' | 'edit' | 'music'
|
||||
kind?: 'video' | 'edit' | 'music' | 'caption'
|
||||
promptId?: string
|
||||
clientId: string
|
||||
status: JobStatus
|
||||
@@ -222,6 +224,7 @@ export function jobSnapshot(job: Job) {
|
||||
clipId: job.clipId,
|
||||
stillId: job.stillId,
|
||||
trackId: job.trackId,
|
||||
resultText: job.resultText,
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
error: job.error,
|
||||
folderLocked: job.library?.folderLocked,
|
||||
|
||||
+253
-8
@@ -187,6 +187,7 @@ interface Catalog {
|
||||
}
|
||||
|
||||
const writeChains = new Map<string, Promise<unknown>>()
|
||||
const catalogCache = new Map<string, { catalog: Catalog; mtimeMs: number }>()
|
||||
|
||||
export function libraryRoot() {
|
||||
const config = useRuntimeConfig()
|
||||
@@ -355,6 +356,13 @@ function ensureOwner(owner: string) {
|
||||
function readCatalog(owner: string): Catalog {
|
||||
ensureOwner(owner)
|
||||
const path = catalogPath(owner)
|
||||
let mtimeMs = 0
|
||||
try {
|
||||
mtimeMs = existsSync(path) ? statSync(path).mtimeMs : 0
|
||||
} catch { mtimeMs = 0 }
|
||||
const hit = catalogCache.get(owner)
|
||||
if (hit && hit.mtimeMs === mtimeMs && mtimeMs > 0) return hit.catalog
|
||||
|
||||
let catalog: Catalog
|
||||
try {
|
||||
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||
@@ -364,8 +372,11 @@ function readCatalog(owner: string): Catalog {
|
||||
}
|
||||
catalog = emptyCatalog()
|
||||
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
||||
try { mtimeMs = statSync(path).mtimeMs } catch { mtimeMs = Date.now() }
|
||||
catalogCache.set(owner, { catalog, mtimeMs })
|
||||
return catalog
|
||||
}
|
||||
catalogCache.set(owner, { catalog, mtimeMs })
|
||||
return catalog
|
||||
}
|
||||
|
||||
@@ -374,6 +385,14 @@ function writeCatalog(owner: string, catalog: Catalog) {
|
||||
const tmp = catalogPath(owner) + '.tmp'
|
||||
writeFileSync(tmp, JSON.stringify(catalog, null, 2))
|
||||
renameSync(tmp, catalogPath(owner))
|
||||
let mtimeMs = Date.now()
|
||||
try { mtimeMs = statSync(catalogPath(owner)).mtimeMs } catch { /* ignore */ }
|
||||
catalogCache.set(owner, { catalog, mtimeMs })
|
||||
}
|
||||
|
||||
function invalidateCatalogCache(owner?: string) {
|
||||
if (owner) catalogCache.delete(owner)
|
||||
else catalogCache.clear()
|
||||
}
|
||||
|
||||
function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
|
||||
@@ -615,6 +634,164 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated metadata page — reads catalog rows only; no existsSync / full publicLibrary map. */
|
||||
export function publicLibraryPage(
|
||||
event: H3Event,
|
||||
opts: { limit?: number; cursor?: string; folderId?: string; type?: string } = {}
|
||||
) {
|
||||
const limit = Math.min(Math.max(Number(opts.limit) || 50, 1), 60)
|
||||
const cursor = String(opts.cursor || '')
|
||||
const folderId = String(opts.folderId || '')
|
||||
const typeFilter = String(opts.type || 'all')
|
||||
const owner = libraryOwnerKey(event)
|
||||
const catalog = readCatalog(owner)
|
||||
const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase()))
|
||||
const clipCounts = new Map<string, number>()
|
||||
const imageCounts = new Map<string, number>()
|
||||
const trackCounts = new Map<string, number>()
|
||||
for (const clip of catalog.clips) {
|
||||
clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1)
|
||||
}
|
||||
for (const still of catalog.stills) {
|
||||
if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue
|
||||
imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1)
|
||||
}
|
||||
for (const track of catalog.tracks || []) {
|
||||
trackCounts.set(track.folderId, (trackCounts.get(track.folderId) || 0) + 1)
|
||||
}
|
||||
const folders = catalog.folders.map(folder => publicFolder(
|
||||
event,
|
||||
folder,
|
||||
clipCounts.get(folder.id) || 0,
|
||||
imageCounts.get(folder.id) || 0,
|
||||
trackCounts.get(folder.id) || 0
|
||||
))
|
||||
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'still' | 'clip' | 'track'
|
||||
createdAt: number
|
||||
thumbUrl: string
|
||||
folderId: string
|
||||
prompt?: string
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
width?: number
|
||||
height?: number
|
||||
role?: string
|
||||
hideInput?: boolean
|
||||
filename?: string
|
||||
duration?: number
|
||||
ext?: string
|
||||
}
|
||||
|
||||
const rows: Row[] = []
|
||||
if (typeFilter !== 'videos' && typeFilter !== 'music') {
|
||||
for (const still of catalog.stills) {
|
||||
if (!unlockedIds.has(still.folderId)) continue
|
||||
if (folderId && folderId !== 'all' && still.folderId !== folderId) continue
|
||||
if (isPipelineStill(catalog, still, clipIds)) continue
|
||||
rows.push({
|
||||
id: still.id,
|
||||
name: still.name || still.filename || still.id,
|
||||
type: 'still',
|
||||
createdAt: still.createdAt || 0,
|
||||
thumbUrl: `/api/library/stills/${still.id}/thumb`,
|
||||
folderId: still.folderId,
|
||||
prompt: still.prompt,
|
||||
promptPre: still.promptPre,
|
||||
promptPost: still.promptPost,
|
||||
width: still.width,
|
||||
height: still.height,
|
||||
role: stillRole(still),
|
||||
hideInput: still.hideInput,
|
||||
filename: still.filename
|
||||
})
|
||||
}
|
||||
}
|
||||
if (typeFilter !== 'images' && typeFilter !== 'music') {
|
||||
for (const clip of catalog.clips) {
|
||||
if (!unlockedIds.has(clip.folderId)) continue
|
||||
if (folderId && folderId !== 'all' && clip.folderId !== folderId) continue
|
||||
rows.push({
|
||||
id: clip.id,
|
||||
name: clip.name || clip.id,
|
||||
type: 'clip',
|
||||
createdAt: clip.createdAt || 0,
|
||||
thumbUrl: `/api/library/clips/${clip.id}/thumb`,
|
||||
folderId: clip.folderId,
|
||||
prompt: clip.prompt,
|
||||
promptPre: clip.promptPre,
|
||||
promptPost: clip.promptPost,
|
||||
width: clip.width,
|
||||
height: clip.height,
|
||||
duration: clip.duration
|
||||
})
|
||||
}
|
||||
}
|
||||
if (typeFilter !== 'images' && typeFilter !== 'videos') {
|
||||
for (const track of catalog.tracks || []) {
|
||||
if (!unlockedIds.has(track.folderId)) continue
|
||||
if (folderId && folderId !== 'all' && track.folderId !== folderId) continue
|
||||
rows.push({
|
||||
id: track.id,
|
||||
name: track.name || track.id,
|
||||
type: 'track',
|
||||
createdAt: track.createdAt || 0,
|
||||
thumbUrl: '',
|
||||
folderId: track.folderId,
|
||||
duration: track.duration,
|
||||
ext: track.ext
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0) || String(b.id).localeCompare(String(a.id)))
|
||||
|
||||
let start = 0
|
||||
if (cursor) {
|
||||
if (cursor.startsWith('offset:')) {
|
||||
start = Math.max(0, Math.min(rows.length, Number(cursor.slice(7)) || 0))
|
||||
} else {
|
||||
const idx = rows.findIndex(row => `${row.type}:${row.id}` === cursor)
|
||||
// Unknown cursor → end (never restart at 0 or the client loops the first page forever)
|
||||
start = idx >= 0 ? idx + 1 : rows.length
|
||||
}
|
||||
}
|
||||
const page = rows.slice(start, start + limit)
|
||||
const nextStart = start + page.length
|
||||
const hasMore = nextStart < rows.length
|
||||
const nextCursor = hasMore ? `offset:${nextStart}` : null
|
||||
|
||||
const stillIds = new Set(page.filter(r => r.type === 'still').map(r => r.id))
|
||||
const clipIdPage = new Set(page.filter(r => r.type === 'clip').map(r => r.id))
|
||||
const trackIds = new Set(page.filter(r => r.type === 'track').map(r => r.id))
|
||||
|
||||
return {
|
||||
folders,
|
||||
items: page,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
limit,
|
||||
stills: catalog.stills
|
||||
.filter(s => stillIds.has(s.id))
|
||||
.map(still => ({ ...still, role: stillRole(still) })),
|
||||
clips: catalog.clips
|
||||
.filter(c => clipIdPage.has(c.id))
|
||||
.map(clip => ({ ...clip, name: clip.name || clip.id, hasThumbnail: !clip.hideThumbnail, hasVideo: true })),
|
||||
tracks: (catalog.tracks || [])
|
||||
.filter(t => trackIds.has(t.id))
|
||||
.map(track => ({ ...track, hasAudio: true })),
|
||||
drafts: catalog.drafts,
|
||||
loraAliases: catalog.loraAliases || {},
|
||||
loraTriggers: catalog.loraTriggers || {},
|
||||
keepPrompts: catalog.keepPrompts || emptyKeepPrompts(),
|
||||
total: rows.length
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLibraryOwner(event: H3Event) {
|
||||
const owner = libraryOwnerKey(event)
|
||||
return { owner, catalog: readCatalog(owner) }
|
||||
@@ -830,7 +1007,7 @@ export async function ensureScaledThumb(source: string, dest: string) {
|
||||
if (pending) return pending
|
||||
const build = withThumbSlot(async () => {
|
||||
const ok = await buildStillThumb(source, dest)
|
||||
return ok ? dest : source
|
||||
return ok ? dest : ''
|
||||
}).finally(() => {
|
||||
thumbBuilds.delete(key)
|
||||
})
|
||||
@@ -1101,14 +1278,42 @@ export function stillThumbFile(owner: string, id: string) {
|
||||
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
|
||||
}
|
||||
|
||||
export function stillPreviewFile(owner: string, id: string) {
|
||||
return join(ownerRoot(owner), 'stills', `${id}.preview.jpg`)
|
||||
}
|
||||
|
||||
/** Sync check — never kicks ffmpeg. Empty string when thumb is missing or stale. */
|
||||
export function existingStillThumbPath(owner: string, id: string) {
|
||||
const source = stillPath(owner, id)
|
||||
const dest = stillThumbFile(owner, id)
|
||||
if (!existsSync(source) || !existsSync(dest)) return ''
|
||||
try {
|
||||
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||
} catch { return '' }
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Sync check for stage preview — never kicks ffmpeg. */
|
||||
export function existingStillPreviewPath(owner: string, id: string) {
|
||||
const source = stillPath(owner, id)
|
||||
const dest = stillPreviewFile(owner, id)
|
||||
if (!existsSync(source) || !existsSync(dest)) return ''
|
||||
try {
|
||||
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||
} catch { return '' }
|
||||
return ''
|
||||
}
|
||||
|
||||
function removeStillFiles(owner: string, id: string) {
|
||||
rmSync(stillPath(owner, id), { force: true })
|
||||
rmSync(stillThumbFile(owner, id), { force: true })
|
||||
rmSync(stillPreviewFile(owner, id), { force: true })
|
||||
}
|
||||
|
||||
let thumbSlots = 0
|
||||
const thumbWaiters: Array<() => void> = []
|
||||
const thumbBuilds = new Map<string, Promise<string>>()
|
||||
const previewBuilds = new Map<string, Promise<string>>()
|
||||
|
||||
function withThumbSlot<T>(fn: () => Promise<T>) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
@@ -1125,14 +1330,14 @@ function withThumbSlot<T>(fn: () => Promise<T>) {
|
||||
})
|
||||
}
|
||||
|
||||
function buildStillThumb(source: string, dest: string) {
|
||||
function buildStillJpeg(source: string, dest: string, scale: string, quality: string) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const proc = spawn('ffmpeg', [
|
||||
'-y',
|
||||
'-i', source,
|
||||
'-vf', 'scale=320:-2',
|
||||
'-vf', scale,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '5',
|
||||
'-q:v', quality,
|
||||
dest
|
||||
], { stdio: 'ignore' })
|
||||
const timer = setTimeout(() => {
|
||||
@@ -1150,6 +1355,14 @@ function buildStillThumb(source: string, dest: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function buildStillThumb(source: string, dest: string) {
|
||||
return buildStillJpeg(source, dest, 'scale=384:-2', '5')
|
||||
}
|
||||
|
||||
function buildStillPreview(source: string, dest: string) {
|
||||
return buildStillJpeg(source, dest, 'scale=1600:-2', '3')
|
||||
}
|
||||
|
||||
export async function stillThumbPath(owner: string, id: string) {
|
||||
const source = stillPath(owner, id)
|
||||
const dest = stillThumbFile(owner, id)
|
||||
@@ -1159,12 +1372,13 @@ export async function stillThumbPath(owner: string, id: string) {
|
||||
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||
} catch { /* rebuild */ }
|
||||
}
|
||||
const key = `${owner}:${id}`
|
||||
const key = `${owner}:${id}:thumb`
|
||||
const pending = thumbBuilds.get(key)
|
||||
if (pending) return pending
|
||||
const build = withThumbSlot(async () => {
|
||||
const ok = await buildStillThumb(source, dest)
|
||||
return ok ? dest : source
|
||||
// Never return the archive master — callers must not stream originals as thumbs
|
||||
return ok ? dest : ''
|
||||
}).finally(() => {
|
||||
thumbBuilds.delete(key)
|
||||
})
|
||||
@@ -1172,11 +1386,40 @@ export async function stillThumbPath(owner: string, id: string) {
|
||||
return build
|
||||
}
|
||||
|
||||
export async function ensureStillThumbnails(owner: string) {
|
||||
export async function stillPreviewPath(owner: string, id: string) {
|
||||
const source = stillPath(owner, id)
|
||||
const dest = stillPreviewFile(owner, id)
|
||||
if (!existsSync(source)) return ''
|
||||
if (existsSync(dest)) {
|
||||
try {
|
||||
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||
} catch { /* rebuild */ }
|
||||
}
|
||||
const key = `${owner}:${id}:preview`
|
||||
const pending = previewBuilds.get(key)
|
||||
if (pending) return pending
|
||||
const build = withThumbSlot(async () => {
|
||||
const ok = await buildStillPreview(source, dest)
|
||||
// Never return the archive master as a stage preview
|
||||
return ok ? dest : ''
|
||||
}).finally(() => {
|
||||
previewBuilds.delete(key)
|
||||
})
|
||||
previewBuilds.set(key, build)
|
||||
return build
|
||||
}
|
||||
|
||||
export async function ensureStillThumbnails(owner: string, opts?: { limit?: number }) {
|
||||
const catalog = readCatalog(owner)
|
||||
const cap = Math.max(0, Math.min(Number(opts?.limit) || 2, 8))
|
||||
let built = 0
|
||||
for (const still of catalog.stills) {
|
||||
if (built >= cap) break
|
||||
if (isPipelineStill(catalog, still)) continue
|
||||
const dest = stillThumbFile(owner, still.id)
|
||||
if (existsSync(dest)) continue
|
||||
await stillThumbPath(owner, still.id)
|
||||
built++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,7 +1429,8 @@ export async function maintainLibrary(owner: string, folderId?: string) {
|
||||
const prev = lastMaintain.get(owner) || 0
|
||||
if (Date.now() - prev < 20000) return
|
||||
lastMaintain.set(owner, Date.now())
|
||||
void ensureStillThumbnails(owner).catch(() => null)
|
||||
// At most 2 missing thumbs per tick — never walk the whole catalog on list
|
||||
void ensureStillThumbnails(owner, { limit: 2 }).catch(() => null)
|
||||
const live = await fetchLiveQueue().catch(() => null)
|
||||
const busy = Boolean(live && live.running > 0)
|
||||
if (!busy) {
|
||||
@@ -1414,6 +1658,7 @@ export async function saveStill(params: {
|
||||
next.stills.unshift(still)
|
||||
})
|
||||
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
|
||||
void stillPreviewPath(params.ownerKey, still.id).catch(() => null)
|
||||
return still
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { ownerRoot } from '~/server/utils/library'
|
||||
import {
|
||||
normalizePromptSequence,
|
||||
normalizePromptSequenceName,
|
||||
normalizeCharacter,
|
||||
normalizeSet,
|
||||
normalizeScene,
|
||||
normalizeSectionOrder,
|
||||
emptyCharacter,
|
||||
emptyScene,
|
||||
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||
type PromptSequence,
|
||||
type PromptSequenceCharacter,
|
||||
type PromptSequenceSectionKey,
|
||||
type PromptSequenceSet,
|
||||
type PromptSequenceScene
|
||||
} from '~/utils/promptSequences'
|
||||
|
||||
const writeChains = new Map<string, Promise<unknown>>()
|
||||
|
||||
function sequencesPath(owner: string) {
|
||||
return join(ownerRoot(owner), 'prompt-sequences.json')
|
||||
}
|
||||
|
||||
function ensureOwner(owner: string) {
|
||||
mkdirSync(ownerRoot(owner), { recursive: true })
|
||||
}
|
||||
|
||||
function readSequences(owner: string): PromptSequence[] {
|
||||
ensureOwner(owner)
|
||||
const path = sequencesPath(owner)
|
||||
if (!existsSync(path)) return []
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
||||
return Array.isArray(parsed)
|
||||
? parsed.map(normalizePromptSequence).filter((item): item is PromptSequence => Boolean(item))
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeSequences(owner: string, sequences: PromptSequence[]) {
|
||||
ensureOwner(owner)
|
||||
const path = sequencesPath(owner)
|
||||
const tmp = `${path}.tmp`
|
||||
writeFileSync(tmp, JSON.stringify(sequences, null, 2))
|
||||
renameSync(tmp, path)
|
||||
}
|
||||
|
||||
function mutate<T>(owner: string, fn: (sequences: PromptSequence[]) => T): Promise<T> {
|
||||
const prev = writeChains.get(owner) || Promise.resolve()
|
||||
const run = prev.then(() => {
|
||||
const sequences = readSequences(owner)
|
||||
const result = fn(sequences)
|
||||
writeSequences(owner, sequences)
|
||||
return result
|
||||
})
|
||||
writeChains.set(owner, run.then(() => undefined, () => undefined))
|
||||
return run
|
||||
}
|
||||
|
||||
function normalizeCharactersInput(raw: unknown): PromptSequenceCharacter[] {
|
||||
if (!Array.isArray(raw) || !raw.length) return [emptyCharacter()]
|
||||
return raw.map(normalizeCharacter)
|
||||
}
|
||||
|
||||
function normalizeSetsInput(raw: unknown): PromptSequenceSet[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map(normalizeSet)
|
||||
}
|
||||
|
||||
function normalizeScenesInput(
|
||||
raw: unknown,
|
||||
characters: PromptSequenceCharacter[],
|
||||
sets: PromptSequenceSet[]
|
||||
): PromptSequenceScene[] {
|
||||
if (!Array.isArray(raw) || !raw.length) return [emptyScene(1, characters, sets)]
|
||||
return raw.map((scene, index) => normalizeScene(scene, index, characters, sets))
|
||||
}
|
||||
|
||||
export function listPromptSequences(owner: string) {
|
||||
return readSequences(owner).sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export function getPromptSequence(owner: string, id: string) {
|
||||
const wanted = String(id || '').trim()
|
||||
const sequence = readSequences(owner).find(item => item.id === wanted)
|
||||
if (!sequence) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
|
||||
return sequence
|
||||
}
|
||||
|
||||
export async function savePromptSequence(owner: string, input: {
|
||||
name: string
|
||||
sectionOrder?: unknown
|
||||
characters?: unknown
|
||||
sets?: unknown
|
||||
scenes?: unknown
|
||||
overwrite?: boolean
|
||||
}) {
|
||||
const name = normalizePromptSequenceName(input.name)
|
||||
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
|
||||
const characters = normalizeCharactersInput(input.characters)
|
||||
const sets = normalizeSetsInput(input.sets)
|
||||
const scenes = normalizeScenesInput(input.scenes, characters, sets)
|
||||
const sectionOrder = input.sectionOrder !== undefined
|
||||
? normalizeSectionOrder(input.sectionOrder)
|
||||
: [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]
|
||||
const now = Date.now()
|
||||
return mutate(owner, (sequences) => {
|
||||
const existing = sequences.find(item => item.name.toLowerCase() === name.toLowerCase())
|
||||
if (existing && !input.overwrite) {
|
||||
throw createError({ statusCode: 409, statusMessage: `Overwrite sequence “${existing.name}”?` })
|
||||
}
|
||||
if (existing) {
|
||||
existing.sectionOrder = sectionOrder
|
||||
existing.characters = characters
|
||||
existing.sets = sets
|
||||
existing.scenes = scenes
|
||||
existing.updatedAt = now
|
||||
return existing
|
||||
}
|
||||
const created: PromptSequence = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
sectionOrder,
|
||||
characters,
|
||||
sets,
|
||||
scenes,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
sequences.unshift(created)
|
||||
return created
|
||||
})
|
||||
}
|
||||
|
||||
export async function updatePromptSequence(owner: string, id: string, input: {
|
||||
name?: string
|
||||
sectionOrder?: unknown
|
||||
characters?: unknown
|
||||
sets?: unknown
|
||||
scenes?: unknown
|
||||
}) {
|
||||
const wanted = String(id || '').trim()
|
||||
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
|
||||
return mutate(owner, (sequences) => {
|
||||
const existing = sequences.find(item => item.id === wanted)
|
||||
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
|
||||
if (input.name !== undefined) {
|
||||
const name = normalizePromptSequenceName(input.name)
|
||||
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
|
||||
const clash = sequences.find(item => item.id !== wanted && item.name.toLowerCase() === name.toLowerCase())
|
||||
if (clash) throw createError({ statusCode: 409, statusMessage: `A sequence named “${clash.name}” already exists` })
|
||||
existing.name = name
|
||||
}
|
||||
if (input.sectionOrder !== undefined) {
|
||||
existing.sectionOrder = normalizeSectionOrder(input.sectionOrder)
|
||||
} else if (!existing.sectionOrder?.length) {
|
||||
existing.sectionOrder = [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER] as PromptSequenceSectionKey[]
|
||||
}
|
||||
if (input.characters !== undefined || input.sets !== undefined || input.scenes !== undefined) {
|
||||
const characters = input.characters !== undefined
|
||||
? normalizeCharactersInput(input.characters)
|
||||
: existing.characters
|
||||
const sets = input.sets !== undefined
|
||||
? normalizeSetsInput(input.sets)
|
||||
: (existing.sets || [])
|
||||
const scenes = input.scenes !== undefined
|
||||
? normalizeScenesInput(input.scenes, characters, sets)
|
||||
: existing.scenes.map((scene, index) => normalizeScene(scene, index, characters, sets))
|
||||
existing.characters = characters
|
||||
existing.sets = sets
|
||||
existing.scenes = scenes
|
||||
}
|
||||
existing.updatedAt = Date.now()
|
||||
return existing
|
||||
})
|
||||
}
|
||||
|
||||
export async function deletePromptSequence(owner: string, id: string) {
|
||||
const wanted = String(id || '').trim()
|
||||
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
|
||||
return mutate(owner, (sequences) => {
|
||||
const index = sequences.findIndex(item => item.id === wanted)
|
||||
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
|
||||
const [removed] = sequences.splice(index, 1)
|
||||
return removed
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize } from '~
|
||||
import { compilePrompt, scopedFile } from '~/shared/studio2/contracts.mjs';
|
||||
import { resolveQwen21Size } from '~/shared/studio2/qwen21-size.mjs';
|
||||
import { ensureQwen21EditPrompt, stitchQwen21PeEditPrompt } from '~/shared/studio2/qwen21-edit.mjs';
|
||||
import { applyPhotosToRequest, injectQwenEditMentionTags } from '~/shared/studio2/photos.mjs';
|
||||
import { qwen21PeRefusal } from '~/shared/studio2/qwen21-pe.mjs';
|
||||
import { QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from '~/shared/studio2/qwen21-turbo.mjs';
|
||||
import { createJob, restoreJob, getJob, emitJob, type Job } from '../jobs';
|
||||
@@ -96,25 +97,38 @@ async function waitPromptHistory(r: any, job: Job, promptId: string) {
|
||||
return history
|
||||
}
|
||||
|
||||
function finalizeQwen21EditSample(rawPrompt: string, request: any) {
|
||||
const photos = Array.isArray(request?.photos) ? request.photos : []
|
||||
const tagged = injectQwenEditMentionTags(String(rawPrompt || ''), photos)
|
||||
return ensureQwen21EditPrompt(tagged, { hasImage2: !!request?.imageBId })
|
||||
}
|
||||
|
||||
async function runQwen21PromptEnhance(r: any, job: Job) {
|
||||
const q = r.request, s = q.settings
|
||||
if (!q.enhancePrompt || q.engine !== 'qwen21' || !['generate', 'edit'].includes(q.mode)) return
|
||||
const edit = q.mode === 'edit'
|
||||
if (Array.isArray(q.photos) && q.photos.length) {
|
||||
const bound = applyPhotosToRequest(q)
|
||||
q.imageAId = bound.imageAId
|
||||
q.imageBId = bound.imageBId
|
||||
q.identityStillId = bound.identityStillId
|
||||
q.photos = bound.photos
|
||||
}
|
||||
// Edit → PE-I2I only (pe_i2i). Generate → PE-T2I only. Never cross-feed.
|
||||
if (edit && !q.imageAId)
|
||||
throw new Error('Qwen Edit requires Start still (image_1). Hero is ignored.')
|
||||
throw new Error('Add a photo and mark it Photo to change.')
|
||||
update(r, 'enhancing')
|
||||
emitJob(job, { type: 'status', message: 'Enhancing prompt…' })
|
||||
// promptRaw = exactly what they typed (compiled from sections before PE).
|
||||
const userPrompt = String(q.compiledPrompt || '')
|
||||
r.request.promptRaw = userPrompt
|
||||
// Edit PE gets the raw edit instruction + Start still — not the keep stanza.
|
||||
// Edit PE gets the raw edit instruction + canvas still — not the keep stanza.
|
||||
const peInput = userPrompt
|
||||
const graph = structuredClone(edit ? qwen21PeEditTemplate : qwen21PeT2iTemplate)
|
||||
graph['9'].inputs.prompt = peInput
|
||||
graph['9'].inputs.seed = s.seed
|
||||
if (edit) {
|
||||
// Same Start still pixels as Edit image_1 — never Hero.
|
||||
// Photo to change → image_1. Outfit/extra → image_2 when present.
|
||||
const a = await upload(r, 'pe-source', readFileSync(stillPath(r.owner, getStill(r.owner, q.imageAId).id)))
|
||||
graph['10'].inputs.image = a
|
||||
graph['9'].inputs.image_1 = ['10', 0]
|
||||
@@ -143,7 +157,7 @@ async function runQwen21PromptEnhance(r: any, job: Job) {
|
||||
const refusal = qwen21PeRefusal(userPrompt, result)
|
||||
if (refusal.refused) {
|
||||
// Fail open: keep the job, send the raw brief (Edit keep-identity stanza still applies).
|
||||
const compiled = edit ? ensureQwen21EditPrompt(userPrompt) : userPrompt
|
||||
const compiled = edit ? finalizeQwen21EditSample(userPrompt, q) : userPrompt
|
||||
r.request.compiledPrompt = compiled
|
||||
r.request.prompt = compiled
|
||||
r.request.enhance = {
|
||||
@@ -160,8 +174,9 @@ async function runQwen21PromptEnhance(r: any, job: Job) {
|
||||
// Edit only: stanza + raw first; PE chunk only if it is an edit directive (never PE-alone).
|
||||
// Generate keeps the long observer paragraph as the product — no describe sanitize.
|
||||
const stitched = stitchQwen21PeEditPrompt(userPrompt, result.positive_prompt)
|
||||
r.request.compiledPrompt = stitched.prompt
|
||||
r.request.prompt = stitched.prompt
|
||||
const compiled = finalizeQwen21EditSample(stitched.prompt, q)
|
||||
r.request.compiledPrompt = compiled
|
||||
r.request.prompt = compiled
|
||||
r.request.enhance = {
|
||||
used: !stitched.skippedAsDescribe,
|
||||
wh_ratio: result.wh_ratio || '',
|
||||
@@ -223,10 +238,18 @@ async function upload(r: any, name: string, data: Buffer): Promise<string> {
|
||||
}
|
||||
async function prepareGraph(r: any) {
|
||||
const q = r.request, s = q.settings, prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`;
|
||||
// Photos roles → graph sockets (imageAId / imageBId / identityStillId). Payload is source of truth.
|
||||
if (Array.isArray(q.photos) && q.photos.length) {
|
||||
const bound = applyPhotosToRequest(q)
|
||||
q.imageAId = bound.imageAId
|
||||
q.imageBId = bound.imageBId
|
||||
q.identityStillId = bound.identityStillId
|
||||
q.photos = bound.photos
|
||||
}
|
||||
const load = async (id: string, label: string) => id ? upload(r, label, readFileSync(stillPath(r.owner, getStill(r.owner, id).id))) : '';
|
||||
const video=['video','extend'].includes(q.mode);
|
||||
const identityPlan=kleinIdentityPlan(q);
|
||||
// Qwen Edit: Start still (imageAId) only — never Hero / identityStillId.
|
||||
// Qwen Edit: Photo to change (imageAId) → image_1. Never Klein hero-ref.
|
||||
const sourceId = q.engine === 'qwen21'
|
||||
? (q.imageAId || '')
|
||||
: (identityPlan ? identityPlan.sourceId : q.imageAId);
|
||||
@@ -304,12 +327,12 @@ async function prepareGraph(r: any) {
|
||||
}
|
||||
const negative = turbo ? '' : stylePrompt(q.imageStyles, true)
|
||||
if (q.mode === 'edit') {
|
||||
// Start still → image_1 only. Never Hero. Never T2I / EmptyLatent.
|
||||
// Photo to change → image_1. Outfit/extra → image_2. Never T2I / EmptyLatent.
|
||||
if (!q.imageAId || !a)
|
||||
throw new Error('Qwen Edit requires Start still (image_1). Hero is ignored.')
|
||||
// promptRaw stays typed; prompt/compiledPrompt = what TextEncode samples.
|
||||
throw new Error('Add a photo and mark it Photo to change.')
|
||||
// promptRaw stays typed; prompt/compiledPrompt = what TextEncode samples (tags injected silently).
|
||||
if (q.promptRaw == null) q.promptRaw = String(q.compiledPrompt || '')
|
||||
const prompt = ensureQwen21EditPrompt(String(q.compiledPrompt || ''))
|
||||
const prompt = finalizeQwen21EditSample(String(q.compiledPrompt || ''), q)
|
||||
q.compiledPrompt = prompt
|
||||
q.prompt = prompt
|
||||
q.task = 'edit'
|
||||
@@ -383,6 +406,8 @@ async function prepareGraph(r: any) {
|
||||
r.sampleDenoise = null
|
||||
r.heroReferenceAttached = false
|
||||
}
|
||||
await ensureComfyLoraNames('image')
|
||||
applyUserLoraToGraph(graph, s.loraStack)
|
||||
}
|
||||
else {
|
||||
const mode = identityPlan?.mode ?? (q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode);
|
||||
@@ -427,6 +452,8 @@ async function run(r: any, job: Job) {
|
||||
r.progress = null;
|
||||
r.request.promptSections = prompts[r.index];
|
||||
r.request.compiledPrompt = compilePrompt(prompts[r.index], ['video', 'extend'].includes(r.request.mode), r.request);
|
||||
// Typed brief before PE / TextEncode — Copy raw / Load input raw need this even when Enhance is off.
|
||||
r.request.promptRaw = String(r.request.compiledPrompt || '');
|
||||
update(r, 'waking');
|
||||
await ensureComfyReady(message => emitJob(job, { type: 'status', message }));
|
||||
if (job.status === 'cancelled')
|
||||
|
||||
@@ -1,25 +1,46 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { openSync, readSync, closeSync } from 'node:fs'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { getClip, getStill, clipVideoPath, stillPath } from '../library'
|
||||
import { imageDimensions } from '../resolution'
|
||||
import { resolveSize } from '~/shared/studio2/input.mjs'
|
||||
const exec = promisify(execFile)
|
||||
|
||||
function readImageHeader(path: string, maxBytes = 65536) {
|
||||
const fd = openSync(path, 'r')
|
||||
try {
|
||||
const buf = Buffer.alloc(maxBytes)
|
||||
const n = readSync(fd, buf, 0, maxBytes, 0)
|
||||
return buf.subarray(0, n)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRequestSize(owner: string, request: any) {
|
||||
const video=['video','extend'].includes(request.mode)
|
||||
let dimensions:any
|
||||
const video = ['video', 'extend'].includes(request.mode)
|
||||
let dimensions: any
|
||||
if (video && request.startClipId) {
|
||||
getClip(owner,request.startClipId)
|
||||
const result=await exec('ffprobe',['-v','error','-select_streams','v:0','-show_entries','stream=width,height','-of','json',clipVideoPath(owner,request.startClipId)],{windowsHide:true,timeout:15000})
|
||||
dimensions=JSON.parse(result.stdout).streams?.[0]
|
||||
const clip = getClip(owner, request.startClipId)
|
||||
if (clip.width && clip.height) {
|
||||
dimensions = { width: clip.width, height: clip.height }
|
||||
} else {
|
||||
const id=request.imageAId || request.identityStillId
|
||||
const result = await exec('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'json', clipVideoPath(owner, request.startClipId)], { windowsHide: true, timeout: 15000 })
|
||||
dimensions = JSON.parse(result.stdout).streams?.[0]
|
||||
}
|
||||
} else {
|
||||
const id = request.imageAId || request.identityStillId
|
||||
if (id) {
|
||||
getStill(owner,id)
|
||||
dimensions=imageDimensions(readFileSync(stillPath(owner,id)))
|
||||
const still = getStill(owner, id)
|
||||
if (still.width && still.height) {
|
||||
dimensions = { width: still.width, height: still.height }
|
||||
} else {
|
||||
// Header only — never read the full archive PNG into memory on Generate click
|
||||
dimensions = imageDimensions(readImageHeader(stillPath(owner, id)))
|
||||
}
|
||||
if (!dimensions) throw new Error('Cannot measure the source still.')
|
||||
}
|
||||
}
|
||||
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
|
||||
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions,undefined,request.engine))
|
||||
Object.assign(request.settings, resolveSize(request.settings.aspect, dimensions, undefined, request.engine))
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { imageV2StackSpecials } from '~/utils/imageV2'
|
||||
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||
|
||||
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
||||
export type StudioJobKind = 'video' | 'edit' | 'music'
|
||||
export type StudioJobKind = 'video' | 'edit' | 'music' | 'caption'
|
||||
|
||||
export interface StudioJobPayload {
|
||||
studio2Id?: string
|
||||
@@ -42,6 +42,7 @@ export interface StudioJobPayload {
|
||||
useIdentityRefs: boolean
|
||||
stillId?: string
|
||||
stillFilename?: string
|
||||
captionStyle?: string
|
||||
hideThumbnail: boolean
|
||||
hideInput?: boolean
|
||||
folderLocked?: boolean
|
||||
@@ -105,6 +106,9 @@ export interface StudioJob {
|
||||
resumeAutoRun?: boolean
|
||||
lastError?: string
|
||||
waitReason?: string
|
||||
resultText?: string
|
||||
/** Hidden from Queue UI; keeps resultText on the stored row. */
|
||||
dismissed?: boolean
|
||||
}
|
||||
|
||||
type StudioQueueStore = {
|
||||
@@ -226,6 +230,7 @@ export function listStudioJobs(owner: string) {
|
||||
export function studioJobKind(job: Pick<StudioJob, 'kind'> | { kind?: string }) {
|
||||
if (job.kind === 'edit') return 'edit'
|
||||
if (job.kind === 'music') return 'music'
|
||||
if (job.kind === 'caption') return 'caption'
|
||||
return 'video'
|
||||
}
|
||||
|
||||
@@ -245,6 +250,8 @@ export function summarizeStudioJob(job: StudioJob) {
|
||||
folderId: job.payload.folderId,
|
||||
musicEngine: job.payload.musicEngine,
|
||||
stillId: job.payload.stillId,
|
||||
captionStyle: job.payload.captionStyle,
|
||||
resultText: job.resultText,
|
||||
workflow: job.payload.workflow,
|
||||
imagePipeline: job.payload.imagePipeline || 'v1',
|
||||
duration: job.payload.duration,
|
||||
@@ -290,7 +297,7 @@ function failZombieLiveJob(job: Job, error: string) {
|
||||
function sweepStaleLiveJobs() {
|
||||
const now = Date.now()
|
||||
for (const job of listJobs()) {
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) continue
|
||||
if (job.saving) continue
|
||||
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
|
||||
failZombieLiveJob(job, 'Job never started')
|
||||
@@ -310,12 +317,12 @@ async function reapZombieLiveJobs() {
|
||||
const { fetchHistory } = await import('~/server/utils/comfy')
|
||||
for (const job of listJobs()) {
|
||||
// Never interrupt download/stitch/library save — Comfy is idle then by design.
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) continue
|
||||
if (job.saving) continue
|
||||
if (job.library?.chainContinuing) continue
|
||||
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
|
||||
if (jobIsLocallySubmitting(job)) continue
|
||||
const zombieMs = (job.kind === 'edit' || job.kind === 'music') ? 45_000 : ZOMBIE_EMPTY_COMFY_MS
|
||||
const zombieMs = (job.kind === 'edit' || job.kind === 'music' || job.kind === 'caption') ? 45_000 : ZOMBIE_EMPTY_COMFY_MS
|
||||
if (jobAgeMs(job) < zombieMs) continue
|
||||
if (!job.promptId) {
|
||||
if (jobAgeMs(job) >= QUEUED_GRACE_MS) failZombieLiveJob(job, 'Job never started on ComfyUI')
|
||||
@@ -355,7 +362,7 @@ async function reapZombieLiveJobs() {
|
||||
}
|
||||
|
||||
function liveJobOwnsGpu(job: Job) {
|
||||
if ((job.studio2 || job.yueGp || job.yue2 || job.upscale) && ['running', 'queued', 'uploading'].includes(job.status)) return true
|
||||
if ((job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) && ['running', 'queued', 'uploading'].includes(job.status)) return true
|
||||
if (job.library?.stopAfterCurrent) return false
|
||||
if (job.library?.chainContinuing) return true
|
||||
if (job.saving) return true
|
||||
@@ -375,7 +382,7 @@ function liveJobOwnsGpu(job: Job) {
|
||||
*/
|
||||
function clearDeadGpuClaimsForForceStart() {
|
||||
for (const live of listJobs()) {
|
||||
if (live.studio2 || live.yueGp || live.yue2 || live.upscale) continue
|
||||
if (live.studio2 || live.yueGp || live.yue2 || live.upscale || live.caption) continue
|
||||
if (live.saving) continue
|
||||
if (jobIsLocallySubmitting(live)) continue
|
||||
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
|
||||
@@ -469,8 +476,11 @@ export async function addStudioJob(params: {
|
||||
kind?: StudioJobKind
|
||||
}) {
|
||||
const now = Date.now()
|
||||
const kind = params.kind === 'edit' ? 'edit' : params.kind === 'music' ? 'music' : 'video'
|
||||
const shotCount = kind === 'music'
|
||||
const kind = params.kind === 'edit' ? 'edit'
|
||||
: params.kind === 'music' ? 'music'
|
||||
: params.kind === 'caption' ? 'caption'
|
||||
: 'video'
|
||||
const shotCount = kind === 'music' || kind === 'caption'
|
||||
? 1
|
||||
: kind === 'edit'
|
||||
? 1 + (params.payload.passes?.length || 0)
|
||||
@@ -527,6 +537,40 @@ function canCancelStudioStatus(status: StudioJobStatus) {
|
||||
return status === 'waiting' || status === 'held' || status === 'running' || status === 'error'
|
||||
}
|
||||
|
||||
function isFinishedStudioStatus(status: StudioJobStatus) {
|
||||
return status === 'complete' || status === 'error' || status === 'cancelled'
|
||||
}
|
||||
|
||||
/** Hide a finished studio-queue row from the Queue panel (keeps resultText). */
|
||||
export async function dismissStudioJob(owner: string, id: string) {
|
||||
return mutate(owner, (jobs) => {
|
||||
const job = jobs.find(item => item.id === id)
|
||||
if (!job) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||
if (!isFinishedStudioStatus(job.status)) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Only finished jobs can be dismissed' })
|
||||
}
|
||||
job.dismissed = true
|
||||
job.updatedAt = Date.now()
|
||||
return structuredClone(job)
|
||||
})
|
||||
}
|
||||
|
||||
/** Dismiss finished studio-queue rows (caption complete cards, errors, etc.). */
|
||||
export async function dismissFinishedStudioJobs(owner: string, ids?: string[]) {
|
||||
return mutate(owner, (jobs) => {
|
||||
let cleared = 0
|
||||
for (const job of jobs) {
|
||||
if (ids?.length && !ids.includes(job.id)) continue
|
||||
if (job.dismissed) continue
|
||||
if (!isFinishedStudioStatus(job.status)) continue
|
||||
job.dismissed = true
|
||||
job.updatedAt = Date.now()
|
||||
cleared += 1
|
||||
}
|
||||
return cleared
|
||||
})
|
||||
}
|
||||
|
||||
export async function cancelStudioJob(owner: string, id: string) {
|
||||
const before = readJobs(owner).find(item => item.id === id)
|
||||
if (!before) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||
@@ -606,6 +650,10 @@ export async function clearStuckStudioWork(owner: string) {
|
||||
const { cancelYue2Job } = await import('./yue2')
|
||||
await cancelYue2Job(job)
|
||||
}
|
||||
if (job.caption) {
|
||||
const { cancelCaptionJob } = await import('./caption')
|
||||
await cancelCaptionJob(job)
|
||||
}
|
||||
job.status = 'cancelled'
|
||||
job.error = 'Cleared by force reset'
|
||||
if (job.library) {
|
||||
@@ -678,6 +726,11 @@ async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
|
||||
await cancelYue2Job(live)
|
||||
return
|
||||
}
|
||||
if (live?.caption) {
|
||||
const { cancelCaptionJob } = await import('./caption')
|
||||
await cancelCaptionJob(live)
|
||||
return
|
||||
}
|
||||
if (live) {
|
||||
live.status = 'cancelled'
|
||||
if (live.library) {
|
||||
@@ -889,6 +942,10 @@ function pendingAlive(job: StudioJob) {
|
||||
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yue2-pending', `${job.liveJobId}.json`)
|
||||
if (existsSync(root)) return true
|
||||
}
|
||||
if (job.kind === 'caption') {
|
||||
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'caption-pending', `${job.liveJobId}.json`)
|
||||
if (existsSync(root)) return true
|
||||
}
|
||||
}
|
||||
if (!job.shotQueueId) return false
|
||||
return listPendingJobs().some(pending => (
|
||||
@@ -1397,6 +1454,32 @@ async function startStudioExtendJob(item: StudioJob) {
|
||||
}
|
||||
}
|
||||
|
||||
async function startStudioCaptionJob(item: StudioJob) {
|
||||
let live: import('~/server/utils/jobs').Job | undefined
|
||||
try {
|
||||
const { startCaptionJob } = await import('./caption')
|
||||
const style = String(item.payload.captionStyle || 'descriptive')
|
||||
const stillId = String(item.payload.stillId || '')
|
||||
if (!stillId) throw new Error('Caption requires a still.')
|
||||
live = await startCaptionJob({
|
||||
ownerKey: item.ownerKey,
|
||||
folderId: item.payload.folderId,
|
||||
stillId,
|
||||
captionStyle: style,
|
||||
name: item.payload.name
|
||||
})
|
||||
await markStudioLive(item.ownerKey, item.id, live.id)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')) {
|
||||
live.status = 'error'
|
||||
live.error = message
|
||||
}
|
||||
await parkStudioOnStartFailure(item.ownerKey, item.id, message)
|
||||
kickStudioQueue()
|
||||
}
|
||||
}
|
||||
|
||||
async function startStudioMusicJob(item: StudioJob) {
|
||||
let live: import('~/server/utils/jobs').Job | undefined
|
||||
try {
|
||||
@@ -1439,6 +1522,10 @@ export async function startStudioJob(item: StudioJob) {
|
||||
}
|
||||
|
||||
async function startStudioJobReserved(item: StudioJob) {
|
||||
if (studioJobKind(item) === 'caption') {
|
||||
await startStudioCaptionJob(item)
|
||||
return
|
||||
}
|
||||
if (item.payload.studio2Id) {
|
||||
try {
|
||||
const { startStudio2Job } = await import('./studio2/runner')
|
||||
@@ -1703,7 +1790,7 @@ export async function onLiveVideoSettled(job: Job) {
|
||||
return
|
||||
}
|
||||
const remaining = remainingStudioShots(job)
|
||||
const wakeFail = !job.studio2 && !job.yueGp && !job.yue2 && !job.upscale && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
|
||||
const wakeFail = !job.studio2 && !job.yueGp && !job.yue2 && !job.upscale && !job.caption && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
|
||||
const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail
|
||||
|
||||
await mutateStore(owner, (store) => {
|
||||
@@ -1777,6 +1864,7 @@ export async function onLiveVideoSettled(job: Job) {
|
||||
: failed
|
||||
? 'error'
|
||||
: 'complete'
|
||||
if (job.caption && job.resultText) row.resultText = job.resultText
|
||||
clearStudioRowSlot(row, status, failed ? job.error : undefined)
|
||||
syncPausedFlag(store)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export const CAPTION_STYLES = ['descriptive', 'klein_prompt', 'delta', 'tags']
|
||||
|
||||
export const DELTA_LOCKED_LOOK =
|
||||
'short light-brown bob, pink tank, light blue shorts, open green Slytherin robe with red-gold trim, barefoot'
|
||||
|
||||
export function captionStylePrompt(style) {
|
||||
switch (style) {
|
||||
case 'klein_prompt':
|
||||
return 'Write one self-contained image generation prompt for Flux Klein. Include subject, pose, clothing, setting, lighting, and camera. No preamble or quotes.'
|
||||
case 'delta':
|
||||
return `The character's locked baseline look is: ${DELTA_LOCKED_LOOK}. Describe ONLY what changed from that baseline in this image. Do not restate the locked look.`
|
||||
case 'tags':
|
||||
return 'List comma-separated descriptive tags for this image. Tags only, no sentences.'
|
||||
case 'descriptive':
|
||||
default:
|
||||
return 'Describe this image in clear, detailed prose. Cover subject, appearance, clothing, pose, setting, and lighting.'
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCaptionBody(raw) {
|
||||
const fail = message => { throw Object.assign(new Error(message), { statusCode: 400 }) }
|
||||
const body = raw || {}
|
||||
const style = String(body.captionStyle || body.style || 'descriptive')
|
||||
if (!CAPTION_STYLES.includes(style)) fail('Unknown caption style.')
|
||||
const folderId = String(body.folderId || '')
|
||||
if (!folderId) fail('Choose a folder.')
|
||||
const stillId = String(body.stillId || body.sourcePath || '')
|
||||
if (!stillId) fail('Choose a still to describe.')
|
||||
return { folderId, stillId, captionStyle: style }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { queueSeeds } from './seed.mjs'
|
||||
import { sectionsFrom, compileSections } from './prompt.mjs'
|
||||
import { QWEN21_ASPECT_IDS, resolveQwen21Size } from './qwen21-size.mjs'
|
||||
import { QWEN21_TURBO_CFG, QWEN21_TURBO_LORA, QWEN21_TURBO_SIGMAS, QWEN21_TURBO_STEPS } from './qwen21-turbo.mjs'
|
||||
import { applyPhotosToRequest } from './photos.mjs'
|
||||
export { sectionsFrom } from './prompt.mjs'
|
||||
export const cameraFamilies = ['static','dolly in','dolly out','pan left','pan right','tilt up','tilt down','zoom in','zoom out','orbit left','orbit right']
|
||||
export const identityLock = '<Picture 1> / hero is identity for face, hair, glasses, body, and wardrobe. Keep the person and clothes fully preserved. Start frame is composition, pose, place, and camera only. Do not copy facial drift from the start frame.'
|
||||
@@ -28,7 +29,7 @@ export function restoreInput(item) {
|
||||
return { ...saved, lockFace:saved.lockFace !== false, lockOutfit:saved.lockOutfit !== false, hideInput:!!saved.hideInput, settings:structuredClone(saved.settings || {}), promptSections: sectionsFrom(saved.promptSections || { action: item.prompt || saved.compiledPrompt || '' }) }
|
||||
}
|
||||
export function validateRequest(raw, xaigen = false) {
|
||||
raw = raw || {}
|
||||
raw = applyPhotosToRequest(raw || {})
|
||||
const fail = message => { throw Object.assign(new Error(message), {statusCode:400}) }
|
||||
const mode = raw.mode || 'generate', engine = raw.engine || 'flux'
|
||||
if (!['generate','edit','compose','refine','iterate','video','extend'].includes(mode)) fail('Unknown task.')
|
||||
@@ -41,13 +42,13 @@ export function validateRequest(raw, xaigen = false) {
|
||||
if (Array.isArray(raw.guides) && raw.guides.length>8) fail('Use up to eight guide stills.')
|
||||
if (!raw.folderId) fail('Choose a folder.')
|
||||
if (['edit','compose','refine'].includes(mode) && !raw.imageAId && !(mode==='edit' && engine==='flux' && raw.identityStillId)) {
|
||||
if (engine === 'qwen21') fail('Qwen Edit requires Start still (image_1). Hero is ignored.')
|
||||
fail('Choose the source still.')
|
||||
if (engine === 'qwen21') fail('Add a photo and mark it Photo to change.')
|
||||
fail('Add a photo to change.')
|
||||
}
|
||||
if ((video || ['edit','compose','iterate'].includes(mode)) && engine !== 'qwen21' && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Choose a sticky hero still.')
|
||||
if (mode === 'compose' && !raw.imageBId) fail('Compose requires a second still.')
|
||||
if ((video || ['edit','compose','iterate'].includes(mode)) && engine !== 'qwen21' && (raw.lockFace !== false || raw.lockOutfit !== false) && !raw.identityStillId && (video || !raw.imageAId)) fail('Add a Person to keep photo.')
|
||||
if (mode === 'compose' && !raw.imageBId) fail('Compose needs a second photo.')
|
||||
if (mode === 'refine' && !raw.maskId) fail('Refine requires a mask.')
|
||||
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip or Previous clip.')
|
||||
if (mode === 'extend' && !raw.startClipId) fail('Select the clip in the family strip.')
|
||||
const allSections = sectionsFrom(raw.promptSections || {action:raw.prompt || ''})
|
||||
const promptSections = video ? allSections : {action:allSections.action}
|
||||
if (!promptSections.action.trim()) fail('Enter an action prompt.')
|
||||
@@ -75,9 +76,10 @@ export function validateRequest(raw, xaigen = false) {
|
||||
const steps = turbo ? QWEN21_TURBO_STEPS : number('steps',defaultSteps,1,100)
|
||||
const cfg = turbo ? QWEN21_TURBO_CFG : number('cfg',defaultCfg,0,20)
|
||||
return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, enhancePrompt, turbo, ...(turbo ? { lora: QWEN21_TURBO_LORA, sigmas: QWEN21_TURBO_SIGMAS } : {}), lockFace:qwen ? false : raw.lockFace !== false, lockOutfit:qwen ? false : raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:qwen ? '' : (raw.identityStillId || ''), imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '',
|
||||
photos: Array.isArray(raw.photos) ? raw.photos : [],
|
||||
startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''},
|
||||
promptSections, compiledPrompt:compilePrompt(promptSections,video,{...raw,engine,lockFace:qwen?false:raw.lockFace,lockOutfit:qwen?false:raw.lockOutfit}),
|
||||
settings: { aspect, fps:number('fps',24,1,60), width, height, steps, cfg, seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:qwen?[]:(Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[]) },
|
||||
settings: { aspect, fps:number('fps',24,1,60), width, height, steps, cfg, seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] },
|
||||
guides: (Array.isArray(raw.guides)?raw.guides:[]).slice(0,8).map(g=>{ if (!g.stillId || !Number.isInteger(g.frame) || g.frame<1 || g.frame>=frames) fail('Each guide requires a still and positive frame index.'); return {stillId:String(g.stillId),frame:g.frame} }),
|
||||
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}}) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Inspector Photos: role cards (plain English). The runner maps roles → graph sockets.
|
||||
* UI never teaches <imageN> / Hero / Edit source.
|
||||
*/
|
||||
|
||||
export const PHOTO_ROLE = {
|
||||
person: 'person',
|
||||
change: 'change',
|
||||
outfit: 'outfit',
|
||||
look: 'look',
|
||||
first: 'first',
|
||||
extra: 'extra'
|
||||
}
|
||||
|
||||
export const PHOTO_ROLE_LABEL = {
|
||||
person: 'Person to keep',
|
||||
change: 'Photo to change',
|
||||
outfit: 'Outfit / object',
|
||||
look: 'Look / style',
|
||||
first: 'First frame',
|
||||
extra: 'Extra photo'
|
||||
}
|
||||
|
||||
/** Human phrase inserted by Mention in prompt (not a Comfy token). */
|
||||
export function mentionPhrase(role, index = 0) {
|
||||
const r = String(role || '')
|
||||
if (r === PHOTO_ROLE.change) return 'this photo'
|
||||
if (r === PHOTO_ROLE.outfit) return 'the outfit photo'
|
||||
if (r === PHOTO_ROLE.person) return 'the face photo'
|
||||
if (r === PHOTO_ROLE.look) return 'the look photo'
|
||||
if (r === PHOTO_ROLE.first) return 'the first frame'
|
||||
return `Photo ${Number(index) + 1}`
|
||||
}
|
||||
|
||||
export function insertMention(text, phrase, selectionStart = 0, selectionEnd = 0) {
|
||||
const value = String(text || '')
|
||||
const chip = String(phrase || '')
|
||||
if (!chip) return { text: value, selectionStart: 0, selectionEnd: 0 }
|
||||
const existing = value.indexOf(chip)
|
||||
if (existing >= 0) {
|
||||
return { text: value, selectionStart: existing, selectionEnd: existing + chip.length }
|
||||
}
|
||||
const start = Math.max(0, Math.min(Number(selectionStart) || 0, value.length))
|
||||
const end = Math.max(start, Math.min(Number(selectionEnd) || start, value.length))
|
||||
const next = `${value.slice(0, start)}${chip}${value.slice(end)}`
|
||||
return { text: next, selectionStart: start, selectionEnd: start + chip.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{
|
||||
* show: boolean,
|
||||
* max: number,
|
||||
* roles: string[],
|
||||
* defaultRole: string,
|
||||
* emptyHint: string|null,
|
||||
* handoffReadonly: boolean
|
||||
* }}
|
||||
*/
|
||||
export function photosPlan({ mode, engine } = {}) {
|
||||
const task = String(mode || 'generate')
|
||||
const eng = String(engine || 'flux')
|
||||
|
||||
if (task === 'music') {
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
|
||||
if (eng === 'qwen21') {
|
||||
if (task === 'generate') {
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
if (task === 'edit') {
|
||||
return {
|
||||
show: true,
|
||||
max: 3,
|
||||
roles: [PHOTO_ROLE.change, PHOTO_ROLE.outfit, PHOTO_ROLE.person, PHOTO_ROLE.extra],
|
||||
defaultRole: PHOTO_ROLE.change,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
|
||||
if (eng === 'krea') {
|
||||
if (task === 'generate') {
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
if (task === 'refine') {
|
||||
return {
|
||||
show: true,
|
||||
max: 1,
|
||||
roles: [PHOTO_ROLE.change],
|
||||
defaultRole: PHOTO_ROLE.change,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
|
||||
if (task === 'extend') {
|
||||
return {
|
||||
show: true,
|
||||
max: 0,
|
||||
roles: [],
|
||||
defaultRole: '',
|
||||
emptyHint: null,
|
||||
handoffReadonly: true
|
||||
}
|
||||
}
|
||||
|
||||
if (task === 'video') {
|
||||
if (eng === 'minimax') {
|
||||
return {
|
||||
show: true,
|
||||
max: 2,
|
||||
roles: [PHOTO_ROLE.first, PHOTO_ROLE.person, PHOTO_ROLE.look],
|
||||
defaultRole: PHOTO_ROLE.first,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
// LTX: first frame only — existing start binding
|
||||
return {
|
||||
show: true,
|
||||
max: 1,
|
||||
roles: [PHOTO_ROLE.first],
|
||||
defaultRole: PHOTO_ROLE.first,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
|
||||
// Klein (flux)
|
||||
if (task === 'generate') {
|
||||
return {
|
||||
show: true,
|
||||
max: 1,
|
||||
roles: [PHOTO_ROLE.person],
|
||||
defaultRole: PHOTO_ROLE.person,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
if (task === 'edit' || task === 'iterate') {
|
||||
return {
|
||||
show: true,
|
||||
max: 2,
|
||||
roles: [PHOTO_ROLE.change, PHOTO_ROLE.person],
|
||||
defaultRole: PHOTO_ROLE.change,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
if (task === 'compose') {
|
||||
return {
|
||||
show: true,
|
||||
max: 3,
|
||||
roles: [PHOTO_ROLE.change, PHOTO_ROLE.person, PHOTO_ROLE.extra],
|
||||
defaultRole: PHOTO_ROLE.change,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
if (task === 'refine') {
|
||||
return {
|
||||
show: true,
|
||||
max: 1,
|
||||
roles: [PHOTO_ROLE.change],
|
||||
defaultRole: PHOTO_ROLE.change,
|
||||
emptyHint: 'Add a photo, then say what it is.',
|
||||
handoffReadonly: false
|
||||
}
|
||||
}
|
||||
|
||||
return { show: false, max: 0, roles: [], defaultRole: '', emptyHint: null, handoffReadonly: false }
|
||||
}
|
||||
|
||||
export function normalizePhotos(raw, plan) {
|
||||
const list = Array.isArray(raw) ? raw : []
|
||||
const roles = new Set(plan?.roles || [])
|
||||
const max = Math.max(0, Number(plan?.max) || 0)
|
||||
const out = []
|
||||
for (const row of list) {
|
||||
if (out.length >= max) break
|
||||
const stillId = String(row?.stillId || row?.id || '').trim()
|
||||
if (!stillId) continue
|
||||
let role = String(row?.role || plan?.defaultRole || '').trim()
|
||||
if (!roles.has(role)) role = plan?.defaultRole || [...roles][0] || PHOTO_ROLE.extra
|
||||
out.push({ stillId, role })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** First card wins when two share a role. Returns duplicate role label or null. */
|
||||
export function duplicatePhotoRole(photos) {
|
||||
const seen = new Set()
|
||||
for (const row of photos || []) {
|
||||
const role = String(row?.role || '')
|
||||
if (!role) continue
|
||||
if (seen.has(role)) return PHOTO_ROLE_LABEL[role] || role
|
||||
seen.add(role)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Photos cards → legacy request fields the graphs already consume.
|
||||
* Qwen Edit: change→imageAId, outfit|extra→imageBId, person→imageBId if empty.
|
||||
* Klein: person→identityStillId, change→imageAId.
|
||||
*/
|
||||
export function bindPhotosToRequest(photos, { mode, engine } = {}) {
|
||||
const task = String(mode || 'generate')
|
||||
const eng = String(engine || 'flux')
|
||||
const list = Array.isArray(photos) ? photos.filter(p => p?.stillId) : []
|
||||
const find = (...roles) => list.find(p => roles.includes(p.role))
|
||||
|
||||
let imageAId = ''
|
||||
let imageBId = ''
|
||||
let identityStillId = ''
|
||||
|
||||
if (eng === 'qwen21' && task === 'edit') {
|
||||
let change = find(PHOTO_ROLE.change)
|
||||
if (!change && list[0]) change = { ...list[0], role: PHOTO_ROLE.change }
|
||||
const outfit = find(PHOTO_ROLE.outfit, PHOTO_ROLE.extra)
|
||||
const person = find(PHOTO_ROLE.person)
|
||||
imageAId = change?.stillId || ''
|
||||
imageBId = outfit?.stillId || ''
|
||||
if (!imageBId && person) imageBId = person.stillId
|
||||
// Person with outfit already in image_2: leave as imageBId only (2-input graph).
|
||||
identityStillId = ''
|
||||
return { imageAId, imageBId, identityStillId, photos: list }
|
||||
}
|
||||
|
||||
if (eng === 'krea' && task === 'refine') {
|
||||
imageAId = find(PHOTO_ROLE.change)?.stillId || list[0]?.stillId || ''
|
||||
return { imageAId, imageBId: '', identityStillId: '', photos: list }
|
||||
}
|
||||
|
||||
if (task === 'video' || (task === 'extend' && eng === 'minimax')) {
|
||||
imageAId = find(PHOTO_ROLE.first)?.stillId || list[0]?.stillId || ''
|
||||
identityStillId = find(PHOTO_ROLE.person, PHOTO_ROLE.look)?.stillId || ''
|
||||
return { imageAId, imageBId: '', identityStillId, photos: list }
|
||||
}
|
||||
|
||||
if (eng === 'ltx' && task === 'video') {
|
||||
imageAId = find(PHOTO_ROLE.first)?.stillId || list[0]?.stillId || ''
|
||||
return { imageAId, imageBId: '', identityStillId: '', photos: list }
|
||||
}
|
||||
|
||||
// Klein / flux
|
||||
if (task === 'generate') {
|
||||
identityStillId = find(PHOTO_ROLE.person)?.stillId || list[0]?.stillId || ''
|
||||
return { imageAId: '', imageBId: '', identityStillId, photos: list }
|
||||
}
|
||||
if (task === 'edit' || task === 'iterate' || task === 'compose' || task === 'refine') {
|
||||
imageAId = find(PHOTO_ROLE.change)?.stillId || list[0]?.stillId || ''
|
||||
identityStillId = find(PHOTO_ROLE.person)?.stillId || ''
|
||||
imageBId = find(PHOTO_ROLE.extra)?.stillId || ''
|
||||
return { imageAId, imageBId, identityStillId, photos: list }
|
||||
}
|
||||
|
||||
return { imageAId, imageBId, identityStillId, photos: list }
|
||||
}
|
||||
|
||||
/** Hydrate Photos cards from legacy hero/source fields when loading a job. */
|
||||
export function photosFromRequest(request = {}) {
|
||||
if (Array.isArray(request.photos) && request.photos.length) {
|
||||
return request.photos.map(p => ({
|
||||
stillId: String(p.stillId || p.id || ''),
|
||||
role: String(p.role || PHOTO_ROLE.change)
|
||||
})).filter(p => p.stillId)
|
||||
}
|
||||
const mode = String(request.mode || 'generate')
|
||||
const engine = String(request.engine || 'flux')
|
||||
const out = []
|
||||
if (engine === 'qwen21' && mode === 'edit') {
|
||||
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.change })
|
||||
if (request.imageBId) out.push({ stillId: request.imageBId, role: PHOTO_ROLE.outfit })
|
||||
return out
|
||||
}
|
||||
if (mode === 'generate' && engine === 'flux' && request.identityStillId) {
|
||||
out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||
return out
|
||||
}
|
||||
if (['edit', 'iterate', 'compose', 'refine'].includes(mode)) {
|
||||
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.change })
|
||||
if (request.identityStillId && request.identityStillId !== request.imageAId) {
|
||||
out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||
}
|
||||
if (request.imageBId) out.push({ stillId: request.imageBId, role: PHOTO_ROLE.extra })
|
||||
return out
|
||||
}
|
||||
if (mode === 'video') {
|
||||
if (request.imageAId) out.push({ stillId: request.imageAId, role: PHOTO_ROLE.first })
|
||||
if (request.identityStillId) out.push({ stillId: request.identityStillId, role: PHOTO_ROLE.person })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Photos → request fields. Call before validate / queue.
|
||||
* If Edit+Qwen has cards but no Photo to change, promote the first card.
|
||||
* When `photos` is an explicit array (even empty), it is source of truth —
|
||||
* do not resurrect cards from leftover imageAId / identityStillId.
|
||||
*/
|
||||
export function applyPhotosToRequest(raw = {}) {
|
||||
const next = { ...raw }
|
||||
const plan = photosPlan({ mode: next.mode, engine: next.engine })
|
||||
if (!plan.show || plan.handoffReadonly) {
|
||||
if (!Array.isArray(next.photos)) next.photos = []
|
||||
return next
|
||||
}
|
||||
const photosProvided = Array.isArray(raw.photos)
|
||||
let photos = normalizePhotos(next.photos, plan)
|
||||
if (!photosProvided && !photos.length && (next.imageAId || next.identityStillId || next.imageBId)) {
|
||||
photos = normalizePhotos(photosFromRequest(next), plan)
|
||||
}
|
||||
// Promote first card to Photo to change when Edit needs it.
|
||||
if (
|
||||
plan.roles.includes(PHOTO_ROLE.change)
|
||||
&& photos.length
|
||||
&& !photos.some(p => p.role === PHOTO_ROLE.change)
|
||||
) {
|
||||
photos = [{ ...photos[0], role: PHOTO_ROLE.change }, ...photos.slice(1)]
|
||||
}
|
||||
const bound = bindPhotosToRequest(photos, { mode: next.mode, engine: next.engine })
|
||||
next.photos = bound.photos
|
||||
next.imageAId = bound.imageAId
|
||||
next.imageBId = bound.imageBId
|
||||
next.identityStillId = bound.identityStillId
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen Edit: map mention phrases → <imageN> for TextEncode.
|
||||
* Photo to change → <image1>; outfit/extra/person-on-2 → <image2>.
|
||||
*/
|
||||
export function injectQwenEditMentionTags(prompt, photos = []) {
|
||||
let text = String(prompt || '')
|
||||
const list = Array.isArray(photos) ? photos.filter(p => p?.stillId) : []
|
||||
let change = list.find(p => p.role === PHOTO_ROLE.change)
|
||||
if (!change && list[0]) change = { ...list[0], role: PHOTO_ROLE.change }
|
||||
const outfit = list.find(p => p.role === PHOTO_ROLE.outfit || p.role === PHOTO_ROLE.extra)
|
||||
const person = list.find(p => p.role === PHOTO_ROLE.person)
|
||||
const image2 = outfit || (!outfit ? person : null)
|
||||
|
||||
const pairs = []
|
||||
if (change) {
|
||||
const idx = list.indexOf(list.find(p => p.stillId === change.stillId && p.role === change.role) || change)
|
||||
pairs.push({ phrase: mentionPhrase(PHOTO_ROLE.change, Math.max(0, idx)), tag: '<image1>' })
|
||||
// Also map generic Photo 1 if they used chip style
|
||||
pairs.push({ phrase: 'Photo 1', tag: '<image1>' })
|
||||
}
|
||||
if (image2) {
|
||||
const idx = list.findIndex(p => p.stillId === image2.stillId)
|
||||
pairs.push({ phrase: mentionPhrase(image2.role, Math.max(0, idx)), tag: '<image2>' })
|
||||
pairs.push({ phrase: 'Photo 2', tag: '<image2>' })
|
||||
}
|
||||
|
||||
// Longer phrases first so "the outfit photo" wins over fragments.
|
||||
pairs.sort((a, b) => b.phrase.length - a.phrase.length)
|
||||
for (const { phrase, tag } of pairs) {
|
||||
if (!phrase || text.includes(tag) && !text.includes(phrase)) continue
|
||||
if (text.includes(phrase)) text = text.split(phrase).join(tag)
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -28,7 +28,11 @@ export function sectionsFrom(value = {}) {
|
||||
export function compileSections(value) {
|
||||
const s = sectionsFrom(value)
|
||||
const camera = s.cameraBeats.length ? ['One continuous take.', ...s.cameraBeats.map((b,i) => {
|
||||
const move = b.moves[0] === 'hold' ? 'hold the camera locked on a tripod' : b.moves.map(m=>m.replaceAll('-',' ')).join(' and ') + (b.moves.length > 1 ? ' simultaneously' : '')
|
||||
const hold = b.moves[0] === 'hold' || b.moves.includes('hold')
|
||||
if (hold) {
|
||||
return `${i ? 'Then' : 'First'}, hold the camera locked on a tripod.`
|
||||
}
|
||||
const move = b.moves.map(m=>m.replaceAll('-',' ')).join(' and ') + (b.moves.length > 1 ? ' simultaneously' : '')
|
||||
const timing = b.durationSec == null ? 'through the end of the remaining clip' : i === 0 ? `for the first ${b.durationSec} seconds` : `for ${b.durationSec} seconds`
|
||||
return `${i ? 'Then' : 'First'}, ${move}, ${{subtle:'small',moderate:'medium',large:'large'}[b.amplitude]} amplitude, ${b.speed} speed, ${timing}.`
|
||||
}), 'No cuts. No new setup.'].join(' ') : s.camera
|
||||
|
||||
@@ -20,11 +20,16 @@ export function buildQwen21EditKeepStanza(rawInstruction) {
|
||||
}
|
||||
|
||||
/** Enhance off / refusal: ensure the sample prompt is the keep stanza, never PE-only. */
|
||||
export function ensureQwen21EditPrompt(prompt) {
|
||||
export function ensureQwen21EditPrompt(prompt, options = {}) {
|
||||
const text = String(prompt || '').trim()
|
||||
if (!text) return text
|
||||
if (/Keep the identity, face, body, pose, and likeness of the person in <image1>/i.test(text)) return text
|
||||
return buildQwen21EditKeepStanza(text)
|
||||
let next = /Keep the identity, face, body, pose, and likeness of the person in <image1>/i.test(text)
|
||||
? text
|
||||
: buildQwen21EditKeepStanza(text)
|
||||
if (options.hasImage2 && !/<image2>/i.test(next)) {
|
||||
next = `${next}\nUse the garment or object in <image2>.`.trim()
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** PE rewrite looks like a T2I observer caption, not an edit directive. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
|
||||
export function gpuStatus(jobs, health, upscales=[], musicJobs=[], captionJobs=[]) {
|
||||
if (!health) return {label:'Status unavailable',tone:'unknown',detail:'Checking GPU connection…'}
|
||||
const upscale=upscales.find(j=>j.status==='running')
|
||||
if(upscale)return {label:'Upscaling',tone:'busy',detail:upscale.name || 'Video upscale'}
|
||||
@@ -7,6 +7,9 @@ export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
|
||||
const stage=music.live?.message || music.live?.stage || music.waitReason || 'YuE2'
|
||||
return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)}
|
||||
}
|
||||
const caption=captionJobs.find(j=>['waiting','running','held'].includes(j.status) || ['queued','running','uploading'].includes(j.live?.status))
|
||||
if(caption)return {label:caption.status==='waiting'?'Queued':'Describing',tone:'busy',detail:caption.payload?.captionStyle || caption.live?.message || 'Caption'}
|
||||
if(health.caption?.busy)return {label:'Describing',tone:'busy',detail:'Caption model is using the GPU'}
|
||||
if(health.yue2?.busy)return {label:'Generating',tone:'busy',detail:'YuE2 is using the GPU'}
|
||||
const job=jobs.find(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state))
|
||||
if(job)return {label:{waking:'Waking up',enhancing:'Enhancing prompt…',submitting:'Submitting',rendering:'Generating…',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
|
||||
|
||||
@@ -9,5 +9,13 @@ export function normalizeStyles(value={}) {
|
||||
}
|
||||
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(', ')}
|
||||
/** Single blank line (mobile / video / edit batchText). */
|
||||
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
|
||||
/**
|
||||
* Desktop image Batch toggle: split only on a double blank line (two empty lines → \n\n\n+).
|
||||
* A single newline or a single blank line stays inside the same prompt.
|
||||
*/
|
||||
export function parseBatchDoubleBlank(value){
|
||||
return String(value||'').split(/\r?\n(?:[ \t]*\r?\n){2,}/).map(s=>s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ export function folderAssets(library, jobs, folderId) {
|
||||
export function newestOutput(jobs, seen, folderId) {
|
||||
return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null
|
||||
}
|
||||
export function reconcileSelection(selected, assets) { return assets.find(a=>a.id===selected?.id) || assets.at(-1) || null }
|
||||
export function reconcileSelection(selected, assets) {
|
||||
if (!selected?.id) return assets.at(-1) || null
|
||||
return assets.find(a => a.id === selected.id) || selected
|
||||
}
|
||||
export function activeJobs(jobs) { return jobs.filter(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state)) }
|
||||
export function previewHidden(asset, revealed, xaigen) {
|
||||
if (!xaigen || !asset?.id) return false
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createCaptionHost, validateCaptionHostRequest } from '../scripts/caption-host.mjs'
|
||||
import { CAPTION_STYLES, captionStylePrompt, DELTA_LOCKED_LOOK, validateCaptionBody } from '../shared/caption.mjs'
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
assert.deepEqual(CAPTION_STYLES, ['descriptive', 'klein_prompt', 'delta', 'tags'])
|
||||
assert.match(captionStylePrompt('delta'), new RegExp(DELTA_LOCKED_LOOK.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||
assert.equal(validateCaptionBody({ folderId: 'f1', stillId: 's1', captionStyle: 'tags' }).captionStyle, 'tags')
|
||||
assert.throws(() => validateCaptionBody({ folderId: 'f1' }), /still/)
|
||||
assert.throws(() => validateCaptionHostRequest({ id: 'short', style: 'descriptive' }), /Invalid/)
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'caption-test-'))
|
||||
const models = join(root, 'models')
|
||||
mkdirSync(models)
|
||||
writeFileSync(join(models, 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'), 'x')
|
||||
writeFileSync(join(models, 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'), 'x')
|
||||
writeFileSync(join(root, 'llama-server.exe'), 'x')
|
||||
|
||||
let prepared = 0
|
||||
const host = createCaptionHost({
|
||||
modelsDir: models,
|
||||
llamaServer: join(root, 'llama-server.exe'),
|
||||
dataDir: join(root, 'jobs'),
|
||||
leaseValid: () => true,
|
||||
prepare: async () => { prepared += 1 },
|
||||
serverWaitMs: 800,
|
||||
spawnProcess: () => {
|
||||
const child = new EventEmitter()
|
||||
child.pid = 4242
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.kill = () => child.emit('close', 0)
|
||||
setTimeout(() => child.emit('close', 0), 50)
|
||||
return child
|
||||
},
|
||||
fetchImpl: async () => ({ ok: false, status: 503, text: async () => 'down' })
|
||||
})
|
||||
|
||||
assert.equal(host.configured(), true)
|
||||
assert.equal(host.busy(), false)
|
||||
|
||||
const id = 'caption-test-abcdefghijkl'
|
||||
mkdirSync(join(root, 'jobs', id), { recursive: true })
|
||||
writeFileSync(join(root, 'jobs', id, 'input.png'), Buffer.alloc(64, 1))
|
||||
|
||||
await assert.rejects(() => host.start({ id, style: 'descriptive' }, 'lease'), /llama-server|Caption/)
|
||||
assert.equal(prepared, 1)
|
||||
assert.ok(existsSync(join(root, 'jobs', id, 'status.json')))
|
||||
console.log('caption tests ok')
|
||||
@@ -0,0 +1,288 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
const src = readFileSync(new URL('../utils/promptSequences.ts', import.meta.url), 'utf8')
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.ESNext
|
||||
}
|
||||
}).outputText
|
||||
const tmp = join(tmpdir(), `prompt-sequences-${process.pid}.mjs`)
|
||||
writeFileSync(tmp, js)
|
||||
const {
|
||||
assembleSceneText,
|
||||
assembleSequenceText,
|
||||
formatCharacterLine,
|
||||
emptyScene,
|
||||
emptyCharacter,
|
||||
emptyOutfit,
|
||||
emptySet,
|
||||
emptyFraming,
|
||||
cloneSceneForNext,
|
||||
normalizePromptSequence,
|
||||
normalizeSectionOrder,
|
||||
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||
ensureSceneStatesForCharacters
|
||||
} = await import(pathToFileURL(tmp).href)
|
||||
unlinkSync(tmp)
|
||||
|
||||
test('formatCharacterLine covers name/description and outfit', () => {
|
||||
assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'Ada: tall')
|
||||
assert.equal(
|
||||
formatCharacterLine({ name: 'Ada', description: 'tall' }, { name: 'Work', description: 'blue jacket' }),
|
||||
'Ada: tall. Outfit: blue jacket'
|
||||
)
|
||||
})
|
||||
|
||||
test('selecting a framing copies only that framing text; labels never appear', () => {
|
||||
const wide = { ...emptyFraming('wide'), id: 'f-wide', text: 'open courtyard at dawn' }
|
||||
const medium = { ...emptyFraming('medium'), id: 'f-med', text: 'courtyard from the arch' }
|
||||
const narrow = { ...emptyFraming('narrow'), id: 'f-nar', text: 'door latch detail' }
|
||||
const set = {
|
||||
...emptySet(),
|
||||
id: 's1',
|
||||
name: 'Courtyard',
|
||||
framings: [wide, medium, narrow]
|
||||
}
|
||||
const base = {
|
||||
style: 'style',
|
||||
settings: 'settings',
|
||||
characterStates: [],
|
||||
prompt: 'prompt'
|
||||
}
|
||||
const mediumText = assembleSceneText({
|
||||
...base,
|
||||
setStates: [{ setId: 's1', on: true, framingId: 'f-med' }]
|
||||
}, [], [set])
|
||||
assert.equal(mediumText, 'style\n\nsettings\n\ncourtyard from the arch\n\nprompt')
|
||||
assert.ok(!mediumText.includes('open courtyard'))
|
||||
assert.ok(!mediumText.includes('door latch'))
|
||||
assert.ok(!mediumText.includes('Courtyard'))
|
||||
assert.ok(!mediumText.includes('wide'))
|
||||
assert.ok(!mediumText.includes('medium'))
|
||||
assert.ok(!mediumText.includes('narrow'))
|
||||
|
||||
const narrowText = assembleSceneText({
|
||||
...base,
|
||||
setStates: [{ setId: 's1', on: true, framingId: 'f-nar' }]
|
||||
}, [], [set])
|
||||
assert.equal(narrowText, 'style\n\nsettings\n\ndoor latch detail\n\nprompt')
|
||||
assert.ok(!narrowText.includes('open courtyard'))
|
||||
assert.ok(!narrowText.includes('from the arch'))
|
||||
|
||||
const noneText = assembleSceneText({
|
||||
...base,
|
||||
setStates: [{ setId: 's1', on: true, framingId: null }]
|
||||
}, [], [set])
|
||||
assert.equal(noneText, 'style\n\nsettings\n\nprompt')
|
||||
})
|
||||
|
||||
test('off set is omitted; sequence join is double blank', () => {
|
||||
const framing = { ...emptyFraming('wide'), id: 'f1', text: 'set text' }
|
||||
const set = { ...emptySet(), id: 's1', name: 'Place', framings: [framing] }
|
||||
const scene1 = {
|
||||
style: 'a',
|
||||
settings: '',
|
||||
setStates: [{ setId: 's1', on: true, framingId: 'f1' }],
|
||||
characterStates: [],
|
||||
prompt: 'p1'
|
||||
}
|
||||
const scene2 = {
|
||||
style: 'b',
|
||||
settings: '',
|
||||
setStates: [{ setId: 's1', on: false, framingId: 'f1' }],
|
||||
characterStates: [],
|
||||
prompt: 'p2'
|
||||
}
|
||||
assert.equal(assembleSceneText(scene2, [], [set]), 'b\n\np2')
|
||||
const full = assembleSequenceText({ sets: [set], scenes: [scene1, scene2] })
|
||||
assert.equal(full, 'a\n\nset text\n\np1\n\n\nb\n\np2')
|
||||
assert.ok(!full.includes('Place'))
|
||||
assert.ok(!full.includes('wide'))
|
||||
})
|
||||
|
||||
test('legacy beat-only set still opens as one wide framing', () => {
|
||||
const legacy = normalizePromptSequence({
|
||||
id: 'seq1',
|
||||
name: 'Old beats',
|
||||
sets: [{
|
||||
id: 's1',
|
||||
name: 'Hall',
|
||||
description: 'stone walls',
|
||||
beats: [
|
||||
{ id: 'b1', name: 'Beat1', text: 'first beat', framings: ['wide'] },
|
||||
{ id: 'b2', name: 'Beat2', text: 'second beat', framings: ['medium'] }
|
||||
]
|
||||
}],
|
||||
scenes: [{
|
||||
id: 'sc1',
|
||||
title: 'Scene 1',
|
||||
style: 'oil',
|
||||
settings: '',
|
||||
shotSize: 'wide',
|
||||
setStates: [{ setId: 's1', on: true }],
|
||||
prompt: 'walk',
|
||||
characters: []
|
||||
}],
|
||||
characters: [{ id: 'x', name: 'Ada', description: 'tall', outfits: [] }],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
assert.equal(legacy.sets.length, 1)
|
||||
assert.equal(legacy.sets[0].framings.length, 1)
|
||||
assert.equal(legacy.sets[0].framings[0].label, 'wide')
|
||||
assert.ok(legacy.sets[0].framings[0].text.includes('stone walls'))
|
||||
assert.ok(legacy.sets[0].framings[0].text.includes('first beat'))
|
||||
assert.ok(legacy.sets[0].framings[0].text.includes('second beat'))
|
||||
assert.equal(legacy.scenes[0].setStates[0].framingId, legacy.sets[0].framings[0].id)
|
||||
const text = assembleSequenceText(legacy)
|
||||
assert.ok(text.includes('stone walls'))
|
||||
assert.ok(text.includes('first beat'))
|
||||
assert.ok(!text.includes('Hall'))
|
||||
assert.ok(!text.includes('Beat1'))
|
||||
})
|
||||
|
||||
test('legacy sequence without sets still assembles', () => {
|
||||
const legacy = normalizePromptSequence({
|
||||
id: 'seq1',
|
||||
name: 'Old',
|
||||
scenes: [
|
||||
{
|
||||
id: 'sc1',
|
||||
title: 'Scene 1',
|
||||
style: 'oil',
|
||||
settings: '',
|
||||
prompt: 'walk',
|
||||
characters: [{ id: 'x', name: 'Ada', description: 'tall' }]
|
||||
}
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
assert.deepEqual(legacy.sets, [])
|
||||
assert.equal(assembleSequenceText(legacy), 'oil\n\nAda: tall\n\nwalk')
|
||||
})
|
||||
|
||||
test('cloneSceneForNext copies setStates including framingId', () => {
|
||||
const sets = [emptySet()]
|
||||
const first = emptyScene(1, [], sets)
|
||||
first.setStates[0].on = false
|
||||
first.setStates[0].framingId = sets[0].framings[0].id
|
||||
const next = cloneSceneForNext(first, 2)
|
||||
assert.equal(next.setStates[0].on, false)
|
||||
assert.equal(next.setStates[0].framingId, sets[0].framings[0].id)
|
||||
assert.equal(next.setStates[0].setId, sets[0].id)
|
||||
})
|
||||
|
||||
test('character outfit and off still work with sets', () => {
|
||||
const ada = {
|
||||
...emptyCharacter(),
|
||||
id: 'c1',
|
||||
name: 'Ada',
|
||||
description: 'tall',
|
||||
outfits: [{ ...emptyOutfit(), id: 'o1', description: 'red dress' }]
|
||||
}
|
||||
const text = assembleSceneText({
|
||||
style: '',
|
||||
settings: '',
|
||||
setStates: [],
|
||||
characterStates: [{ characterId: 'c1', on: true, outfitId: 'o1' }],
|
||||
prompt: 'hi'
|
||||
}, [ada], [])
|
||||
assert.equal(text, 'Ada: tall. Outfit: red dress\n\nhi')
|
||||
})
|
||||
|
||||
test('ensureSceneStatesForCharacters turns new character on only for current scene', () => {
|
||||
const a = emptyCharacter()
|
||||
const scenes = [emptyScene(1, [a]), emptyScene(2, [a])]
|
||||
const b = emptyCharacter()
|
||||
ensureSceneStatesForCharacters(scenes, [a, b], { newCharacterId: b.id, turnOnSceneIndex: 1 })
|
||||
assert.equal(scenes[0].characterStates.find(s => s.characterId === b.id).on, false)
|
||||
assert.equal(scenes[1].characterStates.find(s => s.characterId === b.id).on, true)
|
||||
})
|
||||
|
||||
test('sectionOrder settings then prompt then characters; empty style and set omitted', () => {
|
||||
const ada = {
|
||||
...emptyCharacter(),
|
||||
id: 'c1',
|
||||
name: 'Ada',
|
||||
description: 'tall',
|
||||
outfits: []
|
||||
}
|
||||
const set = {
|
||||
...emptySet(),
|
||||
id: 's1',
|
||||
name: 'Courtyard',
|
||||
framings: [{ ...emptyFraming('wide'), id: 'f1', text: 'open yard' }]
|
||||
}
|
||||
const scene = {
|
||||
style: '',
|
||||
settings: 'soft light',
|
||||
setStates: [{ setId: 's1', on: true, framingId: null }],
|
||||
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
|
||||
prompt: 'looks up'
|
||||
}
|
||||
const order = ['settings', 'prompt', 'characters']
|
||||
const text = assembleSceneText(scene, [ada], [set], order)
|
||||
assert.equal(text, 'soft light\n\nlooks up\n\nAda: tall')
|
||||
assert.ok(!text.includes('open yard'))
|
||||
assert.ok(!text.includes('Courtyard'))
|
||||
assert.ok(!text.includes('wide'))
|
||||
})
|
||||
|
||||
test('legacy sequence without sectionOrder keeps default order and \\n\\n\\n join', () => {
|
||||
const legacy = normalizePromptSequence({
|
||||
id: 'seq1',
|
||||
name: 'Old order',
|
||||
characters: [{ id: 'c1', name: 'Ada', description: 'tall', outfits: [] }],
|
||||
sets: [{
|
||||
id: 's1',
|
||||
name: 'Hall',
|
||||
framings: [{ id: 'f1', label: 'wide', text: 'stone hall' }]
|
||||
}],
|
||||
scenes: [
|
||||
{
|
||||
id: 'sc1',
|
||||
title: 'One',
|
||||
style: 'oil',
|
||||
settings: 'day',
|
||||
setStates: [{ setId: 's1', on: true, framingId: 'f1' }],
|
||||
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
|
||||
prompt: 'walk'
|
||||
},
|
||||
{
|
||||
id: 'sc2',
|
||||
title: 'Two',
|
||||
style: 'ink',
|
||||
settings: '',
|
||||
setStates: [{ setId: 's1', on: false, framingId: 'f1' }],
|
||||
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
|
||||
prompt: 'stop'
|
||||
}
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
assert.deepEqual(legacy.sectionOrder, [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
|
||||
assert.deepEqual(normalizeSectionOrder(undefined), [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
|
||||
assert.deepEqual(normalizeSectionOrder(['prompt', 'prompt', 'bogus', 'settings']), [
|
||||
'prompt',
|
||||
'settings',
|
||||
'style',
|
||||
'set',
|
||||
'characters'
|
||||
])
|
||||
const full = assembleSequenceText(legacy)
|
||||
assert.equal(
|
||||
full,
|
||||
'oil\n\nday\n\nstone hall\n\nAda: tall\n\nwalk\n\n\nink\n\nAda: tall\n\nstop'
|
||||
)
|
||||
assert.ok(!full.includes('Hall'))
|
||||
assert.ok(!full.includes('wide'))
|
||||
})
|
||||
@@ -4,11 +4,12 @@ import {readFileSync} from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
|
||||
import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs'
|
||||
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
|
||||
import {toggleStyle,parseBatch,parseBatchDoubleBlank,stylePrompt} from '../shared/studio2/styles.mjs'
|
||||
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
||||
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
|
||||
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
|
||||
import {ensureQwen21EditPrompt,stitchQwen21PeEditPrompt,QWEN21_EDIT_KEEP_CHANGE} from '../shared/studio2/qwen21-edit.mjs'
|
||||
import {injectQwenEditMentionTags,applyPhotosToRequest} from '../shared/studio2/photos.mjs'
|
||||
import {qwen21PeRefusal} from '../shared/studio2/qwen21-pe.mjs'
|
||||
|
||||
test('deleted library files never return from job outputs; selection falls back or clears',()=>{
|
||||
@@ -78,6 +79,12 @@ test('image chips compile positive and negative independently; video chips do no
|
||||
assert.match(q.compiledPrompt,/Photorealistic/);assert.doesNotMatch(q.compiledPrompt,/Comic|pores|tripod|hold|VIDEO ONLY/i);assert.deepEqual(q.imageStyles,styles)
|
||||
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
||||
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
||||
assert.deepEqual(
|
||||
parseBatchDoubleBlank('Prompt 1 starts here\nBal blaglas\nsldkjf lkaj\n\nsldkfjs lkdfj\nsdlfkjsdkljf\n\n\nPrompt 2 starts here flaskjflaskdj'),
|
||||
['Prompt 1 starts here\nBal blaglas\nsldkjf lkaj\n\nsldkfjs lkdfj\nsdlfkjsdkljf','Prompt 2 starts here flaskjflaskdj']
|
||||
)
|
||||
assert.deepEqual(parseBatchDoubleBlank('one\n\ntwo'),['one\n\ntwo'])
|
||||
assert.deepEqual(parseBatchDoubleBlank('one\n\n\ntwo\n\n\n\nthree\n'),['one','two','three'])
|
||||
})
|
||||
test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
|
||||
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})
|
||||
@@ -100,13 +107,27 @@ test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
|
||||
const edit=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'change the sky'},settings:{aspect:'auto'}})
|
||||
assert.equal(edit.mode,'edit');assert.equal(edit.settings.steps,25);assert.equal(edit.settings.cfg,1)
|
||||
assert.equal(edit.lockFace,false);assert.deepEqual(edit.settings.loraStack,[])
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',promptSections:{action:'x'}}),/Start still/)
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',identityStillId:'hero',promptSections:{action:'x'}}),/Start still/)
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',promptSections:{action:'x'}}),/Photo to change/)
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',identityStillId:'hero',promptSections:{action:'x'}}),/Photo to change/)
|
||||
const qwenPhotos=validateRequest({mode:'edit',engine:'qwen21',folderId:'f',photos:[{stillId:'person',role:'change'},{stillId:'dress',role:'outfit'}],promptSections:{action:'Put her in that dress.'},settings:{aspect:'auto'}})
|
||||
assert.equal(qwenPhotos.imageAId,'person')
|
||||
assert.equal(qwenPhotos.imageBId,'dress')
|
||||
assert.equal(qwenPhotos.identityStillId,'')
|
||||
assert.match(qwenPhotos.compiledPrompt,/Put her in that dress/)
|
||||
assert.doesNotMatch(String(qwenPhotos.promptSections.action),/<image1>/)
|
||||
const afterRemove=applyPhotosToRequest({mode:'edit',engine:'qwen21',photos:[],imageAId:'stale-person',imageBId:'stale-dress'})
|
||||
assert.equal(afterRemove.imageAId,'')
|
||||
assert.equal(afterRemove.imageBId,'')
|
||||
assert.deepEqual(afterRemove.photos,[])
|
||||
assert.throws(()=>validateRequest({mode:'edit',engine:'qwen21',folderId:'f',photos:[],imageAId:'stale',promptSections:{action:'x'}}),/Photo to change/)
|
||||
assert.throws(()=>validateRequest({mode:'compose',engine:'qwen21',folderId:'f',imageAId:'s',imageBId:'b',promptSections:{action:'x'}}),/Generate and Edit/)
|
||||
assert.throws(()=>validateRequest({mode:'iterate',engine:'qwen21',folderId:'f',imageAId:'s',promptSections:{action:'x'}}),/Generate and Edit/)
|
||||
assert.equal(ensureQwen21EditPrompt('put her in a red leather jacket'),`${QWEN21_EDIT_KEEP_CHANGE} put her in a red leather jacket`)
|
||||
assert.match(ensureQwen21EditPrompt('put her in a red leather jacket'),/<image1>/)
|
||||
assert.match(ensureQwen21EditPrompt('put her in that dress',{hasImage2:true}),/<image2>/)
|
||||
assert.equal(ensureQwen21EditPrompt('Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.\nApply only this change: x'),'Keep the identity, face, body, pose, and likeness of the person in <image1>.\nDo not invent a different person.\nApply only this change: x')
|
||||
assert.match(injectQwenEditMentionTags('Put her in the outfit photo',[{stillId:'a',role:'change'},{stillId:'b',role:'outfit'}]),/<image2>/)
|
||||
assert.match(injectQwenEditMentionTags('Change this photo',[{stillId:'a',role:'change'}]),/<image1>/)
|
||||
const stitchedKeep=stitchQwen21PeEditPrompt('put her in a red leather jacket','Change the outfit on the person in <image1> to a red leather jacket')
|
||||
assert.match(stitchedKeep.prompt,/Keep the identity/)
|
||||
assert.match(stitchedKeep.prompt,/<image1>/)
|
||||
@@ -152,12 +173,26 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio
|
||||
styles=toggleStyle(styles,'deformed',true)
|
||||
assert.deepEqual(styles.negative,['blurry','my watermark','deformed'])
|
||||
const src=readFileSync(new URL('../components/studio2/Bench.vue',import.meta.url),'utf8')
|
||||
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Edit source/)
|
||||
const slot=readFileSync(new URL('../components/studio2/AssetSlot.vue',import.meta.url),'utf8')
|
||||
assert.match(src,/loadSelectedJob/);assert.match(src,/hydrateStudio2Job/);assert.match(src,/Photos/)
|
||||
assert.match(src,/photosPlanState\.emptyHint/)
|
||||
assert.match(src,/aspect:'auto'/);assert.match(src,/strip-actions/);assert.match(src,/addNegative/)
|
||||
assert.match(src,/Enhance prompt/);assert.match(src,/enhancePrompt/)
|
||||
assert.match(slot,/Mention in prompt/);assert.match(slot,/This photo is/);assert.match(slot,/Replace/)
|
||||
assert.match(slot,/Choose a photo/)
|
||||
assert.match(src,/uploadPickerFile/);assert.match(src,/setPhotoStill/);assert.match(src,/discardEmptyPhotoDraft/)
|
||||
assert.match(src,/Upload from disk/)
|
||||
assert.doesNotMatch(src,/Hero still/)
|
||||
assert.doesNotMatch(src,/Edit source/)
|
||||
assert.doesNotMatch(src,/Identity \/ reference/)
|
||||
assert.doesNotMatch(src,/Qwen Edit uses Start still/)
|
||||
assert.doesNotMatch(src,/Hero is Klein-only/)
|
||||
assert.doesNotMatch(slot,/<image1>/)
|
||||
assert.match(src,/\bTurbo\b/);assert.match(src,/form\.turbo/)
|
||||
assert.match(src,/Qwen Edit uses Start still as/)
|
||||
assert.match(src,/Hero is Klein-only and is ignored/)
|
||||
const photosSrc=readFileSync(new URL('../shared/studio2/photos.mjs',import.meta.url),'utf8')
|
||||
assert.match(photosSrc,/Photo to change/)
|
||||
assert.match(photosSrc,/mentionPhrase/)
|
||||
assert.match(photosSrc,/Add a photo, then say what it is/)
|
||||
assert.match(readFileSync(new URL('../components/studio2/Details.vue',import.meta.url),'utf8'),/\bTyped\b/)
|
||||
assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/\bTyped\b/)
|
||||
assert.match(readFileSync(new URL('../utils/libraryGeneration.ts',import.meta.url),'utf8'),/promptRaw/)
|
||||
@@ -175,6 +210,8 @@ test('custom negative chips survive normalizeStyles and bench exposes Load actio
|
||||
const runner=readFileSync(new URL('../server/utils/studio2/runner.ts',import.meta.url),'utf8')
|
||||
assert.match(runner,/ensureQwen21EditPrompt/)
|
||||
assert.match(runner,/stitchQwen21PeEditPrompt/)
|
||||
assert.match(runner,/injectQwenEditMentionTags/)
|
||||
assert.match(runner,/applyPhotosToRequest/)
|
||||
assert.match(runner,/skippedAsDescribe/)
|
||||
assert.match(runner,/promptRaw/)
|
||||
assert.match(runner,/images\.image_1/)
|
||||
|
||||
@@ -62,8 +62,13 @@ function pushPromptRows(rows: SettingsRow[], item: {
|
||||
post: item.promptPost
|
||||
})
|
||||
if (parts.pre.trim()) rows.push({ label: 'Pre-text', value: parts.pre.trim() })
|
||||
if (parts.prompt.trim()) rows.push({ label: 'Prompt', value: parts.prompt.trim() })
|
||||
if (typed && typed !== parts.prompt.trim()) rows.push({ label: 'Typed', value: typed })
|
||||
const used = parts.prompt.trim()
|
||||
if (typed && typed !== used) {
|
||||
rows.push({ label: 'Typed', value: typed })
|
||||
if (used) rows.push({ label: 'Used', value: used })
|
||||
} else if (used) {
|
||||
rows.push({ label: 'Prompt', value: used })
|
||||
}
|
||||
if (parts.post.trim()) rows.push({ label: 'Post-text', value: parts.post.trim() })
|
||||
}
|
||||
|
||||
|
||||
+12
-5
@@ -305,23 +305,30 @@ export function inferLoraUse(name: string): LoraUse[] | null {
|
||||
return uses.length ? uses : null
|
||||
}
|
||||
|
||||
/** Qwen 2.1 user adapters: basename stem starts with qwen or qwen2 (not klein). */
|
||||
export function isQwenImageLora(name: string) {
|
||||
const stem = loraIdentityKey(name).replace(/\.(safetensors|sft|ckpt|pt|bin)$/i, '')
|
||||
return /^(qwen2|qwen)/i.test(stem)
|
||||
}
|
||||
|
||||
export function loraMatchesUse(name: string, use: LoraUse) {
|
||||
if (use === 'qwen21') return false
|
||||
return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
|
||||
if (isSystemGraphLora(name)) return false
|
||||
if (use === 'qwen21') return isQwenImageLora(name)
|
||||
return !!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 => {
|
||||
if (isSystemGraphLora(item.name)) return false
|
||||
if (use === 'qwen21') return isQwenImageLora(item.name)
|
||||
const uses = inferLoraUse(item.name)
|
||||
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
|
||||
return !uses || uses.includes(use)
|
||||
})
|
||||
}
|
||||
|
||||
export function filterLorasForUse(names: string[], use: LoraUse) {
|
||||
if (use === 'qwen21') return []
|
||||
return names.filter(name => loraMatchesUse(name, use))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
export type PromptSequenceOutfit = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type PromptSequenceCharacter = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
outfits: PromptSequenceOutfit[]
|
||||
}
|
||||
|
||||
export type PromptSequenceCharacterState = {
|
||||
characterId: string
|
||||
on: boolean
|
||||
/** Active outfit id for this scene, or null/'' for none. */
|
||||
outfitId: string | null
|
||||
}
|
||||
|
||||
/** Suggested labels only — not a hard enum. */
|
||||
export const PROMPT_SEQUENCE_FRAMING_SUGGESTIONS = ['wide', 'medium', 'narrow'] as const
|
||||
|
||||
export type PromptSequenceFraming = {
|
||||
id: string
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type PromptSequenceSet = {
|
||||
id: string
|
||||
name: string
|
||||
framings: PromptSequenceFraming[]
|
||||
}
|
||||
|
||||
export type PromptSequenceSetState = {
|
||||
setId: string
|
||||
on: boolean
|
||||
/** Chosen framing for this set in this scene (like outfitId). Empty = copy nothing. */
|
||||
framingId: string | null
|
||||
}
|
||||
|
||||
export type PromptSequenceScene = {
|
||||
id: string
|
||||
title: string
|
||||
style: string
|
||||
settings: string
|
||||
setStates: PromptSequenceSetState[]
|
||||
characterStates: PromptSequenceCharacterState[]
|
||||
prompt: string
|
||||
}
|
||||
|
||||
/** Scene prompt sections. Default copy order. */
|
||||
export const PROMPT_SEQUENCE_SECTION_KEYS = ['style', 'settings', 'set', 'characters', 'prompt'] as const
|
||||
export type PromptSequenceSectionKey = (typeof PROMPT_SEQUENCE_SECTION_KEYS)[number]
|
||||
export const DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER: PromptSequenceSectionKey[] = [
|
||||
...PROMPT_SEQUENCE_SECTION_KEYS
|
||||
]
|
||||
|
||||
export type PromptSequence = {
|
||||
id: string
|
||||
name: string
|
||||
/** Order of scene sections when assembling / in the editor. */
|
||||
sectionOrder: PromptSequenceSectionKey[]
|
||||
characters: PromptSequenceCharacter[]
|
||||
sets: PromptSequenceSet[]
|
||||
scenes: PromptSequenceScene[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Valid unique keys in given order; missing keys append in default order. */
|
||||
export function normalizeSectionOrder(raw: unknown): PromptSequenceSectionKey[] {
|
||||
const allowed = new Set<string>(PROMPT_SEQUENCE_SECTION_KEYS)
|
||||
const seen = new Set<PromptSequenceSectionKey>()
|
||||
const ordered: PromptSequenceSectionKey[] = []
|
||||
if (Array.isArray(raw)) {
|
||||
for (const item of raw) {
|
||||
const key = String(item || '').trim()
|
||||
if (!allowed.has(key) || seen.has(key as PromptSequenceSectionKey)) continue
|
||||
const typed = key as PromptSequenceSectionKey
|
||||
seen.add(typed)
|
||||
ordered.push(typed)
|
||||
}
|
||||
}
|
||||
for (const key of DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER) {
|
||||
if (!seen.has(key)) ordered.push(key)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
export const PROMPT_SEQUENCE_NAME_MAX = 80
|
||||
export const PROMPT_SEQUENCE_FIELD_MAX = 50_000
|
||||
export const PROMPT_SEQUENCE_TITLE_MAX = 60
|
||||
|
||||
export function newId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `ps-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function emptyOutfit(): PromptSequenceOutfit {
|
||||
return { id: newId(), name: '', description: '' }
|
||||
}
|
||||
|
||||
export function emptyCharacter(): PromptSequenceCharacter {
|
||||
return { id: newId(), name: '', description: '', outfits: [] }
|
||||
}
|
||||
|
||||
export function emptyFraming(label = ''): PromptSequenceFraming {
|
||||
return { id: newId(), label, text: '' }
|
||||
}
|
||||
|
||||
export function emptySet(): PromptSequenceSet {
|
||||
return { id: newId(), name: '', framings: [emptyFraming()] }
|
||||
}
|
||||
|
||||
export function defaultCharacterState(characterId: string, on = true): PromptSequenceCharacterState {
|
||||
return { characterId, on, outfitId: null }
|
||||
}
|
||||
|
||||
export function defaultSetState(setId: string, on = true, framingId: string | null = null): PromptSequenceSetState {
|
||||
return { setId, on, framingId }
|
||||
}
|
||||
|
||||
export function emptyScene(
|
||||
index = 1,
|
||||
characters: PromptSequenceCharacter[] = [],
|
||||
sets: PromptSequenceSet[] = []
|
||||
): PromptSequenceScene {
|
||||
return {
|
||||
id: newId(),
|
||||
title: `Scene ${Math.max(1, index)}`,
|
||||
style: '',
|
||||
settings: '',
|
||||
setStates: sets.map(set => defaultSetState(set.id, true, null)),
|
||||
characterStates: characters.map(character => defaultCharacterState(character.id, true)),
|
||||
prompt: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneSceneForNext(previous: PromptSequenceScene, nextIndex: number): PromptSequenceScene {
|
||||
return {
|
||||
id: newId(),
|
||||
title: `Scene ${nextIndex}`,
|
||||
style: previous.style,
|
||||
settings: previous.settings,
|
||||
setStates: (previous.setStates || []).map(state => ({
|
||||
setId: state.setId,
|
||||
on: !!state.on,
|
||||
framingId: state.framingId || null
|
||||
})),
|
||||
characterStates: (previous.characterStates || []).map(state => ({
|
||||
characterId: state.characterId,
|
||||
on: !!state.on,
|
||||
outfitId: state.outfitId || null
|
||||
})),
|
||||
prompt: previous.prompt
|
||||
}
|
||||
}
|
||||
|
||||
function outfitPromptText(outfit?: { name?: string; description?: string } | null) {
|
||||
if (!outfit) return ''
|
||||
return String(outfit.description || '').trim() || String(outfit.name || '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Character line when on:
|
||||
* - with outfit: "Name: description. Outfit: outfit description"
|
||||
* - without: "Name: description" (or name-only / description-only)
|
||||
*/
|
||||
export function formatCharacterLine(
|
||||
character: { name?: string; description?: string },
|
||||
outfit?: { name?: string; description?: string } | null
|
||||
) {
|
||||
const name = String(character?.name || '').trim()
|
||||
const description = String(character?.description || '').trim()
|
||||
let base = ''
|
||||
if (name && description) base = `${name}: ${description}`
|
||||
else base = name || description
|
||||
if (!base) return ''
|
||||
const outfitText = outfitPromptText(outfit)
|
||||
if (!outfitText) return base
|
||||
return `${base}. Outfit: ${outfitText}`
|
||||
}
|
||||
|
||||
/** Selected framing text for each on set. No set name, no framing label. */
|
||||
export function assembleSetBlock(
|
||||
scene: { setStates?: PromptSequenceSetState[] },
|
||||
sets: PromptSequenceSet[] = []
|
||||
) {
|
||||
const states = scene.setStates || []
|
||||
const stateById = new Map(states.map(state => [state.setId, state]))
|
||||
const chunks: string[] = []
|
||||
for (const set of sets) {
|
||||
const state = stateById.get(set.id)
|
||||
if (state && !state.on) continue
|
||||
const framingId = state?.framingId || null
|
||||
if (!framingId) continue
|
||||
const framing = (set.framings || []).find(item => item.id === framingId)
|
||||
const text = String(framing?.text || '').trim()
|
||||
if (text) chunks.push(text)
|
||||
}
|
||||
return chunks.join('\n')
|
||||
}
|
||||
|
||||
export function resolveSceneCharacterLines(
|
||||
scene: { characterStates?: PromptSequenceCharacterState[] },
|
||||
characters: PromptSequenceCharacter[] = []
|
||||
) {
|
||||
const states = scene.characterStates || []
|
||||
const stateById = new Map(states.map(state => [state.characterId, state]))
|
||||
return characters
|
||||
.map((character) => {
|
||||
const state = stateById.get(character.id)
|
||||
if (state && !state.on) return ''
|
||||
const outfitId = state?.outfitId || null
|
||||
const outfit = outfitId
|
||||
? (character.outfits || []).find(item => item.id === outfitId) || null
|
||||
: null
|
||||
return formatCharacterLine(character, outfit)
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function assembleSceneText(
|
||||
scene: {
|
||||
style?: string
|
||||
settings?: string
|
||||
prompt?: string
|
||||
setStates?: PromptSequenceSetState[]
|
||||
characterStates?: PromptSequenceCharacterState[]
|
||||
/** Legacy per-scene characters (migration / old tests). */
|
||||
characters?: Array<{ name?: string; description?: string }>
|
||||
},
|
||||
sequenceCharacters?: PromptSequenceCharacter[],
|
||||
sequenceSets?: PromptSequenceSet[],
|
||||
sectionOrder?: PromptSequenceSectionKey[] | unknown
|
||||
) {
|
||||
const setBlock = sequenceSets?.length
|
||||
? assembleSetBlock(scene, sequenceSets)
|
||||
: ''
|
||||
const characterBlock = sequenceCharacters?.length
|
||||
? resolveSceneCharacterLines(scene, sequenceCharacters).join('\n')
|
||||
: (scene.characters || [])
|
||||
.map(character => formatCharacterLine(character))
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
const blocks: Record<PromptSequenceSectionKey, string> = {
|
||||
style: String(scene.style || '').trim(),
|
||||
settings: String(scene.settings || '').trim(),
|
||||
set: setBlock,
|
||||
characters: characterBlock,
|
||||
prompt: String(scene.prompt || '').trim()
|
||||
}
|
||||
const order = normalizeSectionOrder(sectionOrder)
|
||||
const parts = order.map(key => blocks[key]).filter(Boolean)
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole sequence as one copyable block.
|
||||
* Scenes join with a double blank line (\n\n\n) — same separator desktop Batch uses.
|
||||
* Sequence name is never included.
|
||||
*/
|
||||
export function assembleSequenceText(sequence: {
|
||||
sectionOrder?: PromptSequenceSectionKey[] | unknown
|
||||
characters?: PromptSequenceCharacter[]
|
||||
sets?: PromptSequenceSet[]
|
||||
scenes?: Array<{
|
||||
style?: string
|
||||
settings?: string
|
||||
prompt?: string
|
||||
setStates?: PromptSequenceSetState[]
|
||||
characterStates?: PromptSequenceCharacterState[]
|
||||
characters?: Array<{ name?: string; description?: string }>
|
||||
}>
|
||||
}) {
|
||||
const characters = sequence.characters || []
|
||||
const sets = sequence.sets || []
|
||||
const sectionOrder = normalizeSectionOrder(sequence.sectionOrder)
|
||||
return (sequence.scenes || [])
|
||||
.map(scene => assembleSceneText(
|
||||
scene,
|
||||
characters.length ? characters : undefined,
|
||||
sets.length ? sets : undefined,
|
||||
sectionOrder
|
||||
))
|
||||
.filter(Boolean)
|
||||
.join('\n\n\n')
|
||||
}
|
||||
|
||||
export function normalizePromptSequenceName(raw: unknown) {
|
||||
return String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_NAME_MAX)
|
||||
}
|
||||
|
||||
export function normalizePromptSequenceField(raw: unknown) {
|
||||
return String(raw || '').replace(/\r\n/g, '\n').slice(0, PROMPT_SEQUENCE_FIELD_MAX)
|
||||
}
|
||||
|
||||
export function normalizePromptSequenceTitle(raw: unknown, fallback: string) {
|
||||
const title = String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_TITLE_MAX)
|
||||
return title || fallback
|
||||
}
|
||||
|
||||
export function normalizeOutfit(raw: unknown): PromptSequenceOutfit {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
name: String(rec.name || '').trim().slice(0, 120),
|
||||
description: normalizePromptSequenceField(rec.description).trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCharacter(raw: unknown): PromptSequenceCharacter {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const outfits = Array.isArray(rec.outfits) ? rec.outfits.map(normalizeOutfit) : []
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
name: String(rec.name || '').trim().slice(0, 120),
|
||||
description: normalizePromptSequenceField(rec.description).trim(),
|
||||
outfits
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeFraming(raw: unknown): PromptSequenceFraming {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
label: String(rec.label || '').trim().slice(0, 120),
|
||||
text: normalizePromptSequenceField(rec.text)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fold legacy beat-only sets into one framing labeled "wide". */
|
||||
function framingsFromLegacyBeats(rec: Record<string, unknown>): PromptSequenceFraming[] {
|
||||
const parts: string[] = []
|
||||
const description = normalizePromptSequenceField(rec.description).trim()
|
||||
if (description) parts.push(description)
|
||||
const beats = Array.isArray(rec.beats) ? rec.beats : []
|
||||
for (const beat of beats) {
|
||||
if (!beat || typeof beat !== 'object') continue
|
||||
const text = normalizePromptSequenceField((beat as Record<string, unknown>).text).trim()
|
||||
if (text) parts.push(text)
|
||||
}
|
||||
if (!parts.length) return [emptyFraming()]
|
||||
return [{ id: newId(), label: 'wide', text: parts.join('\n') }]
|
||||
}
|
||||
|
||||
export function normalizeSet(raw: unknown): PromptSequenceSet {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
let framings: PromptSequenceFraming[]
|
||||
if (Array.isArray(rec.framings) && rec.framings.length) {
|
||||
framings = rec.framings.map(normalizeFraming)
|
||||
} else if (Array.isArray(rec.beats) && rec.beats.length) {
|
||||
framings = framingsFromLegacyBeats(rec)
|
||||
} else if (String(rec.description || '').trim()) {
|
||||
framings = framingsFromLegacyBeats(rec)
|
||||
} else {
|
||||
framings = [emptyFraming()]
|
||||
}
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
name: String(rec.name || '').trim().slice(0, 120),
|
||||
framings
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCharacterState(raw: unknown, fallbackCharacterId = ''): PromptSequenceCharacterState {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const characterId = String(rec.characterId || fallbackCharacterId || '').trim()
|
||||
const outfitRaw = rec.outfitId
|
||||
const outfitId = outfitRaw == null || outfitRaw === '' ? null : String(outfitRaw).trim() || null
|
||||
return {
|
||||
characterId,
|
||||
on: rec.on === undefined ? true : !!rec.on,
|
||||
outfitId
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSetState(
|
||||
raw: unknown,
|
||||
fallbackSetId = '',
|
||||
sets: PromptSequenceSet[] = [],
|
||||
legacyShotSize = ''
|
||||
): PromptSequenceSetState {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const setId = String(rec.setId || fallbackSetId || '').trim()
|
||||
const framingRaw = rec.framingId
|
||||
let framingId = framingRaw == null || framingRaw === '' ? null : String(framingRaw).trim() || null
|
||||
const set = sets.find(item => item.id === setId)
|
||||
if (framingId && set && !set.framings.some(framing => framing.id === framingId)) {
|
||||
framingId = null
|
||||
}
|
||||
if (!framingId && legacyShotSize && set) {
|
||||
const match = set.framings.find(
|
||||
framing => framing.label.trim().toLowerCase() === legacyShotSize.trim().toLowerCase()
|
||||
)
|
||||
if (match) framingId = match.id
|
||||
}
|
||||
return {
|
||||
setId,
|
||||
on: rec.on === undefined ? true : !!rec.on,
|
||||
framingId
|
||||
}
|
||||
}
|
||||
|
||||
function liftLegacyCharacters(scenesRaw: unknown[]): PromptSequenceCharacter[] {
|
||||
const byId = new Map<string, PromptSequenceCharacter>()
|
||||
for (const scene of scenesRaw) {
|
||||
if (!scene || typeof scene !== 'object') continue
|
||||
const chars = (scene as Record<string, unknown>).characters
|
||||
if (!Array.isArray(chars)) continue
|
||||
for (const character of chars) {
|
||||
const normalized = normalizeCharacter(character)
|
||||
if (!byId.has(normalized.id)) byId.set(normalized.id, normalized)
|
||||
}
|
||||
}
|
||||
return [...byId.values()]
|
||||
}
|
||||
|
||||
function syncCharacterStates(
|
||||
statesRaw: unknown,
|
||||
characters: PromptSequenceCharacter[],
|
||||
defaultOn = true
|
||||
): PromptSequenceCharacterState[] {
|
||||
const incoming = Array.isArray(statesRaw)
|
||||
? statesRaw.map(state => normalizeCharacterState(state)).filter(state => state.characterId)
|
||||
: []
|
||||
const byId = new Map(incoming.map(state => [state.characterId, state]))
|
||||
return characters.map((character) => {
|
||||
const existing = byId.get(character.id)
|
||||
if (existing) {
|
||||
const outfitOk = existing.outfitId
|
||||
&& character.outfits.some(outfit => outfit.id === existing.outfitId)
|
||||
return {
|
||||
characterId: character.id,
|
||||
on: existing.on,
|
||||
outfitId: outfitOk ? existing.outfitId : null
|
||||
}
|
||||
}
|
||||
return defaultCharacterState(character.id, defaultOn)
|
||||
})
|
||||
}
|
||||
|
||||
function syncSetStates(
|
||||
statesRaw: unknown,
|
||||
sets: PromptSequenceSet[],
|
||||
defaultOn = true,
|
||||
legacyShotSize = ''
|
||||
): PromptSequenceSetState[] {
|
||||
const incoming = Array.isArray(statesRaw)
|
||||
? statesRaw.map(state => normalizeSetState(state, '', sets, legacyShotSize)).filter(state => state.setId)
|
||||
: []
|
||||
const byId = new Map(incoming.map(state => [state.setId, state]))
|
||||
return sets.map((set) => {
|
||||
const existing = byId.get(set.id)
|
||||
if (existing) {
|
||||
const framingOk = existing.framingId
|
||||
&& set.framings.some(framing => framing.id === existing.framingId)
|
||||
return {
|
||||
setId: set.id,
|
||||
on: existing.on,
|
||||
framingId: framingOk ? existing.framingId : null
|
||||
}
|
||||
}
|
||||
let framingId: string | null = null
|
||||
if (legacyShotSize) {
|
||||
const match = set.framings.find(
|
||||
framing => framing.label.trim().toLowerCase() === legacyShotSize.trim().toLowerCase()
|
||||
)
|
||||
if (match) framingId = match.id
|
||||
}
|
||||
return defaultSetState(set.id, defaultOn, framingId)
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeScene(
|
||||
raw: unknown,
|
||||
index: number,
|
||||
characters: PromptSequenceCharacter[] = [],
|
||||
sets: PromptSequenceSet[] = []
|
||||
): PromptSequenceScene {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const legacyShotSize = String(rec.shotSize || '').trim()
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
title: normalizePromptSequenceTitle(rec.title, `Scene ${index + 1}`),
|
||||
style: normalizePromptSequenceField(rec.style),
|
||||
settings: normalizePromptSequenceField(rec.settings),
|
||||
setStates: syncSetStates(rec.setStates, sets, true, legacyShotSize),
|
||||
characterStates: syncCharacterStates(rec.characterStates, characters, true),
|
||||
prompt: normalizePromptSequenceField(rec.prompt)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePromptSequence(raw: unknown): PromptSequence | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const rec = raw as Record<string, unknown>
|
||||
const name = normalizePromptSequenceName(rec.name)
|
||||
if (!name) return null
|
||||
const scenesRaw = Array.isArray(rec.scenes) ? rec.scenes : []
|
||||
let characters = Array.isArray(rec.characters)
|
||||
? rec.characters.map(normalizeCharacter)
|
||||
: liftLegacyCharacters(scenesRaw)
|
||||
if (!characters.length) characters = [emptyCharacter()]
|
||||
const sets = Array.isArray(rec.sets) ? rec.sets.map(normalizeSet) : []
|
||||
const scenes = scenesRaw.length
|
||||
? scenesRaw.map((scene, index) => normalizeScene(scene, index, characters, sets))
|
||||
: [emptyScene(1, characters, sets)]
|
||||
const createdAt = Number(rec.createdAt) || Date.now()
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
name,
|
||||
sectionOrder: normalizeSectionOrder(rec.sectionOrder),
|
||||
characters,
|
||||
sets,
|
||||
scenes,
|
||||
createdAt,
|
||||
updatedAt: Number(rec.updatedAt) || createdAt
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure every scene has a state row for every sequence character. */
|
||||
export function ensureSceneStatesForCharacters(
|
||||
scenes: PromptSequenceScene[],
|
||||
characters: PromptSequenceCharacter[],
|
||||
opts?: { newCharacterId?: string; turnOnSceneIndex?: number }
|
||||
) {
|
||||
const newCharacterId = opts?.newCharacterId
|
||||
const turnOnIndex = opts?.turnOnSceneIndex
|
||||
for (let i = 0; i < scenes.length; i++) {
|
||||
const scene = scenes[i]
|
||||
const byId = new Map(scene.characterStates.map(state => [state.characterId, state]))
|
||||
scene.characterStates = characters.map((character) => {
|
||||
const existing = byId.get(character.id)
|
||||
if (existing) return existing
|
||||
const on = newCharacterId && character.id === newCharacterId
|
||||
? i === turnOnIndex
|
||||
: true
|
||||
return defaultCharacterState(character.id, on)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure every scene has a state row for every sequence set. */
|
||||
export function ensureSceneStatesForSets(
|
||||
scenes: PromptSequenceScene[],
|
||||
sets: PromptSequenceSet[],
|
||||
opts?: { newSetId?: string; turnOnSceneIndex?: number }
|
||||
) {
|
||||
const newSetId = opts?.newSetId
|
||||
const turnOnIndex = opts?.turnOnSceneIndex
|
||||
for (let i = 0; i < scenes.length; i++) {
|
||||
const scene = scenes[i]
|
||||
const byId = new Map((scene.setStates || []).map(state => [state.setId, state]))
|
||||
scene.setStates = sets.map((set) => {
|
||||
const existing = byId.get(set.id)
|
||||
if (existing) {
|
||||
const framingOk = existing.framingId
|
||||
&& set.framings.some(framing => framing.id === existing.framingId)
|
||||
return {
|
||||
setId: set.id,
|
||||
on: existing.on,
|
||||
framingId: framingOk ? existing.framingId : null
|
||||
}
|
||||
}
|
||||
const on = newSetId && set.id === newSetId
|
||||
? i === turnOnIndex
|
||||
: true
|
||||
return defaultSetState(set.id, on, null)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user