/** * compose-prompt — THE IP. * * Pure function that assembles the full Omni prompt for a single take from: * take/split + take-relative segments + layout + resolved style + analysis + * frame observations + language + duration. * * Structure (ported from the source skill's SKELETON): * CRITICAL RULE 1/2/3 (from prompt-rules) * TASK + LAYOUT block (per layout: split / overlay / motion-only / avatar-host) * VISUAL block (from resolved style) * MOTION block (from resolved style) * SHOTS (one shot per take-relative segment; only quoted text renders) * GLOBAL RULES (layout-specific untouchable zones + language + audio) * * INVARIANTS (tested): * - Timecodes are rebased to the clip's 0:00 (absolute − take.start). * - The output contains NO leaked styling metadata: no `\d+px`, no `\d+ms`, no * hex colour, no easing-curve name, no font-family name. The accent is * rendered as a HUMAN COLOUR NAME, never a hex string. * * Pure: no I/O. Deterministic. */ import type { FrameObservations, MotionLayout, ResolvedStyle, TakeAnalysis, TakeSplit, TranscriptSegment, } from './types.js'; import { motionOverlayCriticalRules } from '../prompt-rules.js'; export interface ComposePromptArgs { /** Take index (0-based). */ index: number; /** The take's span + which segments it covers. */ split: TakeSplit; /** Full transcript segments (absolute timing); rebased here. */ segments: TranscriptSegment[]; layout: MotionLayout; style: ResolvedStyle; analysis: TakeAnalysis; frames?: FrameObservations; /** On-screen text language (human-readable, e.g. "Portuguese"). */ language: string; /** Take duration in seconds (for the TASK line). */ durationSeconds: number; } /** Map a hex accent to a human colour NAME so no hex leaks into the prompt. */ export function accentColourName(hex: string): string { const h = hex.trim().replace(/^#/, '').toLowerCase(); if (!/^[0-9a-f]{6}$/.test(h)) { // Already a name (or malformed) — pass through trimmed, never a hex token. return hex.trim().replace(/^#/, '') || 'the accent colour'; } const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return rgbToName(r, g, b); } function rgbToName(r: number, g: number, b: number): string { const max = Math.max(r, g, b); const min = Math.min(r, g, b); if (max - min < 24) { if (max > 210) return 'soft white'; if (max < 48) return 'near-black'; return 'neutral grey'; } // Hue in degrees. const rf = r / 255; const gf = g / 255; const bf = b / 255; const mx = Math.max(rf, gf, bf); const mn = Math.min(rf, gf, bf); const d = mx - mn; let hue = 0; if (mx === rf) hue = ((gf - bf) / d) % 6; else if (mx === gf) hue = (bf - rf) / d + 2; else hue = (rf - gf) / d + 4; hue = Math.round(hue * 60); if (hue < 0) hue += 360; if (hue < 15 || hue >= 345) return 'vivid red'; if (hue < 45) return 'vivid orange'; if (hue < 70) return 'warm yellow'; if (hue < 160) return 'fresh green'; if (hue < 200) return 'cyan teal'; if (hue < 255) return 'electric blue'; if (hue < 290) return 'soft lavender'; if (hue < 345) return 'magenta pink'; return 'the accent colour'; } function fmtTime(seconds: number): string { const clamped = Math.max(0, Math.round(seconds * 10) / 10); return `${clamped.toFixed(1)}s`; } /** Build the layout-specific TASK + LAYOUT block (genericized 9:16/aspect-neutral). */ function taskLayoutBlock(layout: MotionLayout, durationSeconds: number, frames?: FrameObservations): string { const dur = Math.round(durationSeconds); const side = frames?.speakerSide ?? 'one side'; switch (layout) { case 'split': return [ `TASK: Add motion graphics to the UPPER HALF of this ${dur}-second vertical clip. The footage already has a solid black upper half and the speaker composited in the lower half. ALL motion graphics live in the upper black half only. The lower half — the speaker, their clothing, the background, and any visible elements — must remain completely untouched.`, 'LAYOUT:', '- Top half of frame: motion stage. All overlays live here.', '- Bottom half of frame: speaker untouched, passed through as-is.', ].join('\n'); case 'overlay': return [ `TASK: Add motion graphics composited OVER this ${dur}-second clip. The speaker is in the frame and visible — overlays sit ON TOP of the footage without ever covering the speaker's face, hands, or gestures.`, 'LAYOUT:', `- The speaker is positioned at the ${side} of the frame.`, '- Motion stage: the opposite side and any plain background area. Headlines occupy the upper portion when the speaker is lower, or the side opposite the speaker.', '- Keep the top and bottom UI safe areas clear of critical text.', ].join('\n'); case 'motion-only': return [ `TASK: The speaker does NOT appear visually in the output of this ${dur}-second clip — only their voice is preserved (CRITICAL RULE 1). Replace the entire frame with full-frame motion graphics that tell the story visually from the spoken script, like a premium keynote slide or a product launch reveal.`, 'LAYOUT:', '- Full frame: motion stage. Use the entire canvas.', '- No speaker, no source footage visible. The original video is discarded as the visual base; only its audio is retained.', '- Hero shots, fullscreen headline anchors, and cinematic editorial transitions are encouraged.', '- Density should be HIGHER than the split layout, since the whole frame is yours to compose.', ].join('\n'); case 'avatar-host': return [ `TASK: This ${dur}-second clip is fronted by an identity-locked host character delivering the lines (generated separately as the base layer). Add full-frame motion-graphics overlays ON TOP of the host, synced to the speech, without covering the host's face.`, 'LAYOUT:', '- Host base layer: the locked character, untouched.', '- Motion stage: the frame around and above the host — headlines and cards sit in the plainest areas, never over the face.', '- Do NOT draw any additional human figures, silhouettes, or portraits; the host is the only person and is supplied as the base layer.', ].join('\n'); default: return `TASK: Add motion graphics to this ${dur}-second clip.`; } } /** Layout-specific GLOBAL RULES additions. */ function globalRuleAdditions(layout: MotionLayout): string[] { switch (layout) { case 'split': return ['Do not place anything in the lower half of the frame; the speaker stays pristine.']; case 'overlay': return [ 'Never cover the speaker\'s face, hands, or visible gestures.', 'Overlays primarily sit in the plainest background areas (wall, sky, blurred space).', ]; case 'motion-only': return [ 'The speaker does not appear visually at any point.', 'Treat each take as a slide in a premium keynote: one dominant element at a time, with cinematic transitions.', ]; case 'avatar-host': return [ 'The host character is the only person on screen and is supplied as the base layer — never draw another figure.', 'Never cover the host\'s face.', ]; default: return []; } } /** Compose the SHOTS section from take-relative segments (only quoted text renders). */ function shotsBlock(args: ComposePromptArgs): string { const lines: string[] = [ 'SHOTS — render only the text shown in quotation marks below. Times are relative to this clip\'s 0:00.', ]; const { split, segments, analysis } = args; // Anchor words keyed by the segment they fall in (by relative time) drive the // "giant headline" cue; here we surface them per shot when their time lands in // the segment's relative window. split.segmentIndices.forEach((segIndex, ordinal) => { const seg = segments[segIndex]; if (!seg) { return; } const relStart = Math.max(0, seg.start - split.start); const relEnd = Math.max(relStart, seg.end - split.start); const text = seg.text.trim(); const anchorsHere = analysis.anchorWords.filter((a) => a.relativeTime >= relStart - 0.01 && a.relativeTime <= relEnd + 0.01); const anchorCue = anchorsHere.length > 0 ? ` Emphasize the anchor ${anchorsHere.map((a) => `"${a.word}"`).join(', ')} as a giant headline synced to the voice.` : ''; lines.push( `SHOT ${ordinal + 1} — ${fmtTime(relStart)} to ${fmtTime(relEnd)}` + `\n Animate the meaning of this line in the chosen style, holding the frame still during any pause.${anchorCue}` + `\n Render on screen: "${text}"`, ); // Held-pause cue immediately after this segment, if one starts at relEnd. const pause = analysis.pauses.find((p) => Math.abs(p.start - relEnd) < 0.05); if (pause) { lines.push(` (Pause ${fmtTime(pause.start)} to ${fmtTime(pause.end)}: hold the current state silent and still — no new animation.)`); } }); return lines.join('\n'); } /** * Compose the full Omni prompt for one take. Pure. Output is guaranteed free of * leaked styling metadata (see module-level invariants + the compose test). */ export function composePrompt(args: ComposePromptArgs): string { const { layout, style, analysis, language, durationSeconds, frames } = args; const accentName = accentColourName(style.accent); const parts: string[] = []; // CRITICAL RULES (verbatim, from prompt-rules). parts.push(motionOverlayCriticalRules().join('\n\n')); // TASK + LAYOUT. parts.push(taskLayoutBlock(layout, durationSeconds, frames)); // Anatomy hint — calibrates visual load. parts.push(`REEL POSITION: this take is the "${analysis.anatomy}" beat of the reel — calibrate visual load accordingly.`); // VISUAL + MOTION from style, with the accent expressed as a human colour name. parts.push(style.visualBlock.replace(/the configured accent/g, `a ${accentName} accent`)); parts.push(style.motionBlock); // SHOTS. parts.push(shotsBlock(args)); // GLOBAL RULES. const globals: string[] = [ ...globalRuleAdditions(layout), `On-screen text language: ${language}, exactly as written inside the quotation marks.`, 'Audio: do not modify — the original voiceover passes through unchanged.', `Output: a single video, original as base layer (or fully replaced for the motion-only layout), overlays composited per the LAYOUT rules.`, ]; if (frames?.notes && frames.notes.length > 0) { for (const note of frames.notes) { globals.push(note); } } parts.push(['GLOBAL RULES:', ...globals.map((g) => `- ${g}`)].join('\n')); return parts.join('\n\n'); }