@viralspin/mcp 0.2.0 → 0.2.2
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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/client.js +8 -0
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +62 -16
- package/dist/sanitize.js +22 -0
- package/dist/tools/assembly-adjust.js +107 -15
- package/dist/tools/carousel-video.js +7 -0
- package/dist/tools/color-grade.js +64 -0
- package/dist/tools/connections.js +23 -4
- package/dist/tools/edit-clip.js +136 -0
- package/dist/tools/engage.js +13 -4
- package/dist/tools/explainer-index.js +6 -0
- package/dist/tools/find-moments.js +140 -0
- package/dist/tools/folder-preview.js +71 -0
- package/dist/tools/ideas.js +158 -0
- package/dist/tools/kinetic-text.js +6 -2
- package/dist/tools/local-index.js +84 -0
- package/dist/tools/local-mode.js +84 -0
- package/dist/tools/profile.js +56 -0
- package/dist/tools/publish.js +34 -10
- package/dist/tools/relight-clip.js +98 -0
- package/dist/tools/timeline-edit.js +367 -0
- package/dist/tools/video-assembly.js +131 -4
- package/package.json +41 -29
- package/BUNDLE_LOCAL_SPEC.md +0 -133
- package/engine/assemble.mjs +0 -476
- package/engine/gemini.mjs +0 -164
- package/engine/kinetic.mjs +0 -84
- package/engine/prompts.mjs +0 -94
- package/engine/remotion.config.ts +0 -4
- package/engine/src/Assembly.tsx +0 -330
- package/engine/src/KineticText.tsx +0 -253
- package/engine/src/Root.tsx +0 -39
- package/engine/src/index.ts +0 -4
- package/engine/tsconfig.json +0 -15
- package/src/client.ts +0 -119
- package/src/index.ts +0 -151
- package/src/tools/assembly-adjust.ts +0 -110
- package/src/tools/captions.ts +0 -32
- package/src/tools/carousel-video.ts +0 -211
- package/src/tools/connections.ts +0 -27
- package/src/tools/engage.ts +0 -105
- package/src/tools/explainer-index.ts +0 -298
- package/src/tools/image-ads.ts +0 -192
- package/src/tools/kinetic-text.ts +0 -130
- package/src/tools/photo-picker.ts +0 -31
- package/src/tools/posts.ts +0 -58
- package/src/tools/publish.ts +0 -236
- package/src/tools/review.ts +0 -126
- package/src/tools/video-assembly.ts +0 -205
- package/tsconfig.json +0 -15
package/engine/src/Assembly.tsx
DELETED
|
@@ -1,330 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import {
|
|
3
|
-
AbsoluteFill,
|
|
4
|
-
interpolate,
|
|
5
|
-
spring,
|
|
6
|
-
useCurrentFrame,
|
|
7
|
-
useVideoConfig,
|
|
8
|
-
} from "remotion";
|
|
9
|
-
|
|
10
|
-
export type KineticEffect = "typing" | "billboard_flip" | "isometric";
|
|
11
|
-
export type KineticStyle = "terminal" | "cinematic" | "minimal";
|
|
12
|
-
|
|
13
|
-
export interface CameraTransform {
|
|
14
|
-
rotateX: number;
|
|
15
|
-
rotateY: number;
|
|
16
|
-
rotateZ: number;
|
|
17
|
-
translateZ: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface KineticTextProps {
|
|
21
|
-
text: string;
|
|
22
|
-
effect: KineticEffect;
|
|
23
|
-
style: KineticStyle;
|
|
24
|
-
fontSize: number;
|
|
25
|
-
format: "9x16" | "16x9" | "1x1";
|
|
26
|
-
fps: number;
|
|
27
|
-
totalFrames: number;
|
|
28
|
-
camera: CameraTransform;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export const EFFECT_CAMERA_DEFAULTS: Record<KineticEffect, CameraTransform> = {
|
|
32
|
-
typing: { rotateX: 8, rotateY: 0, rotateZ: 0, translateZ: -120 },
|
|
33
|
-
billboard_flip: { rotateX: 0, rotateY: 0, rotateZ: 0, translateZ: 0 },
|
|
34
|
-
isometric: { rotateX: 55, rotateY: 0, rotateZ: -45, translateZ: -80 },
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const STYLE_THEMES = {
|
|
38
|
-
terminal: { bg: "#0d1117", text: "#00ff88", font: "'Courier New', 'Consolas', monospace", cursor: true, vignette: false, scanlines: true },
|
|
39
|
-
cinematic: { bg: "#000000", text: "#ffffff", font: "'Inter', 'Segoe UI', sans-serif", cursor: false, vignette: true, scanlines: false },
|
|
40
|
-
minimal: { bg: "transparent", text: "#ffffff", font: "'Inter', 'Segoe UI', sans-serif", cursor: false, vignette: false, scanlines: false },
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export const DEFAULT_KINETIC: KineticTextProps = {
|
|
44
|
-
text: "create_video_assembly --aroll talk.mp4",
|
|
45
|
-
effect: "typing",
|
|
46
|
-
style: "terminal",
|
|
47
|
-
fontSize: 72,
|
|
48
|
-
format: "9x16",
|
|
49
|
-
fps: 30,
|
|
50
|
-
totalFrames: 150,
|
|
51
|
-
camera: EFFECT_CAMERA_DEFAULTS.typing,
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
// ─── Typing effect ────────────────────────────────────────────────────────────
|
|
55
|
-
const TypingText: React.FC<{
|
|
56
|
-
text: string;
|
|
57
|
-
frame: number;
|
|
58
|
-
totalFrames: number;
|
|
59
|
-
theme: typeof STYLE_THEMES.terminal;
|
|
60
|
-
fontSize: number;
|
|
61
|
-
}> = ({ text, frame, totalFrames, theme, fontSize }) => {
|
|
62
|
-
const typingFrames = Math.floor(totalFrames * 0.72);
|
|
63
|
-
const visibleChars = Math.min(text.length, Math.floor((frame / typingFrames) * text.length));
|
|
64
|
-
const isTyping = visibleChars < text.length;
|
|
65
|
-
const showCursor = theme.cursor && (isTyping || frame % 30 < 20);
|
|
66
|
-
|
|
67
|
-
return (
|
|
68
|
-
<div style={{
|
|
69
|
-
fontFamily: theme.font,
|
|
70
|
-
fontSize,
|
|
71
|
-
fontWeight: 700,
|
|
72
|
-
color: theme.text,
|
|
73
|
-
lineHeight: 1.6,
|
|
74
|
-
whiteSpace: "pre-wrap",
|
|
75
|
-
wordBreak: "break-word",
|
|
76
|
-
maxWidth: "85%",
|
|
77
|
-
textShadow: theme.text === "#00ff88"
|
|
78
|
-
? `0 0 20px ${theme.text}99, 0 0 7px ${theme.text}`
|
|
79
|
-
: "0 2px 14px rgba(0,0,0,0.9)",
|
|
80
|
-
letterSpacing: "0.02em",
|
|
81
|
-
}}>
|
|
82
|
-
{text.slice(0, visibleChars)}
|
|
83
|
-
{showCursor && (
|
|
84
|
-
<span style={{ opacity: frame % 15 < 9 ? 1 : 0 }}>▋</span>
|
|
85
|
-
)}
|
|
86
|
-
</div>
|
|
87
|
-
);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
// ─── Billboard flip effect ────────────────────────────────────────────────────
|
|
91
|
-
const BillboardFlipText: React.FC<{
|
|
92
|
-
text: string;
|
|
93
|
-
frame: number;
|
|
94
|
-
fps: number;
|
|
95
|
-
theme: typeof STYLE_THEMES.terminal;
|
|
96
|
-
fontSize: number;
|
|
97
|
-
}> = ({ text, frame, fps, theme, fontSize }) => {
|
|
98
|
-
const words = text.split(/\s+/);
|
|
99
|
-
const stagger = 14;
|
|
100
|
-
|
|
101
|
-
return (
|
|
102
|
-
<div style={{
|
|
103
|
-
fontFamily: theme.font,
|
|
104
|
-
fontSize,
|
|
105
|
-
fontWeight: 700,
|
|
106
|
-
color: theme.text,
|
|
107
|
-
display: "flex",
|
|
108
|
-
flexWrap: "wrap",
|
|
109
|
-
gap: `${Math.round(fontSize * 0.28)}px`,
|
|
110
|
-
justifyContent: "center",
|
|
111
|
-
maxWidth: "85%",
|
|
112
|
-
textShadow: "0 2px 24px rgba(0,0,0,0.85)",
|
|
113
|
-
}}>
|
|
114
|
-
{words.map((word, i) => {
|
|
115
|
-
const elapsed = Math.max(0, frame - i * stagger);
|
|
116
|
-
const s = spring({ frame: elapsed, fps, config: { damping: 14, stiffness: 140, mass: 0.6 } });
|
|
117
|
-
const tz = interpolate(s, [0, 1], [-800, 0]);
|
|
118
|
-
const ry = interpolate(s, [0, 1], [90, 0]);
|
|
119
|
-
const opacity = interpolate(elapsed, [0, 6], [0, 1], { extrapolateRight: "clamp" });
|
|
120
|
-
return (
|
|
121
|
-
<span key={i} style={{
|
|
122
|
-
display: "inline-block",
|
|
123
|
-
transform: `translateZ(${tz}px) rotateY(${ry}deg)`,
|
|
124
|
-
opacity,
|
|
125
|
-
}}>
|
|
126
|
-
{word}
|
|
127
|
-
</span>
|
|
128
|
-
);
|
|
129
|
-
})}
|
|
130
|
-
</div>
|
|
131
|
-
);
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
// ─── Isometric extrude effect ─────────────────────────────────────────────────
|
|
135
|
-
const IsometricText: React.FC<{
|
|
136
|
-
text: string;
|
|
137
|
-
frame: number;
|
|
138
|
-
fps: number;
|
|
139
|
-
theme: typeof STYLE_THEMES.terminal;
|
|
140
|
-
fontSize: number;
|
|
141
|
-
}> = ({ text, frame, fps, theme, fontSize }) => {
|
|
142
|
-
const chars = text.split("");
|
|
143
|
-
const stagger = 3;
|
|
144
|
-
const shadowColor = theme.text === "#00ff88" ? "#006633" : "rgba(0,0,0,0.55)";
|
|
145
|
-
|
|
146
|
-
return (
|
|
147
|
-
<div style={{
|
|
148
|
-
fontFamily: theme.font,
|
|
149
|
-
fontSize,
|
|
150
|
-
fontWeight: 900,
|
|
151
|
-
color: theme.text,
|
|
152
|
-
display: "flex",
|
|
153
|
-
flexWrap: "wrap",
|
|
154
|
-
justifyContent: "center",
|
|
155
|
-
maxWidth: "85%",
|
|
156
|
-
letterSpacing: "0.06em",
|
|
157
|
-
}}>
|
|
158
|
-
{chars.map((char, i) => {
|
|
159
|
-
const elapsed = Math.max(0, frame - i * stagger);
|
|
160
|
-
const s = spring({ frame: elapsed, fps, config: { damping: 18, stiffness: 200, mass: 0.5 } });
|
|
161
|
-
const ty = interpolate(s, [0, 1], [80, 0]);
|
|
162
|
-
const opacity = interpolate(elapsed, [0, 8], [0, 1], { extrapolateRight: "clamp" });
|
|
163
|
-
return (
|
|
164
|
-
<span key={i} style={{
|
|
165
|
-
display: "inline-block",
|
|
166
|
-
transform: `translateY(${ty}px)`,
|
|
167
|
-
opacity,
|
|
168
|
-
textShadow: `0 5px 0 ${shadowColor}, 0 2px 12px rgba(0,0,0,0.7)`,
|
|
169
|
-
}}>
|
|
170
|
-
{char === " " ? " " : char}
|
|
171
|
-
</span>
|
|
172
|
-
);
|
|
173
|
-
})}
|
|
174
|
-
</div>
|
|
175
|
-
);
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
// ─── Main composition ─────────────────────────────────────────────────────────
|
|
179
|
-
export const KineticText: React.FC<KineticTextProps> = ({
|
|
180
|
-
text,
|
|
181
|
-
effect,
|
|
182
|
-
style,
|
|
183
|
-
fontSize,
|
|
184
|
-
camera,
|
|
185
|
-
}) => {
|
|
186
|
-
const frame = useCurrentFrame();
|
|
187
|
-
const { fps, durationInFrames } = useVideoConfig();
|
|
188
|
-
const theme = STYLE_THEMES[style];
|
|
189
|
-
|
|
190
|
-
// Camera eases from identity to target over first 60% of duration
|
|
191
|
-
const camP = interpolate(frame, [0, Math.floor(durationInFrames * 0.6)], [0, 1], {
|
|
192
|
-
extrapolateRight: "clamp",
|
|
193
|
-
// cubic ease-in-out
|
|
194
|
-
easing: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
|
|
195
|
-
});
|
|
196
|
-
const cameraTransform = [
|
|
197
|
-
`rotateX(${camera.rotateX * camP}deg)`,
|
|
198
|
-
`rotateY(${camera.rotateY * camP}deg)`,
|
|
199
|
-
`rotateZ(${camera.rotateZ * camP}deg)`,
|
|
200
|
-
`translateZ(${camera.translateZ * camP}px)`,
|
|
201
|
-
].join(" ");
|
|
202
|
-
|
|
203
|
-
const prefix = style === "terminal" ? "$ " : "";
|
|
204
|
-
|
|
205
|
-
const textNode = (() => {
|
|
206
|
-
if (effect === "typing") {
|
|
207
|
-
return (
|
|
208
|
-
<TypingText
|
|
209
|
-
text={prefix + text}
|
|
210
|
-
frame={frame}
|
|
211
|
-
totalFrames={durationInFrames}
|
|
212
|
-
theme={theme}
|
|
213
|
-
fontSize={fontSize}
|
|
214
|
-
/>
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
if (effect === "billboard_flip") {
|
|
218
|
-
return <BillboardFlipText text={text} frame={frame} fps={fps} theme={theme} fontSize={fontSize} />;
|
|
219
|
-
}
|
|
220
|
-
return <IsometricText text={text} frame={frame} fps={fps} theme={theme} fontSize={fontSize} />;
|
|
221
|
-
})();
|
|
222
|
-
|
|
223
|
-
return (
|
|
224
|
-
<AbsoluteFill style={{ background: theme.bg, perspective: "1200px" }}>
|
|
225
|
-
{/* CRT scanlines for terminal style */}
|
|
226
|
-
{theme.scanlines && (
|
|
227
|
-
<AbsoluteFill style={{
|
|
228
|
-
backgroundImage: "repeating-linear-gradient(0deg, rgba(0,0,0,0.07) 0px, rgba(0,0,0,0.07) 1px, transparent 1px, transparent 4px)",
|
|
229
|
-
pointerEvents: "none",
|
|
230
|
-
zIndex: 10,
|
|
231
|
-
}} />
|
|
232
|
-
)}
|
|
233
|
-
{/* Radial vignette for cinematic style */}
|
|
234
|
-
{theme.vignette && (
|
|
235
|
-
<AbsoluteFill style={{
|
|
236
|
-
background: "radial-gradient(ellipse at center, transparent 25%, rgba(0,0,0,0.8) 100%)",
|
|
237
|
-
pointerEvents: "none",
|
|
238
|
-
zIndex: 10,
|
|
239
|
-
}} />
|
|
240
|
-
)}
|
|
241
|
-
{/* 3D camera layer — no overflow:hidden here, that flattens Z-space */}
|
|
242
|
-
<AbsoluteFill style={{
|
|
243
|
-
display: "flex",
|
|
244
|
-
alignItems: "center",
|
|
245
|
-
justifyContent: "center",
|
|
246
|
-
transformStyle: "preserve-3d",
|
|
247
|
-
transform: cameraTransform,
|
|
248
|
-
}}>
|
|
249
|
-
{textNode}
|
|
250
|
-
</AbsoluteFill>
|
|
251
|
-
</AbsoluteFill>
|
|
252
|
-
);
|
|
253
|
-
};
|
package/engine/src/Root.tsx
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { Composition } from "remotion";
|
|
2
|
-
import { Assembly, DEFAULT_EDIT, EditData } from "./Assembly";
|
|
3
|
-
import { KineticText, DEFAULT_KINETIC, KineticTextProps } from "./KineticText";
|
|
4
|
-
|
|
5
|
-
const dimsFor = (fmt: EditData["format"]) =>
|
|
6
|
-
fmt === "1x1" ? { width: 1080, height: 1080 } : fmt === "16x9" ? { width: 1920, height: 1080 } : { width: 1080, height: 1920 };
|
|
7
|
-
|
|
8
|
-
export const RemotionRoot: React.FC = () => {
|
|
9
|
-
return (
|
|
10
|
-
<>
|
|
11
|
-
<Composition
|
|
12
|
-
id="Assembly"
|
|
13
|
-
component={Assembly}
|
|
14
|
-
durationInFrames={DEFAULT_EDIT.totalFrames}
|
|
15
|
-
fps={DEFAULT_EDIT.fps}
|
|
16
|
-
{...dimsFor(DEFAULT_EDIT.format)}
|
|
17
|
-
defaultProps={DEFAULT_EDIT}
|
|
18
|
-
calculateMetadata={({ props }) => ({
|
|
19
|
-
durationInFrames: props.totalFrames,
|
|
20
|
-
fps: props.fps,
|
|
21
|
-
...dimsFor(props.format),
|
|
22
|
-
})}
|
|
23
|
-
/>
|
|
24
|
-
<Composition
|
|
25
|
-
id="KineticText"
|
|
26
|
-
component={KineticText}
|
|
27
|
-
durationInFrames={DEFAULT_KINETIC.totalFrames}
|
|
28
|
-
fps={DEFAULT_KINETIC.fps}
|
|
29
|
-
{...dimsFor(DEFAULT_KINETIC.format)}
|
|
30
|
-
defaultProps={DEFAULT_KINETIC}
|
|
31
|
-
calculateMetadata={({ props }: { props: KineticTextProps }) => ({
|
|
32
|
-
durationInFrames: props.totalFrames,
|
|
33
|
-
fps: props.fps,
|
|
34
|
-
...dimsFor(props.format),
|
|
35
|
-
})}
|
|
36
|
-
/>
|
|
37
|
-
</>
|
|
38
|
-
);
|
|
39
|
-
};
|
package/engine/src/index.ts
DELETED
package/engine/tsconfig.json
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ESNext",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"jsx": "react-jsx",
|
|
6
|
-
"moduleResolution": "bundler",
|
|
7
|
-
"resolveJsonModule": true,
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"noEmit": true,
|
|
12
|
-
"lib": ["ESNext", "DOM"]
|
|
13
|
-
},
|
|
14
|
-
"include": ["src"]
|
|
15
|
-
}
|