Add sidecar Qwen VL prompt recommend, fix queued shot bursts, and leave unused MiniMax identity slots empty.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
|
||||
import { comfyConfigured } from '~/server/utils/comfy'
|
||||
import { imageComfyConfigured, getBeastImageHost } from '~/server/utils/imageComfy'
|
||||
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost } from '~/server/utils/imageComfy'
|
||||
import { checkImageComfyHttp, fetchQueueAt } from '~/server/utils/imageComfyLifecycle'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const videoConfigured = comfyConfigured()
|
||||
const imageConfigured = imageComfyConfigured()
|
||||
const beastHost = getBeastImageHost()
|
||||
const [comfy, queue, beast, beastQueue] = await Promise.all([
|
||||
const sidecarHost = getSidecarImageHost()
|
||||
const [comfy, queue, beast, beastQueue, sidecar, sidecarQueue] = await Promise.all([
|
||||
videoConfigured
|
||||
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
||||
: Promise.resolve({ ok: false, host: '', stats: null }),
|
||||
@@ -15,10 +16,15 @@ export default defineEventHandler(async () => {
|
||||
beastHost
|
||||
? checkImageComfyHttp(2500, beastHost).catch(() => ({ ok: false, host: beastHost, stats: null }))
|
||||
: Promise.resolve({ ok: false, host: beastHost, stats: null }),
|
||||
beastHost ? fetchQueueAt(beastHost) : Promise.resolve({ running: 0, pending: 0 })
|
||||
beastHost ? fetchQueueAt(beastHost) : Promise.resolve({ running: 0, pending: 0 }),
|
||||
sidecarHost
|
||||
? checkImageComfyHttp(2500, sidecarHost).catch(() => ({ ok: false, host: sidecarHost, stats: null }))
|
||||
: Promise.resolve({ ok: false, host: sidecarHost, stats: null }),
|
||||
sidecarHost ? fetchQueueAt(sidecarHost) : Promise.resolve({ running: 0, pending: 0 })
|
||||
])
|
||||
const busy = Boolean(comfy.ok && queue.running > 0)
|
||||
const beastBusy = Boolean(beast.ok && beastQueue.running > 0)
|
||||
const sidecarBusy = Boolean(sidecar.ok && sidecarQueue.running > 0)
|
||||
const imageOk = Boolean(beast.ok || comfy.ok)
|
||||
const imageBusy = Boolean(imageConfigured && (beastBusy || busy))
|
||||
const preferredHost = beastHost || comfy.host || ''
|
||||
@@ -50,10 +56,12 @@ export default defineEventHandler(async () => {
|
||||
},
|
||||
sidecar: {
|
||||
label: 'Sidecar',
|
||||
configured: false,
|
||||
ok: false,
|
||||
busy: false,
|
||||
host: ''
|
||||
configured: Boolean(sidecarHost),
|
||||
ok: Boolean(sidecar.ok),
|
||||
busy: sidecarBusy,
|
||||
host: sidecarHost,
|
||||
queueRunning: sidecarQueue.running || 0,
|
||||
queuePending: sidecarQueue.pending || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { listPromptPresets } from '~/server/utils/promptPresets'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const owner = libraryOwnerKey(event)
|
||||
return {
|
||||
presets: listPromptPresets(owner).map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description || '',
|
||||
builtin: Boolean(item.builtin),
|
||||
systemPrompt: item.systemPrompt
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createPromptPreset, listPromptPresets } from '~/server/utils/promptPresets'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ name?: string; systemPrompt?: string; description?: string }>(event).catch(() => ({}))
|
||||
const owner = libraryOwnerKey(event)
|
||||
const created = await createPromptPreset(owner, {
|
||||
name: body.name || '',
|
||||
systemPrompt: body.systemPrompt || '',
|
||||
description: body.description
|
||||
})
|
||||
return {
|
||||
preset: created,
|
||||
presets: listPromptPresets(owner)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { deletePromptPreset, listPromptPresets } from '~/server/utils/promptPresets'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing instruction set id' })
|
||||
const owner = libraryOwnerKey(event)
|
||||
await deletePromptPreset(owner, id)
|
||||
return {
|
||||
ok: true,
|
||||
presets: listPromptPresets(owner)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { updatePromptPreset, listPromptPresets } from '~/server/utils/promptPresets'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing instruction set id' })
|
||||
const body = await readBody<{ name?: string; systemPrompt?: string; description?: string }>(event).catch(() => ({}))
|
||||
const owner = libraryOwnerKey(event)
|
||||
const preset = await updatePromptPreset(owner, id, body)
|
||||
return {
|
||||
preset,
|
||||
presets: listPromptPresets(owner)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { promptComfyConfigured, createPromptJob, runPromptRecommend, failPromptJob, promptJobSnapshot } from '~/server/utils/promptComfy'
|
||||
import { getPromptPreset } from '~/server/utils/promptPresets'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (!promptComfyConfigured()) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
statusMessage: 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST.'
|
||||
})
|
||||
}
|
||||
|
||||
const form = await readMultipartFormData(event).catch(() => null)
|
||||
if (!form?.length) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Expected an image and a draft prompt' })
|
||||
}
|
||||
|
||||
const fields: Record<string, string> = {}
|
||||
let image: { filename: string; data: Buffer; type?: string } | null = null
|
||||
for (const part of form) {
|
||||
if (part.name === 'image' && part.filename && part.data?.length) {
|
||||
image = { filename: part.filename, data: part.data, type: part.type }
|
||||
} else if (part.name && part.data) {
|
||||
fields[part.name] = part.data.toString('utf8')
|
||||
}
|
||||
}
|
||||
|
||||
if (!image) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Load a still before recommending a prompt' })
|
||||
}
|
||||
const draftPrompt = (fields.prompt || '').trim()
|
||||
if (!draftPrompt) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Type a draft prompt to enhance' })
|
||||
}
|
||||
|
||||
const owner = libraryOwnerKey(event)
|
||||
const preset = getPromptPreset(owner, fields.presetId || 'enhance')
|
||||
const mode = ['edit', 'shot-script', 'video'].includes(fields.mode) ? fields.mode : 'video'
|
||||
const job = createPromptJob()
|
||||
|
||||
void runPromptRecommend(job, {
|
||||
image,
|
||||
draftPrompt,
|
||||
systemPrompt: preset.systemPrompt,
|
||||
mode
|
||||
}).catch(error => failPromptJob(job, error))
|
||||
|
||||
return promptJobSnapshot(job)
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getPromptJob, promptJobSnapshot } from '~/server/utils/promptComfy'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
const job = id ? getPromptJob(id) : undefined
|
||||
if (!job) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Recommend job not found' })
|
||||
}
|
||||
return promptJobSnapshot(job)
|
||||
})
|
||||
@@ -1,10 +1,17 @@
|
||||
import { startQueueBurst } from '~/server/utils/videoChain'
|
||||
|
||||
function parseCount(raw: unknown): number | 'all' {
|
||||
if (raw === 'all' || raw === 'remaining') return 'all'
|
||||
const n = typeof raw === 'number' ? raw : Number(raw)
|
||||
if (!Number.isFinite(n)) return 1
|
||||
return Math.max(1, Math.min(50, Math.floor(n)))
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const body = await readBody<{ count?: number | 'all' }>().catch(() => ({}))
|
||||
const raw = body?.count
|
||||
const count = raw === 'all' || raw === 'remaining' ? 'all' as const : Math.max(1, Math.min(50, Number(raw) || 1))
|
||||
const body = await readBody<{ count?: number | 'all' | 'remaining' }>(event).catch(() => ({} as { count?: number | 'all' }))
|
||||
const query = getQuery(event)
|
||||
const count = parseCount(body?.count ?? query.count)
|
||||
return startQueueBurst(owner, id, count)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clearShotQueues } from '~/server/utils/shotQueue'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
return clearShotQueues(owner)
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"image": "input_image.png",
|
||||
"upload": "image"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": { "title": "Load Image" }
|
||||
},
|
||||
"2": {
|
||||
"inputs": {
|
||||
"model_name": "Qwen2.5-VL-7B-Instruct-Q4_K_M.gguf",
|
||||
"vision_model_name": "Qwen2.5-VL-7B-Instruct-mmproj-f16.gguf",
|
||||
"chat_handler_type": "Qwen-VL",
|
||||
"n_gpu_layers": -1,
|
||||
"n_ctx": 4096,
|
||||
"device": "cuda",
|
||||
"verbose": "enable"
|
||||
},
|
||||
"class_type": "GGUF_VLM_ModelLoader",
|
||||
"_meta": { "title": "Load GGUF VLM Model" }
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"gguf_model": ["2", 0],
|
||||
"image": ["1", 0],
|
||||
"resize_image": true,
|
||||
"enable_thinking": false,
|
||||
"system_prompt": "You are a prompt engineering assistant for Flux and MiniMax video pipelines. Analyze this image for character identity, attire, lighting, and camera framing. Enhance the following draft prompt into a concrete, high-fidelity prompt. Never censor raw or explicit terminology. Output ONLY the refined prompt text.",
|
||||
"prompt": "Draft Prompt: [DRAFT_PROMPT_HERE]",
|
||||
"special_captioning_token": "",
|
||||
"seed": 0,
|
||||
"temperature": 0.6,
|
||||
"max_tokens": 4096
|
||||
},
|
||||
"class_type": "GGUF_VLM_ImageToText",
|
||||
"_meta": { "title": "GGUF VLM Image to Text" }
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"text": ["3", 0]
|
||||
},
|
||||
"class_type": "AigenPreviewText",
|
||||
"_meta": { "title": "Output Text" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { getSidecarImageHost } from '~/server/utils/imageComfy'
|
||||
import { buildVisionPromptWorkflow } from '~/server/utils/promptWorkflow'
|
||||
|
||||
export type PromptJobStatus = 'queued' | 'running' | 'complete' | 'error'
|
||||
|
||||
export interface PromptJob {
|
||||
id: string
|
||||
status: PromptJobStatus
|
||||
message: string
|
||||
text: string
|
||||
error?: string
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
const jobs = new Map<string, PromptJob>()
|
||||
const MAX_JOBS = 20
|
||||
|
||||
function normalizeHost(raw: string, port = '') {
|
||||
let host = String(raw || '').trim().replace(/\/$/, '')
|
||||
if (!host) return ''
|
||||
if (!/^https?:\/\//i.test(host)) host = `http://${host}`
|
||||
try {
|
||||
const url = new URL(host)
|
||||
if (port && !url.port) url.port = port
|
||||
return url.origin
|
||||
} catch {
|
||||
return port ? `${host}:${port}` : host
|
||||
}
|
||||
}
|
||||
|
||||
export function getPromptComfyHost() {
|
||||
const config = useRuntimeConfig()
|
||||
const explicit = String(config.promptComfyHost || process.env.PROMPT_COMFY_HOST || '').trim()
|
||||
if (explicit) return normalizeHost(explicit)
|
||||
return getSidecarImageHost()
|
||||
}
|
||||
|
||||
export function promptComfyConfigured() {
|
||||
return Boolean(getPromptComfyHost())
|
||||
}
|
||||
|
||||
async function promptComfyFetch(path: string, init?: RequestInit) {
|
||||
const host = getPromptComfyHost()
|
||||
if (!host) {
|
||||
throw createError({ statusCode: 503, statusMessage: 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST.' })
|
||||
}
|
||||
try {
|
||||
return await fetch(`${host}${path}`, init)
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: `Sidecar Qwen VL is unreachable (${host})`,
|
||||
data: { cause: error instanceof Error ? error.message : String(error) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function promptInputFilename(original: string, jobId?: string) {
|
||||
const raw = String(original || 'still.png')
|
||||
const dot = raw.lastIndexOf('.')
|
||||
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png'
|
||||
const base = (dot >= 0 ? raw.slice(0, dot) : raw).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48) || 'still'
|
||||
const id = String(jobId || crypto.randomUUID()).replace(/-/g, '').slice(0, 10)
|
||||
return `aigen_prompt_${id}_${base}${ext}`
|
||||
}
|
||||
|
||||
async function uploadPromptImage(file: { filename: string; data: Buffer; type?: string }, jobId?: string) {
|
||||
const body = new FormData()
|
||||
const blob = new Blob([new Uint8Array(file.data)], { type: file.type || 'application/octet-stream' })
|
||||
const filename = promptInputFilename(file.filename, jobId)
|
||||
body.append('image', blob, filename)
|
||||
body.append('overwrite', 'true')
|
||||
body.append('type', 'input')
|
||||
const res = await promptComfyFetch('/upload/image', { method: 'POST', body })
|
||||
if (!res.ok) {
|
||||
throw createError({ statusCode: 502, statusMessage: `Sidecar image upload failed (${res.status})` })
|
||||
}
|
||||
const uploaded = (await res.json()) as { name: string; subfolder?: string; type?: string }
|
||||
return uploaded.name || filename
|
||||
}
|
||||
|
||||
async function queuePromptGraph(graph: unknown, clientId: string) {
|
||||
const res = await promptComfyFetch('/prompt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: graph, client_id: clientId })
|
||||
})
|
||||
const payload = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const message = (payload as { error?: { message?: string } }).error?.message
|
||||
|| (payload as { node_errors?: unknown }).node_errors
|
||||
|| `Sidecar queue failed (${res.status})`
|
||||
throw createError({ statusCode: 502, statusMessage: String(message), data: payload })
|
||||
}
|
||||
return payload as { prompt_id: string }
|
||||
}
|
||||
|
||||
async function fetchPromptHistory(promptId: string) {
|
||||
const res = await promptComfyFetch(`/history/${encodeURIComponent(promptId)}`)
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as Record<string, unknown>
|
||||
}
|
||||
|
||||
function collectStrings(value: unknown, depth = 0): string[] {
|
||||
if (value == null || depth > 8) return []
|
||||
if (typeof value === 'string') {
|
||||
const text = value.trim()
|
||||
return text ? [text] : []
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return []
|
||||
if (Array.isArray(value)) return value.flatMap(item => collectStrings(item, depth + 1))
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>
|
||||
for (const key of ['text', 'string', 'strings']) {
|
||||
if (record[key] !== undefined) {
|
||||
const found = collectStrings(record[key], depth + 1)
|
||||
if (found.length) return found
|
||||
}
|
||||
}
|
||||
return Object.values(record).flatMap(item => collectStrings(item, depth + 1))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function extractPromptText(history: Record<string, unknown> | null, promptId: string) {
|
||||
if (!history) return ''
|
||||
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined
|
||||
const outputs = wrapped?.outputs || (history as { outputs?: Record<string, unknown> }).outputs
|
||||
if (!outputs || typeof outputs !== 'object') return ''
|
||||
const node4 = outputs['4']
|
||||
const candidates = [
|
||||
...(node4 ? collectStrings(node4) : []),
|
||||
...collectStrings(outputs)
|
||||
].sort((a, b) => b.length - a.length)
|
||||
return candidates[0] || ''
|
||||
}
|
||||
|
||||
function historyError(history: Record<string, unknown> | null, promptId: string) {
|
||||
const entry = history?.[promptId] as {
|
||||
status?: {
|
||||
status_str?: string
|
||||
completed?: boolean
|
||||
messages?: Array<[string, Record<string, unknown>]>
|
||||
}
|
||||
} | undefined
|
||||
const status = entry?.status?.status_str
|
||||
const err = entry?.status?.messages?.find(([type]) => type === 'execution_error')?.[1]
|
||||
const message = String(err?.exception_message || err?.message || '').trim()
|
||||
if (status === 'interrupted') return 'Prompt recommend was interrupted.'
|
||||
if (status === 'error') return message || 'Sidecar Qwen VL reported an execution error'
|
||||
if (entry?.status?.completed && !extractPromptText(history, promptId)) {
|
||||
return 'Sidecar Qwen VL finished without prompt text'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function waitForPromptText(promptId: string, timeoutMs = 240_000) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const history = await fetchPromptHistory(promptId)
|
||||
const text = extractPromptText(history, promptId)
|
||||
if (text) return text
|
||||
const error = historyError(history, promptId)
|
||||
if (error) {
|
||||
throw createError({ statusCode: 502, statusMessage: error })
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1200))
|
||||
}
|
||||
throw createError({ statusCode: 504, statusMessage: 'Sidecar Qwen VL timed out while writing the prompt' })
|
||||
}
|
||||
|
||||
export function createPromptJob(): PromptJob {
|
||||
const job: PromptJob = {
|
||||
id: crypto.randomUUID(),
|
||||
status: 'queued',
|
||||
message: 'Queued',
|
||||
text: '',
|
||||
startedAt: Date.now()
|
||||
}
|
||||
jobs.set(job.id, job)
|
||||
while (jobs.size > MAX_JOBS) {
|
||||
const oldest = jobs.keys().next().value
|
||||
if (oldest) jobs.delete(oldest)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
export function getPromptJob(id: string) {
|
||||
return jobs.get(id)
|
||||
}
|
||||
|
||||
export function promptJobSnapshot(job: PromptJob) {
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
message: job.message,
|
||||
text: job.text,
|
||||
error: job.error,
|
||||
elapsedMs: Date.now() - job.startedAt
|
||||
}
|
||||
}
|
||||
|
||||
function draftUserPrompt(draft: string, mode: string) {
|
||||
const studio = mode === 'edit'
|
||||
? 'image-edit'
|
||||
: mode === 'shot-script'
|
||||
? 'video-shot-script'
|
||||
: 'video'
|
||||
return `Studio mode: ${studio}\n\nDraft Prompt:\n${draft}`
|
||||
}
|
||||
|
||||
export async function runPromptRecommend(job: PromptJob, params: {
|
||||
image: { filename: string; data: Buffer; type?: string }
|
||||
draftPrompt: string
|
||||
systemPrompt: string
|
||||
mode: string
|
||||
}) {
|
||||
job.status = 'running'
|
||||
job.message = 'Uploading still to Sidecar Qwen VL…'
|
||||
const imageName = await uploadPromptImage(params.image, job.id)
|
||||
job.message = 'Qwen VL is reading the still…'
|
||||
const graph = buildVisionPromptWorkflow({
|
||||
imageName,
|
||||
draftPrompt: draftUserPrompt(params.draftPrompt, params.mode),
|
||||
systemPrompt: params.systemPrompt
|
||||
})
|
||||
const queued = await queuePromptGraph(graph, job.id)
|
||||
job.message = 'Writing the recommended prompt…'
|
||||
const text = await waitForPromptText(queued.prompt_id)
|
||||
job.text = text.trim()
|
||||
job.status = 'complete'
|
||||
job.message = 'Recommended prompt ready'
|
||||
}
|
||||
|
||||
export function failPromptJob(job: PromptJob, error: unknown) {
|
||||
const err = error as { statusMessage?: string; message?: string }
|
||||
job.status = 'error'
|
||||
job.error = err.statusMessage || err.message || 'Prompt recommend failed'
|
||||
job.message = job.error
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface PromptPreset {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
systemPrompt: string
|
||||
builtin?: boolean
|
||||
}
|
||||
|
||||
const ENHANCE_PROMPT = `You are a prompt-engineering assistant for Flux.2 Klein image edit and MiniMax H3 image-to-video.
|
||||
|
||||
Look at the attached still. The person (or subject) in that still is the identity lock. The user draft is the requested change.
|
||||
|
||||
Write production-ready prompt text the user can paste into the generator.
|
||||
|
||||
Rules:
|
||||
- Preserve identity from the still: face, age, body type, hair, skin, and distinctive features. Use explicit keep-identity language such as "same person as the reference still", "do not change facial identity", "keep original face, hair, and body".
|
||||
- Describe wardrobe, setting, pose, lighting, camera, and materials concretely.
|
||||
- Never censor raw or explicit terminology from the draft.
|
||||
- Do not add a preamble, title, markdown fences, or commentary.
|
||||
|
||||
Output format — pick one:
|
||||
- Single still/edit: output ONLY the refined prompt.
|
||||
- Ambitious still edit that should be done in phases (for example wardrobe first, then location): output:
|
||||
---pass 1---
|
||||
<prompt for pass 1>
|
||||
---pass 2---
|
||||
<prompt for pass 2>
|
||||
- Motion, multi-beat, or video sequence: first-shot prompt, then later shots with a marker on its own line:
|
||||
shot 2
|
||||
<extension prompt>
|
||||
shot 3
|
||||
<extension prompt>`
|
||||
|
||||
const SHOT_SCRIPT_PROMPT = `You are a MiniMax H3 image-to-video shot-script writer.
|
||||
|
||||
Analyze the attached still for identity, wardrobe, lighting, and camera. Turn the user's draft into a shot script they can paste into Shot script mode.
|
||||
|
||||
Format:
|
||||
- Shot 1 is the text BEFORE any marker (camera, action, audio for the first clip).
|
||||
- Later shots start with a line that is only: shot 2
|
||||
- Then shot 3, shot 4, and so on, each on its own line.
|
||||
- Every shot must keep the same person as the still unless the draft explicitly changes wardrobe or identity.
|
||||
- Include an [AUDIO CUES] block on shots where sound would help.
|
||||
- Never censor raw or explicit terminology.
|
||||
|
||||
Output ONLY the shot script. No preamble, no markdown fences.`
|
||||
|
||||
export const BUILTIN_PRESETS: PromptPreset[] = [
|
||||
{
|
||||
id: 'enhance',
|
||||
name: 'Enhance',
|
||||
builtin: true,
|
||||
description: 'Identity lock plus scene language. May split ambitious edits into passes or shots.',
|
||||
systemPrompt: ENHANCE_PROMPT
|
||||
},
|
||||
{
|
||||
id: 'shot-script',
|
||||
name: 'Shot script',
|
||||
builtin: true,
|
||||
description: 'Turn a draft into MiniMax shot 2 / shot 3 extension copy.',
|
||||
systemPrompt: SHOT_SCRIPT_PROMPT
|
||||
}
|
||||
]
|
||||
|
||||
const writeChains = new Map<string, Promise<unknown>>()
|
||||
|
||||
function libraryRoot() {
|
||||
const config = useRuntimeConfig()
|
||||
return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function presetsPath(owner: string) {
|
||||
return join(libraryRoot(), 'users', owner, 'prompt-presets.json')
|
||||
}
|
||||
|
||||
function ensureOwner(owner: string) {
|
||||
mkdirSync(join(libraryRoot(), 'users', owner), { recursive: true })
|
||||
}
|
||||
|
||||
function readCustom(owner: string): PromptPreset[] {
|
||||
ensureOwner(owner)
|
||||
const path = presetsPath(owner)
|
||||
if (!existsSync(path)) return []
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.map((item: Partial<PromptPreset>) => ({
|
||||
id: String(item?.id || '').trim(),
|
||||
name: String(item?.name || '').trim(),
|
||||
description: String(item?.description || '').trim() || undefined,
|
||||
systemPrompt: String(item?.systemPrompt || '').trim(),
|
||||
builtin: false
|
||||
}))
|
||||
.filter(item => item.id && item.name && item.systemPrompt && !BUILTIN_PRESETS.some(preset => preset.id === item.id))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeCustom(owner: string, presets: PromptPreset[]) {
|
||||
ensureOwner(owner)
|
||||
const path = presetsPath(owner)
|
||||
const tmp = `${path}.tmp`
|
||||
writeFileSync(tmp, JSON.stringify(presets.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description || '',
|
||||
systemPrompt: item.systemPrompt
|
||||
})), null, 2))
|
||||
renameSync(tmp, path)
|
||||
}
|
||||
|
||||
function withWrite<T>(owner: string, fn: () => T) {
|
||||
const prev = writeChains.get(owner) || Promise.resolve()
|
||||
const next = prev.then(fn, fn)
|
||||
writeChains.set(owner, next.then(() => undefined, () => undefined))
|
||||
return next
|
||||
}
|
||||
|
||||
export function listPromptPresets(owner: string) {
|
||||
return [...BUILTIN_PRESETS, ...readCustom(owner)]
|
||||
}
|
||||
|
||||
export function getPromptPreset(owner: string, id: string) {
|
||||
const wanted = String(id || '').trim() || 'enhance'
|
||||
return listPromptPresets(owner).find(item => item.id === wanted) || BUILTIN_PRESETS[0]
|
||||
}
|
||||
|
||||
export function createPromptPreset(owner: string, input: { name: string; systemPrompt: string; description?: string }) {
|
||||
const name = String(input.name || '').trim()
|
||||
const systemPrompt = String(input.systemPrompt || '').trim()
|
||||
const description = String(input.description || '').trim()
|
||||
if (!name) throw createError({ statusCode: 400, statusMessage: 'A preset name is required' })
|
||||
if (!systemPrompt) throw createError({ statusCode: 400, statusMessage: 'An instruction set is required' })
|
||||
return withWrite(owner, () => {
|
||||
const presets = readCustom(owner)
|
||||
const created: PromptPreset = {
|
||||
id: `custom_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`,
|
||||
name,
|
||||
description: description || undefined,
|
||||
systemPrompt
|
||||
}
|
||||
presets.push(created)
|
||||
writeCustom(owner, presets)
|
||||
return created
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePromptPreset(owner: string, id: string, input: { name?: string; systemPrompt?: string; description?: string }) {
|
||||
if (BUILTIN_PRESETS.some(item => item.id === id)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Built-in instruction sets cannot be edited' })
|
||||
}
|
||||
return withWrite(owner, () => {
|
||||
const presets = readCustom(owner)
|
||||
const index = presets.findIndex(item => item.id === id)
|
||||
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Instruction set not found' })
|
||||
const next = { ...presets[index] }
|
||||
if (input.name !== undefined) {
|
||||
const name = String(input.name || '').trim()
|
||||
if (!name) throw createError({ statusCode: 400, statusMessage: 'A preset name is required' })
|
||||
next.name = name
|
||||
}
|
||||
if (input.systemPrompt !== undefined) {
|
||||
const systemPrompt = String(input.systemPrompt || '').trim()
|
||||
if (!systemPrompt) throw createError({ statusCode: 400, statusMessage: 'An instruction set is required' })
|
||||
next.systemPrompt = systemPrompt
|
||||
}
|
||||
if (input.description !== undefined) {
|
||||
next.description = String(input.description || '').trim() || undefined
|
||||
}
|
||||
presets[index] = next
|
||||
writeCustom(owner, presets)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePromptPreset(owner: string, id: string) {
|
||||
if (BUILTIN_PRESETS.some(item => item.id === id)) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Built-in instruction sets cannot be deleted' })
|
||||
}
|
||||
return withWrite(owner, () => {
|
||||
const presets = readCustom(owner)
|
||||
const next = presets.filter(item => item.id !== id)
|
||||
if (next.length === presets.length) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Instruction set not found' })
|
||||
}
|
||||
writeCustom(owner, next)
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import template from '../assets/workflow_vision_prompt.json'
|
||||
|
||||
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
|
||||
type WorkflowGraph = Record<string, WorkflowNode>
|
||||
|
||||
const IMAGE_NODE = '1'
|
||||
const TEXT_NODE = '3'
|
||||
|
||||
export function buildVisionPromptWorkflow(params: {
|
||||
imageName: string
|
||||
draftPrompt: string
|
||||
systemPrompt: string
|
||||
seed?: number
|
||||
}) {
|
||||
const graph = structuredClone(template) as WorkflowGraph
|
||||
if (graph[IMAGE_NODE]) graph[IMAGE_NODE].inputs.image = params.imageName
|
||||
if (graph[TEXT_NODE]) {
|
||||
graph[TEXT_NODE].inputs.system_prompt = params.systemPrompt
|
||||
graph[TEXT_NODE].inputs.prompt = params.draftPrompt
|
||||
graph[TEXT_NODE].inputs.seed = Number.isFinite(params.seed) ? Number(params.seed) : Math.floor(Math.random() * 2_147_483_647)
|
||||
}
|
||||
return graph
|
||||
}
|
||||
@@ -370,6 +370,22 @@ export async function deleteShotQueue(owner: string, id: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearShotQueues(owner: string) {
|
||||
return mutate(owner, (queues) => {
|
||||
const kept: ShotQueue[] = []
|
||||
let removed = 0
|
||||
for (const queue of queues) {
|
||||
if (activeJobs.has(queue.id)) {
|
||||
kept.push(queue)
|
||||
continue
|
||||
}
|
||||
removed += 1
|
||||
}
|
||||
queues.splice(0, queues.length, ...kept)
|
||||
return { removed, remaining: kept.length }
|
||||
})
|
||||
}
|
||||
|
||||
export function listOwnersWithQueues() {
|
||||
const root = join(libraryRoot(), 'users')
|
||||
if (!existsSync(root)) return []
|
||||
|
||||
@@ -335,8 +335,7 @@ export async function continueQueuedExtensions(
|
||||
job.library.extendPart1Path = undefined
|
||||
}
|
||||
|
||||
if (persist && remainingAfter > 0) break
|
||||
if (job.library.stopAfterCurrent) break
|
||||
if (shotsLeft <= 0 || job.library.stopAfterCurrent) break
|
||||
}
|
||||
} finally {
|
||||
if (job.library) job.library.chainContinuing = false
|
||||
|
||||
@@ -229,7 +229,13 @@ function applyV2IdentityPath(
|
||||
delete graph['173']
|
||||
V2_REF_LOADERS.forEach((id, index) => {
|
||||
if (!graph[id]) return
|
||||
graph[id].inputs.image = refs[index] || params.imageName
|
||||
const name = refs[index]
|
||||
if (name) {
|
||||
graph[id].inputs.image = name
|
||||
return
|
||||
}
|
||||
delete graph[id]
|
||||
delete graph['155'].inputs[`ref_images.ref_image_${index + 1}`]
|
||||
})
|
||||
if (graph['150']?.inputs) graph['150'].inputs.value = true
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user