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>
228 lines
7.8 KiB
TypeScript
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 }
|
|
})
|
|
}
|