Split prompts into pre/prompt/post and tidy the AIGen video form.

Keep wrappers off the library mid-prompt so Use as input and Extend restore the same split, send stills to xAIGen as hidden inputs, and stop the two-column library from growing with the list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-01 18:39:02 -05:00
co-authored by Cursor
parent ed04dcfee3
commit f788fd4bb4
22 changed files with 753 additions and 199 deletions
+196 -161
View File
@@ -7,7 +7,7 @@
@gpu="pollComfyHealth"
/>
<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(0,1fr)]">
<main class="mx-auto grid min-w-0 max-w-7xl items-start gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<section id="studio-input" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">
<div>
<div class="mb-4 grid grid-cols-2 gap-3">
@@ -219,12 +219,6 @@
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<label class="text-sm font-medium text-zinc-300">{{ studioMode === 'editv2' && v2Mode === 'generate' ? 'Generate prompt' : studioMode === 'editv2' && v2Mode === 'refine' ? 'Refine prompt' : studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
<div class="flex flex-wrap items-center gap-3">
<KeepPromptMenu
:items="activeKeepPrompts"
:heading="keepPromptHeading"
align="right"
@insert="insertKeepPrompt"
/>
<button
v-if="identityPrompting"
type="button"
@@ -284,21 +278,15 @@
:class="studioMode === 'edit' || studioMode === 'editv2' ? 'md:grid-cols-2' : ''"
>
<div class="min-w-0">
<textarea
v-model="prompt"
:rows="shotScriptMode || identityPrompting ? 12 : 6"
class="hidden max-h-[min(28rem,70vh)] w-full overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 [overflow-wrap:anywhere] focus:ring-2 md:block"
:placeholder="promptPlaceholder"
<PromptPartsEditor
v-model:pre="promptPre"
v-model:prompt="prompt"
v-model:post="promptPost"
:prompt-placeholder="promptPlaceholder"
:prompt-rows="shotScriptMode || identityPrompting ? 12 : 6"
:keep-items="activeKeepPrompts"
:keep-heading="keepPromptHeading"
/>
<button
type="button"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-left md:hidden"
@click="promptDrawerOpen = true"
>
<p v-if="prompt.trim()" class="line-clamp-4 whitespace-pre-wrap text-sm text-zinc-200 [overflow-wrap:anywhere]">{{ prompt }}</p>
<p v-else class="text-sm text-zinc-600">{{ promptPlaceholder }}</p>
<span class="mt-2 inline-block text-xs text-amber-200">{{ prompt.trim() ? 'View / edit prompt' : 'Write prompt' }}</span>
</button>
</div>
<label v-if="studioMode === 'edit' || studioMode === 'editv2'" class="block min-w-0 text-sm">
<span class="mb-2 block font-medium text-zinc-300">Negative prompt</span>
@@ -318,18 +306,6 @@
/>
</label>
</div>
<div v-if="activeKeepPrompts.length" class="mt-2 flex flex-wrap gap-1.5">
<button
v-for="item in activeKeepPrompts"
:key="item.id"
type="button"
class="max-w-full truncate rounded-full border border-white/10 px-2.5 py-1 text-xs text-zinc-300 hover:border-amber-300/40 hover:text-amber-100"
:title="item.text"
@click="insertKeepPrompt(item)"
>
{{ item.label }}
</button>
</div>
<p v-if="studioMode === 'editv2' && v2Mode === 'generate'" class="mt-3 text-xs text-zinc-500">No reference image. Prompt only.</p>
<div v-if="studioMode === 'editv2' && v2Mode === 'refine'" class="mt-3 space-y-2">
<p class="text-xs text-zinc-500">Prompt only what should change in the painted area.</p>
@@ -432,11 +408,14 @@
Remove
</button>
</div>
<textarea
<PromptField
v-model="item.prompt"
rows="3"
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"
:rows="3"
:title="`${imagePassMode === 'batch' ? 'Prompt' : 'Pass'} ${index + 2}`"
:placeholder="extraPassPlaceholder"
:keep-items="activeKeepPrompts"
:keep-heading="keepPromptHeading"
:disabled="editBusy"
/>
</div>
<button
@@ -458,28 +437,34 @@
<p class="mt-3 text-[10px] uppercase tracking-wide text-zinc-500">Concatenated result</p>
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ composedIdentityPrompt }}</p>
</details>
<div v-if="studioMode === 'video'" class="mt-3 space-y-3">
<GlobalLocksEditor v-model="globalLocks" />
<div class="rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2.5">
<PermanenceRefStrip
:refs="familyPermanenceRefs"
:name-options="lockBindingKeys"
label="Permanence refs · all shots"
hint="Name + still"
cta="Add permanence refs"
add-title="Choose a still to name for every shot"
:hidden="hideInputPreview"
@add-ref="openPermanencePicker()"
@remove="removeFamilyRef"
@update-label="updateFamilyRefLabel"
/>
<p class="mt-2 text-[11px] leading-relaxed text-zinc-500">
MiniMax will not see these photos. Continuity is the last frame of the previous clip. The stills only name the lock lines.
</p>
<p class="mt-1 text-[11px] leading-relaxed text-zinc-500">{{ permanenceComfyNote }}</p>
<p v-if="familyRefLabelLine" class="mt-1 text-[11px] text-amber-200/80">{{ familyRefLabelLine }}</p>
<details v-if="studioMode === 'video'" class="mt-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<summary class="cursor-pointer text-sm font-medium text-zinc-200">
Continuity
<span class="ml-2 font-normal text-xs text-zinc-500">Locks and named stills. Last-frame I2V carries the picture.</span>
</summary>
<div class="mt-3 space-y-3">
<GlobalLocksEditor v-model="globalLocks" embedded />
<div class="rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2.5">
<PermanenceRefStrip
:refs="familyPermanenceRefs"
:name-options="lockBindingKeys"
label="Named stills · all shots"
hint="Name + still"
cta="Add a named still"
add-title="Choose a still to name for every shot"
:hidden="hideInputPreview"
@add-ref="openPermanencePicker()"
@remove="removeFamilyRef"
@update-label="updateFamilyRefLabel"
/>
<p class="mt-2 text-[11px] leading-relaxed text-zinc-500">
MiniMax does not see these photos. Shot 2+ starts from the previous clip’s last frame. Names only go in the lock line.
</p>
<p class="mt-1 text-[11px] leading-relaxed text-zinc-500">{{ permanenceComfyNote }}</p>
<p v-if="familyRefLabelLine" class="mt-1 text-[11px] text-amber-200/80">{{ familyRefLabelLine }}</p>
</div>
</div>
</div>
</details>
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 space-y-2">
<p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
<div
@@ -532,16 +517,7 @@
class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4"
>
<div class="mb-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-zinc-200">Extension #{{ index + 1 }}</p>
<KeepPromptMenu
:items="keepPrompts.video"
heading="Video prompts"
align="left"
:disabled="videoBusy"
@insert="insertKeepIntoExtension(item.id, $event)"
/>
</div>
<p class="text-sm font-medium text-zinc-200">Extension #{{ index + 1 }}</p>
<button
type="button"
class="text-xs text-red-300 hover:text-red-200"
@@ -551,11 +527,14 @@
Remove
</button>
</div>
<textarea
<PromptField
v-model="item.prompt"
rows="3"
class="max-h-32 w-full overflow-y-auto 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 [overflow-wrap:anywhere] focus:ring-2 md:max-h-none"
:rows="3"
:title="`Extension #${index + 1}`"
placeholder="Motion prompt for this extension…"
:keep-items="keepPrompts.video"
keep-heading="Video prompts"
:disabled="videoBusy"
/>
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
<LoraSelect
@@ -737,11 +716,13 @@
</div>
</div>
<div v-if="videoEngine === 'minimax' && videoStart === 'still' && minimaxGraph === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 space-y-3">
<div>
<p class="text-sm font-medium text-zinc-200">Character stills</p>
<p class="mt-0.5 text-xs text-zinc-500">Optional. Extra views of the same person — face, hair, body. Shot 2+ still starts from the last frame (clothes and pose stay). Empty slots are left out. A named extra person turns these off.</p>
</div>
<details v-if="videoEngine === 'minimax' && videoStart === 'still' && minimaxGraph === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3" :open="identityRefs.some(Boolean)">
<summary class="cursor-pointer text-sm font-medium text-zinc-200">
Character stills
<span class="ml-2 font-normal text-xs text-zinc-500">Optional extra views of the same person. Off unless you need them.</span>
</summary>
<div class="mt-3 space-y-3">
<p class="text-xs text-zinc-500">Face, hair, body. Shot 2+ still starts from the last frame (clothes and pose stay). Empty slots are left out. A named extra person turns these off.</p>
<div v-if="!extraPersonLock" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div
v-for="slot in 4"
@@ -782,7 +763,8 @@
</div>
<p v-if="extraPersonLock" class="text-xs text-zinc-500">Character stills stay off while a second named person is attached. Shot 2+ starts from the last frame.</p>
<p v-else class="text-xs text-zinc-500">Picture 1 is the start still on shot 1 and the last frame after that. Extra stills are Picture 2–5. They do not put the start-still outfit back on.</p>
</div>
</div>
</details>
</div>
<div v-if="studioMode === 'video'" class="max-w-md space-y-2">
@@ -1046,20 +1028,15 @@
<p class="text-xs text-zinc-500">Graph default is 20 steps, CFG 1, euler. CFG 0–20 (0.1 steps), steps 1–50. A second still switches the two-image branch on. Scale to 1 MP runs Scale Image to Total Pixels (lanczos) on both stills before Klein.</p>
</div>
<details v-if="studioMode === 'video'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
<div class="mt-4 space-y-4">
<div>
<AspectRatioPick v-model="aspect" heading="Aspect ratio / resolution" :options="videoAspects" />
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? (file ? ' · matching the still' : ' · 16:9 until a still is loaded') : '' }}</p>
</div>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p>
<div class="grid grid-cols-2 gap-2">
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = true">{{ ltxVideo ? '8 Distilled LoRA' : (videoWorkflow === 'v2' ? '6 Turbo · LoRA on' : '8 Turbo · LoRA on') }}</button>
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="!turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = false">{{ ltxVideo ? '12 Quality · LoRA on' : '20 High-Fidelity · LoRA bypass' }}</button>
</div>
</div>
<div v-if="studioMode === 'video'" class="space-y-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<DurationField
v-model="duration"
label="Duration"
:hint="shotScriptMode
? 'This length applies to the initial shot and every extension in the script.'
: 'Type any length, or hover the wheel and scroll. Shift-scroll jumps by 1s. Drag up and down.'"
/>
<div class="text-sm">
<Thumbwheel
v-model="cfg"
@@ -1069,9 +1046,48 @@
:step="CFG_STEP"
@update:model-value="cfgTouched = true"
/>
<p class="mt-1 text-[11px] text-zinc-500">{{ ltxVideo ? 'Default 3.5 for PinkCherry' : (turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity') }}. Hover and scroll ±0.1, Shift-scroll ±1. Drag the wheel up and down.</p>
<p class="mt-1 text-[11px] text-zinc-500">{{ ltxVideo ? 'Default 3.5 for PinkCherry' : (turbo ? 'Default 1.5 with Turbo LoRA' : 'Default 4.0 with High-Fidelity') }}.</p>
</div>
</div>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Seed</span>
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
</label>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Sampler & scheduler</p>
<div class="grid grid-cols-2 gap-3">
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Sampler</span>
<select v-model="samplerName" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option value="res_multistep">res_multistep</option>
<option value="euler">euler</option>
<option value="dpmpp_2m">dpmpp_2m</option>
</select>
</label>
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Scheduler</span>
<select v-model="scheduler" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option value="simple">simple</option>
<option value="ddim_uniform">ddim_uniform</option>
<option value="sgm_uniform">sgm_uniform</option>
</select>
</label>
</div>
</div>
<details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<summary class="cursor-pointer text-sm font-medium">Aspect, steps, frame rate</summary>
<div class="mt-4 space-y-4">
<div>
<AspectRatioPick v-model="aspect" heading="Aspect ratio / resolution" :options="videoAspects" />
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? (file ? ' · matching the still' : ' · 16:9 until a still is loaded') : '' }}</p>
</div>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Steps</p>
<div class="grid grid-cols-2 gap-2">
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = true">{{ ltxVideo ? '8 Distilled LoRA' : (videoWorkflow === 'v2' ? '6 Turbo · LoRA on' : '8 Turbo · LoRA on') }}</button>
<button type="button" class="rounded-xl border px-3 py-2 text-sm" :class="!turbo ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'" @click="turbo = false">{{ ltxVideo ? '12 Quality · LoRA on' : '20 High-Fidelity · LoRA bypass' }}</button>
</div>
</div>
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Frame rate</span>
<select v-model.number="fps" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
@@ -1081,41 +1097,9 @@
</select>
<p class="mt-1 text-[11px] text-zinc-500">{{ frameCount }} frames · duration × FPS</p>
</label>
<DurationField
v-model="duration"
label="Duration"
:hint="shotScriptMode
? 'This length applies to the initial shot and every extension in the script.'
: 'Type any length, or hover the wheel and scroll. Shift-scroll jumps by 1s. Drag up and down.'"
/>
</div>
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Seed</span>
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2" placeholder="random">
</label>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Sampler & scheduler</p>
<div class="grid grid-cols-2 gap-3">
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Sampler</span>
<select v-model="samplerName" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option value="res_multistep">res_multistep</option>
<option value="euler">euler</option>
<option value="dpmpp_2m">dpmpp_2m</option>
</select>
</label>
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Scheduler</span>
<select v-model="scheduler" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm">
<option value="simple">simple</option>
<option value="ddim_uniform">ddim_uniform</option>
<option value="sgm_uniform">sgm_uniform</option>
</select>
</label>
</div>
</div>
</div>
</details>
</details>
</div>
<label
v-if="studioMode === 'video' && queuedExtensionCount > 0"
@@ -1228,8 +1212,8 @@
</div>
</section>
<section class="min-w-0 max-w-full space-y-5">
<div ref="outputPanel" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6">
<section class="min-w-0 max-w-full space-y-5 lg:sticky lg:top-4 lg:flex lg:max-h-[calc(100dvh-5.5rem)] lg:flex-col lg:overflow-hidden">
<div ref="outputPanel" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 lg:shrink-0">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<h2 class="font-display text-2xl font-bold">Output</h2>
@@ -1428,21 +1412,16 @@
</p>
</div>
<label class="block text-sm">
<span class="mb-1 flex items-center gap-2 text-zinc-400">
Extension prompt
<KeepPromptMenu
:items="keepPrompts.video"
heading="Video prompts"
align="left"
:disabled="lastFrameLoading || videoBusy"
@insert="insertKeepExtend"
/>
</span>
<textarea
<span class="mb-1 block text-zinc-400">Extension prompt</span>
<PromptField
v-model="extendPrompt"
rows="4"
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"
:rows="4"
title="Extension prompt"
placeholder="Continue the motion from this frame…"
:keep-items="keepPrompts.video"
keep-heading="Video prompts"
:disabled="lastFrameLoading || videoBusy"
textarea-class=""
/>
</label>
<DurationField
@@ -1572,7 +1551,7 @@
</div>
</div>
<div class="glass rounded-3xl p-5 sm:p-6">
<div class="glass rounded-3xl p-5 sm:p-6 lg:flex lg:min-h-0 lg:flex-1 lg:flex-col">
<div class="flex items-center justify-between gap-3">
<h2 class="font-display text-xl font-bold">Library</h2>
<div class="flex items-center gap-3">
@@ -1603,6 +1582,7 @@
</form>
<template v-else>
<div class="lg:flex lg:min-h-0 lg:flex-1 lg:flex-col">
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
<div class="flex min-w-0 flex-wrap gap-1.5">
<button
@@ -1656,7 +1636,7 @@
<button type="button" class="text-red-300 hover:text-red-200" @click="askRemoveSelected">Delete</button>
<button type="button" class="text-zinc-400 hover:text-white" @click="clearSelection">Clear</button>
</div>
<ul class="mt-4 max-h-[min(28rem,50vh)] space-y-3 overflow-y-auto">
<ul class="mt-4 max-h-[min(28rem,50vh)] space-y-3 overflow-y-auto lg:min-h-0 lg:max-h-none lg:flex-1">
<li
v-for="entry in visibleLibraryEntries"
:key="entry.key"
@@ -1932,6 +1912,13 @@
:disabled="keepingInputBusy"
@click="saveStillAsInput(entry.stillGroup.head)"
>Save as input</button>
<button
v-if="backupStudioEnabled"
type="button"
class="text-xs text-amber-300"
:disabled="backupBusyId === entry.stillGroup.head.id"
@click="sendStillToBackup(entry.stillGroup.head)"
>{{ backupBusyId === entry.stillGroup.head.id ? 'Sending…' : 'Send to Backup on AIGen only' }}</button>
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(entry.key)">
{{ expandedDetails[entry.key] ? 'Hide details' : 'Details' }}
</button>
@@ -2008,6 +1995,13 @@
:disabled="keepingInputBusy"
@click="saveStillAsInput(item)"
>Save as input</button>
<button
v-if="backupStudioEnabled"
type="button"
class="text-xs text-amber-300"
:disabled="backupBusyId === item.id"
@click="sendStillToBackup(item)"
>{{ backupBusyId === item.id ? 'Sending…' : 'Send to Backup on AIGen only' }}</button>
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(item.id)">
{{ expandedDetails[item.id] ? 'Hide details' : 'Details' }}
</button>
@@ -2045,20 +2039,12 @@
Showing {{ visibleLibraryEntries.length }} of {{ libraryEntries.length }}.
<NuxtLink to="/library" class="text-amber-300 hover:text-amber-100">Open full library</NuxtLink>
</p>
</div>
</template>
</div>
</section>
</main>
<PromptDrawer
:open="promptDrawerOpen"
v-model:text="prompt"
:title="studioMode === 'edit' || studioMode === 'editv2' ? 'Edit prompt' : (shotScriptMode ? 'Shot script' : 'Prompt')"
:placeholder="promptPlaceholder"
:keep-items="activeKeepPrompts"
:keep-heading="keepPromptHeading"
@update:open="promptDrawerOpen = $event"
/>
<ImageBatchModal
:open="imageBatchOpen"
v-model:text="imageBatchDraft"
@@ -2155,6 +2141,7 @@
</template>
<script setup lang="ts">
import { composePromptParts, restorePromptParts } from '~/utils/promptParts'
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
import { parseImageBatchScript, parseShotScript } from '~/utils/parseShots'
import type { DirectorScript } from '~/utils/directorScripts'
@@ -2296,6 +2283,8 @@ interface LibraryStill {
role?: 'input' | 'output'
name?: string
prompt?: string
promptPre?: string
promptPost?: string
familyId?: string
parentStillId?: string
chainIndex?: number
@@ -2333,6 +2322,8 @@ interface LibraryClip {
permanenceRefs?: PermanenceRef[]
loraName?: string
loraStack?: LoraStackItem[]
promptPre?: string
promptPost?: string
}
interface RetryDraft {
@@ -2343,6 +2334,8 @@ interface RetryDraft {
stillFilename?: string
name?: string
prompt: string
promptPre?: string
promptPost?: string
aspect: string
width: number
height: number
@@ -2439,7 +2432,10 @@ const inputStillRenameEl = ref<HTMLInputElement | null>(null)
const preview = ref('')
const dragging = ref(false)
const prompt = ref('')
const promptDrawerOpen = ref(false)
const promptPre = ref('')
const promptPost = ref('')
const backupBusyId = ref('')
const backupStudioEnabled = computed(() => Boolean(runtimeConfig.public.backupStudioEnabled))
const shotScriptMode = ref(false)
const directorScripts = ref<DirectorScript[]>([])
const directorScriptId = ref('')
@@ -2983,7 +2979,7 @@ const canUseGeneratedStill = computed(() => Boolean(
))
const videoBlockReason = computed(() => {
if (!textToVideo.value && !hasInputStill.value) return 'Load a still first. Image-to-video needs an input still.'
if (!prompt.value.trim()) return 'Write a motion prompt first.'
if (![promptPre.value, prompt.value, promptPost.value].some(part => part.trim())) return 'Write a motion prompt first.'
if (!folderId.value) return 'Choose a library folder before generating.'
if (!queueReady.value) {
return shotScriptMode.value ? 'Each shot in the script needs a prompt.' : 'Each queued extension needs a prompt.'
@@ -3135,7 +3131,7 @@ const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, i
function composedShotPreview(text: string, index: number, shotN?: number) {
const composed = composeShotPrompt({
globalLocks: globalLocks.value,
prompt: text,
prompt: composePromptParts(promptPre.value, text, promptPost.value),
shotIndex: index,
familyRefs: familyPermanenceRefs.value,
shotRefs: shotPermanenceRefs.value[shotN ?? index + 1]
@@ -4095,6 +4091,21 @@ function insertKeepPrompt(item: KeepPrompt) {
prompt.value = insertKeepSnippet(prompt.value, item.text)
}
function applyPromptRestore(saved: string, stored?: { prompt?: string; promptPre?: string; promptPost?: string } | null) {
const parts = restorePromptParts(saved, {
pre: stored?.promptPre,
prompt: stored?.prompt ?? saved,
post: stored?.promptPost
}, {
pre: promptPre.value,
prompt: prompt.value,
post: promptPost.value
})
promptPre.value = parts.pre
prompt.value = parts.prompt
promptPost.value = parts.post
}
function insertKeepExtend(item: KeepPrompt) {
extendPrompt.value = insertKeepSnippet(extendPrompt.value, item.text)
}
@@ -4118,11 +4129,9 @@ function insertLoraTrigger(phrase: string, target: 'main' | 'extend' | { shot: n
}
if (typeof target === 'object' && 'shot' in target) {
prompt.value = insertActivationInShotScript(prompt.value, target.shot, token)
promptDrawerOpen.value = true
return
}
prompt.value = insertActivationPhrase(prompt.value, token)
promptDrawerOpen.value = true
}
function setShotLoraStack(shotN: number, stack: LoraStackItem[]) {
@@ -4680,6 +4689,8 @@ function resetImage() {
function clearVideoForm() {
resetImage()
prompt.value = ''
promptPre.value = ''
promptPost.value = ''
clipName.value = ''
extensionQueue.value = []
shotScriptMode.value = false
@@ -5115,6 +5126,8 @@ async function restoreStudioJob(id: string) {
referenceStillIds?: Array<string | null>
extensions?: { prompt: string; duration: number; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
queueAutoRun?: boolean
promptPre?: string
promptPost?: string
globalLocks?: string
permanenceRefs?: PermanenceRef[]
shotPermanenceRefs?: PermanenceRef[][]
@@ -5131,9 +5144,9 @@ async function restoreStudioJob(id: string) {
closeExtend()
const extensions = payload.extensions || []
applyVideoWorkflow(payload.workflow)
prompt.value = extensions.length
applyPromptRestore(extensions.length
? [payload.prompt.trim(), ...extensions.map((item, index) => `shot ${index + 2}\n${item.prompt.trim()}`)].join('\n\n')
: (payload.prompt || '')
: (payload.prompt || ''), payload)
clipName.value = payload.name || ''
seedInput.value = String(payload.seed || '')
restoreAdvanced({
@@ -5516,7 +5529,7 @@ async function rerun(item: LibraryClip, collection = false) {
const parts = familyPartsFor(item)
const target = collection ? parts[0] || item : item
const restoreAll = collection && parts.length > 1
prompt.value = restoreAll ? familyScript(parts) : target.prompt
applyPromptRestore(restoreAll ? familyScript(parts) : target.prompt, target)
clipName.value = restoreAll
? (familyBaseName(collectionTitle({ head: target, parts })) || target.name || '')
: (target.name || '')
@@ -5574,7 +5587,7 @@ async function rerun(item: LibraryClip, collection = false) {
async function loadDraft(draft: RetryDraft) {
activeDraftId = draft.id
prompt.value = draft.prompt
applyPromptRestore(draft.prompt, draft)
clipName.value = draft.name || ''
seedInput.value = String(draft.seed)
restoreAdvanced(draft)
@@ -5711,6 +5724,7 @@ async function openExtend(item?: LibraryClip) {
}
currentClipId.value = id
const clip = clips.value.find(entry => entry.id === id)
if (clip) applyPromptRestore(clip.prompt, clip)
extendPrompt.value = clip?.prompt || prompt.value
extendDuration.value = clampDuration(duration.value)
extendLoraStack.value = readLoraStack(clip)
@@ -5750,6 +5764,8 @@ async function generateExtension() {
body: {
clipId: id,
prompt: extendPrompt.value.trim(),
promptPre: promptPre.value.trim() || undefined,
promptPost: promptPost.value.trim() || undefined,
duration: extendDuration.value,
loraStack: extendLoraStack.value
}
@@ -5812,7 +5828,7 @@ function applyStillSettings(still: LibraryStill) {
const settings = still.settings
folderId.value = still.folderId
browseFolderId.value = still.folderId
if (still.prompt) prompt.value = still.prompt
if (still.prompt || still.promptPre || still.promptPost) applyPromptRestore(still.prompt || '', still)
if (dest === 'imagev2') {
studioMode.value = 'editv2'
outputFocus.value = 'edit'
@@ -5889,6 +5905,19 @@ function stillUseAsInputMessage(still: LibraryStill) {
return 'Loaded as input. It stays in Output, not the upload library.'
}
async function sendStillToBackup(still: Pick<LibraryStill, 'id'>) {
if (!backupStudioEnabled.value || backupBusyId.value) return
backupBusyId.value = still.id
try {
await $fetch(`/api/library/stills/${still.id}/backup`, { method: 'POST' })
toast('Sent to Backup as a hidden input.')
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not send that still to Backup')
} finally {
backupBusyId.value = ''
}
}
async function saveStillAsInput(still: Pick<LibraryStill, 'id'>) {
if (keepingInputBusy.value) return
keepingInputBusy.value = true
@@ -6470,6 +6499,8 @@ async function editImage() {
body.append('image', file.value)
if (editRefFile.value) body.append('image2', editRefFile.value)
body.append('prompt', prompt.value.trim())
if (promptPre.value.trim()) body.append('promptPre', promptPre.value.trim())
if (promptPost.value.trim()) body.append('promptPost', promptPost.value.trim())
body.append('aspect', aspect.value)
body.append('negative', editNegative.value)
body.append('steps', String(clampImageSteps(editSteps.value)))
@@ -6707,6 +6738,8 @@ async function editImageV2() {
body.append('aspect', aspect.value)
body.append('engine', v2Engine.value)
body.append('prompt', prompt.value.trim())
if (promptPre.value.trim()) body.append('promptPre', promptPre.value.trim())
if (promptPost.value.trim()) body.append('promptPost', promptPost.value.trim())
body.append('negative', v2Negative.value)
body.append('snofs_model', String(imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthModel ?? 0))
body.append('snofs_clip', String(imageV2EngineLoras().find(item => isImageV2ConceptLora(item.name))?.strengthClip ?? 0))
@@ -6909,6 +6942,8 @@ async function generate() {
if (file.value) body.append('image', file.value)
if (inputStillId.value) body.append('stillId', inputStillId.value)
body.append('prompt', initialPrompt)
if (promptPre.value.trim()) body.append('promptPre', promptPre.value.trim())
if (promptPost.value.trim()) body.append('promptPost', promptPost.value.trim())
body.append('aspect', aspect.value)
body.append('width', String(outputSize.value.width))
body.append('height', String(outputSize.value.height))
+23
View File
@@ -271,6 +271,13 @@
:disabled="keepingInputBusy"
@click="saveFocusedAsInput"
>{{ keepingInputBusy ? 'Saving…' : 'Save as input' }}</button>
<button
v-if="focused.kind === 'still' && backupStudioEnabled"
type="button"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
:disabled="backupBusy"
@click="sendFocusedToBackup"
>{{ backupBusy ? 'Sending…' : 'Send to Backup on AIGen only' }}</button>
<a
v-if="downloadHref"
:href="downloadHref"
@@ -375,6 +382,8 @@ const filterOptions = [
const runtimeConfig = useRuntimeConfig()
const instanceName = ref(String(runtimeConfig.public.instanceName || 'AIGen'))
const backupStudioEnabled = computed(() => Boolean(runtimeConfig.public.backupStudioEnabled))
const backupBusy = ref(false)
const showPrivacyToggles = computed(() => isXaigenStudio({
studio: runtimeConfig.public.studio,
instanceName: runtimeConfig.public.instanceName || instanceName.value
@@ -762,6 +771,20 @@ function useInStudio(entry: LibraryEntry) {
}
}
async function sendFocusedToBackup() {
const still = focusedStill.value
if (!still || !backupStudioEnabled.value || backupBusy.value) return
backupBusy.value = true
try {
await $fetch(`/api/library/stills/${still.id}/backup`, { method: 'POST' })
toast('Sent to Backup as a hidden input.')
} catch (error: any) {
toast(error?.data?.statusMessage || 'Could not send that still to Backup')
} finally {
backupBusy.value = false
}
}
async function saveFocusedAsInput() {
const still = focusedStill.value
if (!still || keepingInputBusy.value) return