/** * Standing prompt rules — pure, deterministic prompt-fragment scrubbers. * * No I/O, no network. Each helper enforces a "standing rule" that should * hold across every provider prompt: * - proper names get swapped for stable visual descriptors, * - brand tokens get neutralised away, * - identity-drift and audio-source rules get appended verbatim. */ export interface CastDescriptor { name: string; descriptor: string; } /** Escape regex metacharacters so a name is matched literally. */ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Replace each cast `name` with its `descriptor` using word-boundary, * case-sensitive matching. Substrings inside larger words are left intact * (e.g. "Mee" does not clobber "Meera"). */ export function stripProperNames(text: string, cast: CastDescriptor[]): string { let out = text; for (const { name, descriptor } of cast) { if (!name) { continue; } const re = new RegExp(`\\b${escapeRegExp(name)}\\b`, 'g'); out = out.replace(re, descriptor); } return out; } /** * Remove brand tokens (word-boundary, case-insensitive) so the result no * longer contains the brand name. Each token is replaced with a neutral * descriptor and any doubled/leading/trailing whitespace is collapsed. */ export function brandNeutralize(text: string, brands: string[]): string { let out = text; for (const brand of brands) { if (!brand) { continue; } const re = new RegExp(`\\b${escapeRegExp(brand)}\\b`, 'gi'); out = out.replace(re, 'generic unbranded'); } return out.replace(/\s{2,}/g, ' ').trim(); } /** Standing rule: forbid identity drift / face morphing across frames. */ export function noFaceMorphTag(): string { return 'no face morphing, no identity drift — keep facial features stable across all frames'; } /** Standing rule: diegetic audio only (no scored/added music or VO). */ export function diegeticAudioLine(): string { return 'Audio: Diegetic sound only — natural ambience, environmental foley, and subject-driven sound.'; } const NEGATIVE_TO_POSITIVE: ReadonlyArray<[RegExp, string]> = [ [/no identity drift\.?/gi, 'face, hair, wardrobe, and silhouette stay identical throughout.'], [/no face morphing\.?/gi, 'facial features stay stable across all frames.'], [/don'?t move (the )?feet\.?/gi, 'boots stay planted on the same ground marks.'], ]; /** Rewrite known prohibitions into positive positional/behavioral locks. */ export function negativeToPositive(text: string): string { let out = text; for (const [re, replacement] of NEGATIVE_TO_POSITIVE) { out = out.replace(re, replacement); } return out.replace(/\s{2,}/g, ' ').trim(); } const REPASTE_BLOCK_MARKER = 'Continuity descriptors —'; /** * StoryCraft anti-drift rule: rather than referring back to earlier scenes * ("the same woman as before"), re-state the FULL cast + setting + prop * descriptions verbatim in every scene prompt so the model never has to recall * across generations. * * Pure + idempotent: if the descriptor block (identified by `REPASTE_BLOCK_MARKER`) * is already present in `prompt`, the prompt is returned unchanged. When no * descriptors are supplied, the prompt is returned unchanged. */ export function repasteContinuityDescriptors( prompt: string, descriptors: { cast?: string[]; settings?: string[]; props?: string[] }, ): string { const segments: string[] = []; const cast = (descriptors.cast ?? []).map((value) => value.trim()).filter(Boolean); const settings = (descriptors.settings ?? []).map((value) => value.trim()).filter(Boolean); const props = (descriptors.props ?? []).map((value) => value.trim()).filter(Boolean); if (cast.length > 0) segments.push(`Cast: ${cast.join('; ')}`); if (settings.length > 0) segments.push(`Settings: ${settings.join('; ')}`); if (props.length > 0) segments.push(`Props: ${props.join('; ')}`); if (segments.length === 0) return prompt; // Idempotent: never double-inject. We key off the stable marker rather than // the full block so a previously-injected (possibly differently-ordered) // block is still recognized. if (prompt.includes(REPASTE_BLOCK_MARKER)) return prompt; const block = `${REPASTE_BLOCK_MARKER} ${segments.join('. ')}.`; const trimmed = prompt.trim(); return trimmed ? `${block}\n\n${trimmed}` : block; } /** * The three standing CRITICAL RULES for the motion-overlay composer. They are * always emitted verbatim at the top of every composed Omni prompt: * * 1. AUDIO — pass the original voiceover through untouched; only the visual * layer receives new content. * 2. NO METADATA ON SCREEN — never render px/ms/hex/font-name/easing/stroke as * visible text; ONLY text inside quotation marks in the SHOTS section * renders (a hard-won guard against Omni leaking styling notes on screen). * 3. TEXT ONLY, NO PORTRAITS — no human silhouettes/portrait outlines/avatar * icons (a policy-filter guardrail). The avatar-host layout's separately * generated host base is exempt; the overlay prompt still forbids drawn * portraits. */ export const MOTION_OVERLAY_CRITICAL_RULES = { audio: 'CRITICAL RULE 1 — AUDIO: Do not transcribe, translate, regenerate, dub, or modify the audio in any way. The original voiceover must be passed through unchanged to the output. Only the visual layer receives new content.', noMetadata: 'CRITICAL RULE 2 — NO METADATA ON SCREEN: This prompt contains internal styling notes. Under no circumstances should any of the following appear as visible text in the output video: pixel sizes, font weights or family names, colour codes, easing curve names, durations, frame rates, stroke widths, or any other technical specification. ONLY render text that is explicitly placed inside quotation marks in the SHOTS section below.', noPortraits: 'CRITICAL RULE 3 — TEXT ONLY, NO PORTRAITS: Do not generate human silhouettes, portrait outlines, avatar icons, or any depiction of a person. When the speech mentions a person, render their name as pure centred typography, like a directory listing or a credit roll — never a human figure.', } as const; /** * The three motion-overlay CRITICAL RULE strings, in order (audio, no-metadata, * no-portraits). Pure — `compose-prompt` reuses this so the rules live in one * place. Returns a fresh array each call. */ export function motionOverlayCriticalRules(): string[] { return [ MOTION_OVERLAY_CRITICAL_RULES.audio, MOTION_OVERLAY_CRITICAL_RULES.noMetadata, MOTION_OVERLAY_CRITICAL_RULES.noPortraits, ]; } /** One resolved asset tag: the descriptor substituted into prompt text, and an optional reference to wire. */ export interface AssetTagEntry { descriptor: string; referencePath?: string; } /** name (lowercased) -> entry. */ export type AssetTagLookup = Map; export interface ResolveAssetTagsResult { /** Prompt text with @Name tokens replaced by descriptors (or the bare word if unresolved). */ text: string; /** References to wire, dedup, first-appearance order. */ referencedPaths: string[]; /** Tag names with no lookup entry (caller warns; never fatal). */ unresolved: string[]; } // @Name: starts with a letter, then letters/digits/underscore/hyphen. const ASSET_TAG_RE = /@([A-Za-z][\w-]*)/g; // Reserved: `@imageN`/`@videoN`/`@audioN` are positional reference-binding // contracts (see seedance-blocks.ts; and Dreamina Omni Reference, where the // prompt MUST reference each omni slot as @imageN/@videoN/@audioN) — NOT named // character tags. Leave these untouched so per-line voice tags (`@video1`) survive. const RESERVED_SLOT_RE = /^(image|video|audio)\d+$/i; // Reserved: Google Flow inline @-markers (useapi blog 260609) — @character_1..7, // @referenceImage_1..7, @referenceAudio_1..5 (POST /videos) and @reference_1..10 // (POST /images) anchor a body slot to a prompt position; case-insensitive. // `referencevideo_1` deliberately does NOT match — V2V has no inline marker and // stays flag-only (--ref-video). See src/video/flow-markers.ts. const RESERVED_FLOW_MARKER_RE = /^(character|referenceimage|referenceaudio|reference)_\d+$/i; /** * Resolve `@Name` tags in a prompt. Each tag is replaced with the looked-up * visual descriptor and its reference (if any) is collected; an unresolved tag * is replaced with the bare word and recorded in `unresolved`. Pure — runs * BEFORE stripProperNames so any residual proper names still get scrubbed. */ export function resolveAssetTags(text: string, lookup: AssetTagLookup): ResolveAssetTagsResult { const referencedPaths: string[] = []; const seenRefs = new Set(); const unresolved: string[] = []; const out = text.replace(ASSET_TAG_RE, (match, name: string) => { if (RESERVED_SLOT_RE.test(name) || RESERVED_FLOW_MARKER_RE.test(name)) { return match; // @imageN positional binding / Flow inline marker — preserve verbatim } const entry = lookup.get(name.toLowerCase()); if (!entry) { unresolved.push(name); return name; // strip the @, keep the bare word } if (entry.referencePath && !seenRefs.has(entry.referencePath)) { seenRefs.add(entry.referencePath); referencedPaths.push(entry.referencePath); } return entry.descriptor; }); return { text: out, referencedPaths, unresolved }; }