Files
TowstyandCursor 40cd17f35c Add MiniMax and PinkCherry LTX as pickable video engines, with still or text start.
Shot 1 can run text-to-video; later shots still extend from the last frame. Recommend output is also normalized into shot-script cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 19:38:53 -05:00

228 lines
7.8 KiB
TypeScript

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 SHOT_SCRIPT_FORMAT = `Output ONLY a MiniMax shot list the user can paste into Shot script mode.
Each shot starts with "shot N" on its own line (shot 1, shot 2, shot 3…). Then these three headers, each on its own line:
[SHOT CONFIGURATION]
[SUBJECT DIRECTION & ACTION]
[AUDIO CUES]
Blank line between shots. No preamble, no markdown fences, no commentary.
Do not glue shots into one paragraph. "shot 2" must be a line by itself, never "shot 2:" mid-sentence.
One beat per shot. Later shots: same people, same clothes as now, plus the one new action.
Do not repeat a long identity paragraph on every shot.
Never censor raw or explicit terminology.
Example:
shot 1
[SHOT CONFIGURATION]
- Shot Type: Medium-wide, 50mm, eye-level
- Camera Movement: Slow track from a slight side-front angle
- Lighting: Bright daylight through the trees
- Environment/Scenery: The forest from the still
[SUBJECT DIRECTION & ACTION]
The woman from the still walks the path, taking in the trees.
[AUDIO CUES]
Footsteps on dirt, birds, breeze.
shot 2
[SHOT CONFIGURATION]
- Shot Type: Medium, 50mm, eye-level
- Camera Movement: Hold, then a small pan to her hand
- Lighting: Same daylight
- Environment/Scenery: Same path, same trees
[SUBJECT DIRECTION & ACTION]
She stops and rests her hand on the bark.
[AUDIO CUES]
Soft fabric, breeze, birds.`
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.
- 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.
If Studio mode is image-edit:
- 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>
If Studio mode is video or video-shot-script:
${SHOT_SCRIPT_FORMAT}`
const SHOT_SCRIPT_PROMPT = `You are a MiniMax H3 image-to-video shot-script writer.
Analyze the attached still for wardrobe, lighting, and camera. Turn the user's draft into a shot script.
${SHOT_SCRIPT_FORMAT}`
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 a MiniMax shot list with configuration, action, and audio blocks.',
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 }
})
}