@viralspin/mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/BUNDLE_LOCAL_SPEC.md +133 -0
  2. package/dist/client.js +47 -9
  3. package/dist/index.js +83 -1
  4. package/dist/tools/assembly-adjust.js +102 -0
  5. package/dist/tools/carousel-video.js +190 -0
  6. package/dist/tools/engage.js +88 -0
  7. package/dist/tools/explainer-index.js +301 -0
  8. package/dist/tools/image-ads.js +156 -0
  9. package/dist/tools/kinetic-text.js +118 -0
  10. package/dist/tools/photo-picker.js +31 -0
  11. package/dist/tools/publish.js +72 -1
  12. package/dist/tools/review.js +103 -0
  13. package/dist/tools/video-assembly.js +183 -0
  14. package/engine/assemble.mjs +476 -0
  15. package/engine/gemini.mjs +164 -0
  16. package/engine/kinetic.mjs +84 -0
  17. package/engine/prompts.mjs +94 -0
  18. package/engine/remotion.config.ts +4 -0
  19. package/engine/src/Assembly.tsx +330 -0
  20. package/engine/src/KineticText.tsx +253 -0
  21. package/engine/src/Root.tsx +39 -0
  22. package/engine/src/index.ts +4 -0
  23. package/engine/tsconfig.json +15 -0
  24. package/package.json +29 -29
  25. package/src/client.ts +119 -80
  26. package/src/index.ts +151 -60
  27. package/src/tools/assembly-adjust.ts +110 -0
  28. package/src/tools/captions.ts +32 -32
  29. package/src/tools/carousel-video.ts +211 -0
  30. package/src/tools/connections.ts +27 -27
  31. package/src/tools/engage.ts +105 -0
  32. package/src/tools/explainer-index.ts +298 -0
  33. package/src/tools/image-ads.ts +192 -0
  34. package/src/tools/kinetic-text.ts +130 -0
  35. package/src/tools/photo-picker.ts +31 -0
  36. package/src/tools/posts.ts +58 -58
  37. package/src/tools/publish.ts +236 -158
  38. package/src/tools/review.ts +126 -0
  39. package/src/tools/video-assembly.ts +205 -0
  40. package/tsconfig.json +15 -15
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Kinetic text renderer — no A-roll, no B-roll, no transcription.
4
+ * node kinetic.mjs --text "..." [--effect typing|billboard_flip|isometric]
5
+ * [--style terminal|cinematic|minimal] [--format 9x16|16x9|1x1]
6
+ * [--font-size <px>] [--duration-sec <n>]
7
+ * [--camera-rx <deg>] [--camera-ry <deg>] [--camera-rz <deg>] [--camera-tz <px>]
8
+ * [--out <path>]
9
+ * Writes props JSON, calls Remotion render for the KineticText composition, prints RESULT:<path>.
10
+ */
11
+ import { fileURLToPath } from "node:url";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { spawnSync } from "node:child_process";
15
+
16
+ const ENGINE = path.dirname(fileURLToPath(import.meta.url));
17
+ const PUBLIC = path.join(ENGINE, "public");
18
+ const OUT_DIR = path.join(ENGINE, "out");
19
+
20
+ fs.mkdirSync(PUBLIC, { recursive: true });
21
+ fs.mkdirSync(OUT_DIR, { recursive: true });
22
+
23
+ const argv = process.argv.slice(2);
24
+ const one = (flag, def) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? argv[i + 1] : def; };
25
+
26
+ const text = one("--text", "");
27
+ if (!text) { console.error("ERROR: --text is required"); process.exit(1); }
28
+
29
+ const effect = one("--effect", "typing");
30
+ const style = one("--style", "terminal");
31
+ const format = one("--format", "9x16");
32
+ const fontSizeArg = one("--font-size", null);
33
+ const durationSecArg = one("--duration-sec", null);
34
+ const cameraRx = parseFloat(one("--camera-rx", null) ?? "NaN");
35
+ const cameraRy = parseFloat(one("--camera-ry", null) ?? "NaN");
36
+ const cameraRz = parseFloat(one("--camera-rz", null) ?? "NaN");
37
+ const cameraTz = parseFloat(one("--camera-tz", null) ?? "NaN");
38
+ const outArg = one("--out", null);
39
+
40
+ const fps = 30;
41
+
42
+ // Auto-size: ~3 frames per char + 45 frame tail (1.5s hold at end)
43
+ const totalFrames = durationSecArg
44
+ ? Math.round(parseFloat(durationSecArg) * fps)
45
+ : Math.max(60, Math.ceil(text.length * 3 + 45));
46
+
47
+ // Default font size by format
48
+ const defaultFontSize = format === "16x9" ? 96 : 72;
49
+ const fontSize = fontSizeArg ? parseInt(fontSizeArg, 10) : defaultFontSize;
50
+
51
+ // Per-effect camera defaults
52
+ const CAMERA_DEFAULTS = {
53
+ typing: { rotateX: 8, rotateY: 0, rotateZ: 0, translateZ: -120 },
54
+ billboard_flip: { rotateX: 0, rotateY: 0, rotateZ: 0, translateZ: 0 },
55
+ isometric: { rotateX: 55, rotateY: 0, rotateZ: -45, translateZ: -80 },
56
+ };
57
+ const defaults = CAMERA_DEFAULTS[effect] ?? CAMERA_DEFAULTS.typing;
58
+
59
+ const camera = {
60
+ rotateX: isNaN(cameraRx) ? defaults.rotateX : cameraRx,
61
+ rotateY: isNaN(cameraRy) ? defaults.rotateY : cameraRy,
62
+ rotateZ: isNaN(cameraRz) ? defaults.rotateZ : cameraRz,
63
+ translateZ: isNaN(cameraTz) ? defaults.translateZ : cameraTz,
64
+ };
65
+
66
+ const timestamp = Date.now();
67
+ const out = path.resolve(outArg ?? path.join(OUT_DIR, `kinetic_${timestamp}.mp4`));
68
+ fs.mkdirSync(path.dirname(out), { recursive: true });
69
+
70
+ const props = { text, effect, style, fontSize, format, fps, totalFrames, camera };
71
+ const propsFile = path.join(PUBLIC, "kineticProps.json");
72
+ fs.writeFileSync(propsFile, JSON.stringify(props, null, 2));
73
+
74
+ process.stderr.write(`[kinetic] text="${text.slice(0, 60)}" effect=${effect} style=${style} format=${format} frames=${totalFrames}\n`);
75
+
76
+ const entry = path.join(ENGINE, "src", "index.ts");
77
+ const cmd = `npx remotion render "${entry}" KineticText "${out}" "--props=${propsFile}" "--public-dir=${PUBLIC}"`;
78
+ const r = spawnSync(cmd, { cwd: ENGINE, shell: true, stdio: "inherit" });
79
+ if (r.status !== 0) {
80
+ console.error(`[kinetic] remotion render failed (exit ${r.status})`);
81
+ process.exit(1);
82
+ }
83
+
84
+ console.log("RESULT:" + out);
@@ -0,0 +1,94 @@
1
+ // "Secret sauce" prompts for the local video-assembly engine.
2
+ //
3
+ // These BUILTIN strings are the editable source of truth surfaced in the admin
4
+ // dashboard LLM Prompt Store under the "LOCAL VIDEO ASSEMBLY" section
5
+ // (keys local_assembly.*). At runtime the MCP tool fetches the active rows from
6
+ // the backend and hands them to the engine in the VIRALSPIN_ASSEMBLY_PROMPTS env
7
+ // var (a JSON map key→value_text). Anything missing, blank, or malformed falls
8
+ // back to the BUILTIN below, so an offline run — or a misnamed/deactivated row —
9
+ // still produces a video.
10
+ //
11
+ // Templated prompts use {{double_brace}} tokens (filled by fill() in the engine)
12
+ // so the literal single-brace JSON in their output spec is never disturbed.
13
+
14
+ export const BUILTIN = {
15
+ "local_assembly.transcribe":
16
+ "Transcribe this short spoken clip accurately (a creator talking to camera). " +
17
+ 'Return ONLY JSON: {"segments":[{"start_ms":int,"end_ms":int,"text":str}]}. ' +
18
+ "Break into natural phrases. Best-guess unclear words; do not invent content. " +
19
+ "ALSO include significant NON-SPEECH audio as their own segments, with the sound in parentheses — " +
20
+ "e.g. (coughing), (laughing), (sighing), (music), (silence). These give the editor context.",
21
+
22
+ "local_assembly.frame_analysis":
23
+ "Framing a vertical 9:16 social video from this (often horizontal) source. Return ONLY JSON: " +
24
+ '{"subject":"person|product|scenery","rotate":0,"focal_x":0-1,"focal_y":0-1,' +
25
+ '"secondary_x":0-1|null,"secondary_y":0-1|null,"desc":"<=8 words","keywords":["..."]}. ' +
26
+ "rotate = CLOCKWISE degrees (0/90/180/270) to make it UPRIGHT (0 if already upright); give focal/secondary " +
27
+ "in coords AFTER that rotation. focal_x/y = center of the MAIN subject (a person's FACE) to keep in a tall 9:16 " +
28
+ "crop. secondary_x/y = a second notable element to reveal with a pan (sign/text/landmark/product) or null. " +
29
+ "keywords = 2-4 concrete nouns/themes.",
30
+
31
+ "local_assembly.album_ranking":
32
+ `A short video's narration says:\n"{{transcript_text}}"\n\nWe have these B-roll ALBUMS (folder name + file count):\n` +
33
+ `{{albums_json}}\n\nRank how relevant each album is as B-roll for this narration, judging mainly by NAME. ` +
34
+ 'Return ONLY JSON: {"weights":{"<album>":0.1-1.0},"note":"<one sentence>"}. Every album gets a weight.',
35
+
36
+ "local_assembly.style_inference":
37
+ `These are {{frame_count}} sampled frames from a short-form social video (aspect {{format}}, {{duration}}s, ` +
38
+ `~{{cuts_per_sec}} hard scene-cuts/sec). Infer its EDITING STYLE. Return ONLY JSON: ` +
39
+ '{"captions":bool,"hook":bool,"pops":bool,"pace":"fast|medium|slow","coverage":0-1,"note":"..."}. ' +
40
+ "captions=bold spoken-word subtitles burned in? hook=persistent headline/title overlay near top? " +
41
+ "pops=big animated keyword/number callouts? pace=overall energy. coverage=fraction showing B-roll vs talking head.",
42
+
43
+ "local_assembly.timeline_edit":
44
+ `You are a senior short-form editor cutting a HIGH-ENERGY vertical 9:16 Reel (bold captions, hook, pops, quick cuts).\n` +
45
+ `The A-roll is a person talking (their voice is the soundtrack). B-roll is a MIX of (a) GENERIC ` +
46
+ `lifestyle/aspirational footage (beach/travel/product) that does NOT literally depict the topic — ` +
47
+ `energetic punctuation; and (b) the CREATOR'S OWN PERSONAL photos that may genuinely depict the real ` +
48
+ `moments she describes (sick, recovering, hospital, pregnancy/childbirth, family). Match by content.\n\n` +
49
+ `TRANSCRIPT (ms):\n{{transcript_json}}\n\nB-ROLL CANDIDATES:\n{{candidates_json}}\n\n` +
50
+ `TOTAL: {{total_ms}} ms. USER DIRECTION: {{user_direction}}\n\n` +
51
+ `Produce THREE things:\n` +
52
+ `1. "hook": a punchy 4-8 word headline for the first ~3.5s + ONE "keyword" within it to highlight.\n` +
53
+ `2. "pops": 2-4 big keyword/number callouts that punch in ON the beat said. Each {"from_ms":int,"text":"SHORT UPPERCASE"}.\n` +
54
+ `3. "cuts": B-roll cutaways. RULES:\n` +
55
+ ` - PACING: {{pacing}}\n` +
56
+ ` - STRONGLY prefer video-kind assets; alternate dir 0/1. A cutaway must GENUINELY relate to the beat.\n` +
57
+ ` - TWO kinds of B-roll, match each candidate's CONTENT (desc/keywords) to the beat:\n` +
58
+ ` • GENERIC lifestyle/beach/travel/product → ONLY on posting/content/success/going-viral/lifestyle/aspiration lines. NEVER a generic clip on a specific physical-state line (sick, recovering, surgery, giving birth, coughing) — looks broken.\n` +
59
+ ` • The creator's OWN PERSONAL photos that GENUINELY depict the moment (visibly sick/in bed, hospital, pregnant/newborn, recovering, real family) → DO use these ON the matching personal line (a real sick photo on "you're sick, recovering" is GREAT; a beach photo is not).\n` +
60
+ ` - KEEP THE SPEAKER (no cutaway) on: non-speech beats like "(coughing)", the final line, abstract lines, and ANY beat where NO candidate (generic OR personal) genuinely fits. Staying on the speaker beats an irrelevant clip; don't force a cutaway to fill time.\n` +
61
+ ` - The clip opens with a COUGH before she speaks — keep the speaker there.\n` +
62
+ ` - Each broll_id at most twice; no overlaps; within total duration.\n` +
63
+ ` Each cut: {"broll_id":str,"from_ms":int,"dur_ms":int,"dir":0,"video":bool}.\n\n` +
64
+ `Return ONLY JSON: {"hook":{"text":str,"keyword":str},"pops":[...],"cuts":[...]}`,
65
+ };
66
+
67
+ let _overrides = null;
68
+ /** Parse VIRALSPIN_ASSEMBLY_PROMPTS once. Any failure → empty (use builtins). */
69
+ function overrides() {
70
+ if (_overrides) return _overrides;
71
+ _overrides = {};
72
+ const raw = process.env.VIRALSPIN_ASSEMBLY_PROMPTS;
73
+ if (raw) {
74
+ try {
75
+ const m = JSON.parse(raw);
76
+ for (const [k, v] of Object.entries(m)) {
77
+ if (typeof v === "string" && v.trim()) _overrides[k] = v;
78
+ }
79
+ } catch { /* malformed override blob → keep builtins */ }
80
+ }
81
+ return _overrides;
82
+ }
83
+
84
+ /** Admin-store override for `key` if present and non-empty, else the builtin. */
85
+ export function prompt(key) {
86
+ return overrides()[key] ?? BUILTIN[key];
87
+ }
88
+
89
+ /** Replace {{token}} placeholders. Missing keys are left untouched. */
90
+ export function fill(template, vars) {
91
+ let out = template;
92
+ for (const [k, v] of Object.entries(vars)) out = out.split(`{{${k}}}`).join(String(v));
93
+ return out;
94
+ }
@@ -0,0 +1,4 @@
1
+ import { Config } from "@remotion/cli/config";
2
+
3
+ Config.setVideoImageFormat("jpeg");
4
+ Config.setOverwriteOutput(true);
@@ -0,0 +1,330 @@
1
+ import React from "react";
2
+ import {
3
+ AbsoluteFill,
4
+ Img,
5
+ OffthreadVideo,
6
+ Sequence,
7
+ interpolate,
8
+ spring,
9
+ staticFile,
10
+ useCurrentFrame,
11
+ useVideoConfig,
12
+ } from "remotion";
13
+
14
+ // ── caption style system ──────────────────────────────────────────────
15
+ export type CaptionStyleId = "default" | "hormozi" | "glow_neon" | "minimalist" | "whiteboard";
16
+
17
+ export const CAPTION_STYLE_LABELS: Record<CaptionStyleId, { label: string; desc: string }> = {
18
+ default: { label: "Bold Pop", desc: "White spring pop-in — clean & universal" },
19
+ hormozi: { label: "Hormozi", desc: "Word-by-word yellow highlight — high-energy talking-head" },
20
+ glow_neon: { label: "Glow Neon", desc: "Neon cyan pulse — dark aesthetic, music, gaming" },
21
+ minimalist: { label: "Minimalist", desc: "Lowercase fade-in — calm, educational, soft aesthetic" },
22
+ whiteboard: { label: "Whiteboard", desc: "Left-to-right reveal — explainers, how-to, tutorials" },
23
+ };
24
+
25
+ // ── editData schema (Remotion inputProps) ─────────────────────────────
26
+ export type Cut = {
27
+ from: number; dur: number; file: string; video?: boolean; dir: 0 | 1;
28
+ focal: [number, number];
29
+ secondary?: [number, number] | null;
30
+ srcW?: number; srcH?: number;
31
+ };
32
+ export type Caption = { from: number; dur: number; text: string; y?: number };
33
+ export type Pop = { from: number; dur: number; text: string };
34
+ export type Hook = { from: number; dur: number; text: string; keyword: string };
35
+ export type EditData = {
36
+ format: "9x16" | "1x1" | "16x9";
37
+ fps: number;
38
+ totalFrames: number;
39
+ aroll: string;
40
+ arollFocal: [number, number];
41
+ cuts: Cut[];
42
+ captions?: Caption[];
43
+ pops?: Pop[];
44
+ hook?: Hook | null;
45
+ captionStyle?: CaptionStyleId;
46
+ };
47
+
48
+ const CAP_FONT = "'Segoe UI', 'Arial Black', Impact, sans-serif";
49
+ const pos = (f: [number, number]) => `${f[0] * 100}% ${f[1] * 100}%`;
50
+
51
+ function arollSegments(cuts: Cut[], total: number) {
52
+ const segs: { from: number; to: number }[] = [];
53
+ let cursor = 0;
54
+ for (const c of [...cuts].sort((a, b) => a.from - b.from)) {
55
+ if (c.from > cursor) segs.push({ from: cursor, to: c.from });
56
+ cursor = Math.max(cursor, c.from + c.dur);
57
+ }
58
+ if (cursor < total) segs.push({ from: cursor, to: total });
59
+ return segs;
60
+ }
61
+
62
+ const ARoll: React.FC<{ data: EditData }> = ({ data }) => {
63
+ const frame = useCurrentFrame();
64
+ const segs = arollSegments(data.cuts, data.totalFrames);
65
+ const idx = segs.findIndex((s) => frame >= s.from && frame < s.to);
66
+ const seg = segs[idx] ?? segs[0] ?? { from: 0, to: data.totalFrames };
67
+ const p = interpolate(frame, [seg.from, seg.to], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
68
+ const scale = idx % 2 === 0 ? 1.0 + 0.07 * p : 1.07 - 0.07 * p;
69
+ const volume = (f: number) => {
70
+ let v = 1;
71
+ for (const c of data.cuts) {
72
+ if (!c.video) continue;
73
+ v = Math.min(v, interpolate(f, [c.from - 8, c.from, c.from + c.dur, c.from + c.dur + 8], [1, 0.85, 0.85, 1],
74
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }));
75
+ }
76
+ return v;
77
+ };
78
+ const o = pos(data.arollFocal);
79
+ return (
80
+ <AbsoluteFill style={{ overflow: "hidden", backgroundColor: "black" }}>
81
+ <div style={{ width: "100%", height: "100%", transform: `scale(${scale})`, transformOrigin: o }}>
82
+ <OffthreadVideo src={staticFile(data.aroll)} volume={volume} style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: o }} />
83
+ </div>
84
+ </AbsoluteFill>
85
+ );
86
+ };
87
+
88
+ // B-roll: fill the frame (no blur bars), and EXPLICITLY position so the subject's focal point
89
+ // maps to frame-center. Default motion pans so the subject ENDS centered; if there's a secondary
90
+ // point of interest, start centered on the subject then pan toward it. Clamped to image edges.
91
+ const PAN = 0.32;
92
+ const ZOOM = 0.1;
93
+ const clampN = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
94
+
95
+ const BrollLayer: React.FC<{ cut: Cut }> = ({ cut }) => {
96
+ const frame = useCurrentFrame();
97
+ const { width, height } = useVideoConfig();
98
+ const isVideo = !!cut.video;
99
+ const p = interpolate(frame, [0, cut.dur], [0, 1], { extrapolateRight: "clamp" });
100
+ const fade = interpolate(frame, [0, isVideo ? 5 : 3], [0, 1], { extrapolateRight: "clamp" });
101
+ const vol = (lf: number) => interpolate(lf, [0, 8, cut.dur - 8, cut.dur], [0, 0.25, 0.25, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
102
+
103
+ const srcW = cut.srcW || width, srcH = cut.srcH || height;
104
+ const cover = Math.max(width / srcW, height / srcH);
105
+ const k = cover * (cut.dir === 0 ? 1 + ZOOM * p : 1 + ZOOM * (1 - p));
106
+ const fgW = srcW * k, fgH = srcH * k;
107
+
108
+ const [fx, fy] = cut.focal;
109
+ const sec = cut.secondary;
110
+ let sx, sy, ex, ey;
111
+ if (sec) { sx = fx; sy = fy; ex = fx + (sec[0] - fx) * PAN; ey = fy + (sec[1] - fy) * PAN * 0.7; }
112
+ else { const d = cut.dir === 0 ? 0.1 : -0.1; sx = clampN(fx + d, 0, 1); sy = fy; ex = fx; ey = fy; }
113
+ const cx = sx + (ex - sx) * p;
114
+ const cy = sy + (ey - sy) * p;
115
+
116
+ const left = clampN(width / 2 - cx * fgW, width - fgW, 0);
117
+ const top = clampN(height / 2 - cy * fgH, height - fgH, 0);
118
+ const style: React.CSSProperties = { width: "100%", height: "100%", objectFit: "cover" };
119
+
120
+ return (
121
+ <AbsoluteFill style={{ opacity: fade, backgroundColor: "black", overflow: "hidden" }}>
122
+ <div style={{ position: "absolute", left, top, width: fgW, height: fgH }}>
123
+ {isVideo
124
+ ? <OffthreadVideo src={staticFile(cut.file)} volume={vol} style={style} />
125
+ : <Img src={staticFile(cut.file)} style={style} />}
126
+ </div>
127
+ </AbsoluteFill>
128
+ );
129
+ };
130
+
131
+ // ── caption line — 5 switchable styles ───────────────────────────────
132
+ const CaptionLine: React.FC<{ text: string; y: number; dur: number; captionStyle: CaptionStyleId }> = ({
133
+ text, y, dur, captionStyle,
134
+ }) => {
135
+ const frame = useCurrentFrame();
136
+ const { fps } = useVideoConfig();
137
+
138
+ // ── Hormozi: word-by-word yellow highlight with black pill ──────────
139
+ if (captionStyle === "hormozi") {
140
+ const words = text.split(/\s+/);
141
+ const activeIdx = Math.min(
142
+ words.length - 1,
143
+ Math.floor(interpolate(frame, [0, Math.max(1, dur)], [0, words.length], { extrapolateRight: "clamp" }))
144
+ );
145
+ const entryScale = 0.88 + 0.12 * spring({ frame, fps, config: { damping: 180, stiffness: 280, mass: 0.3 } });
146
+ return (
147
+ <AbsoluteFill>
148
+ <div style={{
149
+ position: "absolute", top: `${y * 100}%`, left: "50%",
150
+ transform: `translate(-50%, -50%) scale(${entryScale})`,
151
+ display: "flex", flexWrap: "wrap", justifyContent: "center",
152
+ gap: "6px", maxWidth: "88%",
153
+ }}>
154
+ {words.map((w, i) => {
155
+ const active = i === activeIdx;
156
+ return (
157
+ <span key={i} style={{
158
+ fontFamily: "'Arial Black', Impact, sans-serif",
159
+ fontWeight: 900,
160
+ fontSize: active ? 68 : 60,
161
+ color: active ? "#FFE01B" : "white",
162
+ textTransform: "uppercase",
163
+ background: active ? "rgba(0,0,0,0.88)" : "transparent",
164
+ padding: active ? "2px 10px 4px" : "2px 0",
165
+ borderRadius: 8,
166
+ textShadow: active ? "0 2px 0 rgba(0,0,0,0.4)" : "0 2px 10px rgba(0,0,0,.9)",
167
+ WebkitTextStroke: active ? undefined : "1.5px rgba(0,0,0,.5)",
168
+ lineHeight: 1.1,
169
+ }}>{w}</span>
170
+ );
171
+ })}
172
+ </div>
173
+ </AbsoluteFill>
174
+ );
175
+ }
176
+
177
+ // ── Glow Neon: pulsing cyan text shadow ─────────────────────────────
178
+ if (captionStyle === "glow_neon") {
179
+ const sp = spring({ frame, fps, config: { damping: 160, stiffness: 200, mass: 0.5 } });
180
+ const entryScale = 0.84 + 0.16 * sp;
181
+ const glowPulse = 0.7 + 0.3 * Math.sin(frame * 0.25);
182
+ const g = Math.round(12 + 8 * glowPulse);
183
+ return (
184
+ <AbsoluteFill>
185
+ <div style={{
186
+ position: "absolute", top: `${y * 100}%`, left: "50%",
187
+ transform: `translate(-50%, -50%) scale(${entryScale})`,
188
+ maxWidth: "86%", textAlign: "center",
189
+ fontFamily: "'Segoe UI', 'Arial Black', sans-serif",
190
+ fontWeight: 800, fontSize: 58,
191
+ color: "#00f7ff",
192
+ textShadow: `0 0 ${g}px #00f7ff, 0 0 ${g * 2}px rgba(0,247,255,0.5), 0 2px 8px rgba(0,0,0,0.9)`,
193
+ letterSpacing: 1.5, lineHeight: 1.1, textTransform: "uppercase",
194
+ }}>{text}</div>
195
+ </AbsoluteFill>
196
+ );
197
+ }
198
+
199
+ // ── Minimalist: lowercase clean fade ────────────────────────────────
200
+ if (captionStyle === "minimalist") {
201
+ const fadeIn = interpolate(frame, [0, 8], [0, 1], { extrapolateRight: "clamp" });
202
+ const fadeOut = interpolate(frame, [Math.max(1, dur - 6), dur], [1, 0], { extrapolateLeft: "clamp" });
203
+ return (
204
+ <AbsoluteFill>
205
+ <div style={{
206
+ position: "absolute", top: `${y * 100}%`, left: "50%",
207
+ transform: "translate(-50%, -50%)",
208
+ opacity: Math.min(fadeIn, fadeOut),
209
+ maxWidth: "80%", textAlign: "center",
210
+ fontFamily: "'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
211
+ fontWeight: 300, fontSize: 48,
212
+ color: "white",
213
+ textShadow: "0 1px 6px rgba(0,0,0,0.7)",
214
+ letterSpacing: 0.5, lineHeight: 1.15,
215
+ }}>{text.toLowerCase()}</div>
216
+ </AbsoluteFill>
217
+ );
218
+ }
219
+
220
+ // ── Whiteboard: clip-rect left-to-right reveal ──────────────────────
221
+ if (captionStyle === "whiteboard") {
222
+ const sp = spring({ frame, fps, config: { damping: 200, stiffness: 180, mass: 0.5 } });
223
+ const revealRatio = interpolate(frame, [0, Math.max(1, dur * 0.7)], [0, 1], { extrapolateRight: "clamp" });
224
+ const clipRight = Math.round((1 - revealRatio) * 100);
225
+ return (
226
+ <AbsoluteFill>
227
+ <div style={{
228
+ position: "absolute", top: `${y * 100}%`, left: "50%",
229
+ transform: `translate(-50%, -50%) scale(${0.9 + 0.1 * sp})`,
230
+ maxWidth: "84%", textAlign: "center",
231
+ fontFamily: "'Segoe UI', Arial, sans-serif",
232
+ fontWeight: 700, fontSize: 56,
233
+ color: "white",
234
+ textShadow: "0 2px 10px rgba(0,0,0,.85)",
235
+ clipPath: `inset(0 ${clipRight}% 0 0)`,
236
+ letterSpacing: 0.5, lineHeight: 1.1,
237
+ }}>{text}</div>
238
+ </AbsoluteFill>
239
+ );
240
+ }
241
+
242
+ // ── default: spring pop-in (bold white) ─────────────────────────────
243
+ const sp = spring({ frame, fps, config: { damping: 200, stiffness: 220, mass: 0.4 } });
244
+ const scale = 0.82 + 0.18 * sp;
245
+ return (
246
+ <AbsoluteFill>
247
+ <div style={{
248
+ position: "absolute", top: `${y * 100}%`, left: "50%",
249
+ transform: `translate(-50%, -50%) scale(${scale})`,
250
+ maxWidth: "84%", textAlign: "center",
251
+ fontFamily: CAP_FONT, fontWeight: 900, fontSize: 60, color: "white", lineHeight: 1.06,
252
+ textShadow: "0 3px 14px rgba(0,0,0,.9)", WebkitTextStroke: "2px rgba(0,0,0,.6)", letterSpacing: 0.3,
253
+ }}>{text}</div>
254
+ </AbsoluteFill>
255
+ );
256
+ };
257
+
258
+ // ── hook headline (top), with one keyword in a coral highlight box ────
259
+ const HookOverlay: React.FC<{ hook: Hook }> = ({ hook }) => {
260
+ const frame = useCurrentFrame();
261
+ const fade = Math.min(
262
+ interpolate(frame, [0, 8], [0, 1], { extrapolateRight: "clamp" }),
263
+ interpolate(frame, [hook.dur - 12, hook.dur], [1, 0], { extrapolateLeft: "clamp" })
264
+ );
265
+ const kw = hook.keyword?.trim();
266
+ const i = kw ? hook.text.toLowerCase().indexOf(kw.toLowerCase()) : -1;
267
+ const before = i >= 0 ? hook.text.slice(0, i) : hook.text;
268
+ const word = i >= 0 ? hook.text.slice(i, i + kw.length) : "";
269
+ const after = i >= 0 ? hook.text.slice(i + kw.length) : "";
270
+ const base: React.CSSProperties = { fontFamily: CAP_FONT, fontWeight: 900, fontSize: 56, color: "white", lineHeight: 1.15, textShadow: "0 3px 12px rgba(0,0,0,.85)" };
271
+ return (
272
+ <AbsoluteFill style={{ justifyContent: "flex-start", alignItems: "center", paddingTop: "9%", opacity: fade }}>
273
+ <div style={{ ...base, maxWidth: "86%", textAlign: "center" }}>
274
+ {before}
275
+ {word && (
276
+ <span style={{ background: "#ff5a5f", color: "white", padding: "0 14px", borderRadius: 12, boxDecorationBreak: "clone", WebkitBoxDecorationBreak: "clone" }}>{word}</span>
277
+ )}
278
+ {after}
279
+ </div>
280
+ </AbsoluteFill>
281
+ );
282
+ };
283
+
284
+ // ── big keyword pop-text (scale-punch) on emphasis beats ──────────────
285
+ const PopText: React.FC<{ text: string }> = ({ text }) => {
286
+ const frame = useCurrentFrame();
287
+ const { fps } = useVideoConfig();
288
+ const sp = spring({ frame, fps, config: { damping: 11, stiffness: 130, mass: 0.6 } });
289
+ const scale = interpolate(sp, [0, 1], [0.35, 1]);
290
+ const rot = interpolate(sp, [0, 1], [-7, -3]);
291
+ return (
292
+ <AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
293
+ <div style={{
294
+ transform: `scale(${scale}) rotate(${rot}deg)`, maxWidth: "90%", textAlign: "center",
295
+ fontFamily: CAP_FONT, fontWeight: 900, fontSize: 104, color: "#ffe14d", textTransform: "uppercase",
296
+ textShadow: "0 5px 0 #c0392b, 0 10px 24px rgba(0,0,0,.6)", WebkitTextStroke: "3px #7a1f12", lineHeight: 0.95,
297
+ }}>{text}</div>
298
+ </AbsoluteFill>
299
+ );
300
+ };
301
+
302
+ export const Assembly: React.FC<EditData> = (data) => {
303
+ const cs = data.captionStyle ?? "default";
304
+ return (
305
+ <AbsoluteFill style={{ backgroundColor: "black" }}>
306
+ <ARoll data={data} />
307
+ {data.cuts.map((c, i) => (
308
+ <Sequence key={"cut" + i} from={c.from} durationInFrames={c.dur}>
309
+ <BrollLayer cut={c} />
310
+ </Sequence>
311
+ ))}
312
+ {(data.pops ?? []).map((p, i) => (
313
+ <Sequence key={"pop" + i} from={p.from} durationInFrames={p.dur}><PopText text={p.text} /></Sequence>
314
+ ))}
315
+ {data.hook && (
316
+ <Sequence from={data.hook.from} durationInFrames={data.hook.dur}><HookOverlay hook={data.hook} /></Sequence>
317
+ )}
318
+ {(data.captions ?? []).map((c, i) => (
319
+ <Sequence key={"cap" + i} from={c.from} durationInFrames={c.dur}>
320
+ <CaptionLine text={c.text} y={c.y ?? 0.58} dur={c.dur} captionStyle={cs} />
321
+ </Sequence>
322
+ ))}
323
+ </AbsoluteFill>
324
+ );
325
+ };
326
+
327
+ export const DEFAULT_EDIT: EditData = {
328
+ format: "9x16", fps: 30, totalFrames: 711, aroll: "aroll.mp4", arollFocal: [0.5, 0.38],
329
+ cuts: [], captions: [], pops: [], hook: null, captionStyle: "default",
330
+ };