@mevdragon/vidfarm-devcli 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 (63) hide show
  1. package/.env.example +11 -4
  2. package/PLATFORM_SPEC.md +142 -2
  3. package/README.md +165 -16
  4. package/SKILL.developer.md +577 -0
  5. package/dist/infra/cdk/bin/vidfarm-prod.js +59 -0
  6. package/dist/infra/cdk/lib/vidfarm-prod-stack.js +212 -0
  7. package/dist/src/account-pages.js +578 -0
  8. package/dist/src/app.js +887 -66
  9. package/dist/src/cli.js +284 -5
  10. package/dist/src/config.js +24 -4
  11. package/dist/src/db.js +427 -18
  12. package/dist/src/dev-app.js +59 -12
  13. package/dist/src/homepage.js +441 -0
  14. package/dist/src/index.js +12 -7
  15. package/dist/src/lib/crypto.js +14 -0
  16. package/dist/src/lib/template-dna.js +542 -0
  17. package/dist/src/lib/template-style-options.js +49 -0
  18. package/dist/src/registry.js +54 -7
  19. package/dist/src/runtime.js +3 -1
  20. package/dist/src/services/auth.js +69 -5
  21. package/dist/src/services/jobs.js +23 -4
  22. package/dist/src/services/providers.js +74 -12
  23. package/dist/src/services/storage.js +52 -18
  24. package/dist/src/services/template-certification.js +160 -0
  25. package/dist/src/services/template-loader.js +37 -0
  26. package/dist/src/services/template-sources.js +135 -0
  27. package/dist/src/worker.js +19 -7
  28. package/dist/templates/template_0000/src/lib/images.js +242 -0
  29. package/dist/templates/template_0000/src/remotion/Root.js +33 -0
  30. package/dist/templates/template_0000/src/sdk.js +3 -0
  31. package/dist/templates/template_0000/src/style-options.js +51 -0
  32. package/dist/templates/template_0000/src/template-dna.js +9 -0
  33. package/dist/templates/template_0000/src/template.js +1217 -0
  34. package/package.json +9 -1
  35. package/templates/template_0000/README.md +121 -0
  36. package/templates/template_0000/SKILL.md +193 -0
  37. package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
  38. package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  39. package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
  40. package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
  41. package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  42. package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
  43. package/templates/template_0000/composition.json +11 -0
  44. package/templates/template_0000/package-lock.json +5137 -0
  45. package/templates/template_0000/package.json +30 -0
  46. package/templates/template_0000/research/preview/.gitkeep +1 -0
  47. package/templates/template_0000/research/source_notes.md +7 -0
  48. package/templates/template_0000/scripts/create-site.mjs +27 -0
  49. package/templates/template_0000/scripts/render-cloud.mjs +72 -0
  50. package/templates/template_0000/src/lib/images.ts +284 -0
  51. package/templates/template_0000/src/remotion/Root.js +33 -0
  52. package/templates/template_0000/src/remotion/Root.tsx +75 -0
  53. package/templates/template_0000/src/remotion/index.tsx +4 -0
  54. package/templates/template_0000/src/sdk.ts +122 -0
  55. package/templates/template_0000/src/style-options.js +51 -0
  56. package/templates/template_0000/src/style-options.ts +60 -0
  57. package/templates/template_0000/src/template-dna.ts +15 -0
  58. package/templates/template_0000/src/template.ts +1747 -0
  59. package/templates/template_0000/template.config.json +26 -0
  60. package/templates/template_0000/tsconfig.json +19 -0
  61. package/dist/templates/template_0000/demo-template.js +0 -196
  62. package/dist/templates/template_0000/remotion/Root.js +0 -66
  63. /package/dist/templates/template_0000/{remotion → src/remotion}/index.js +0 -0
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "vidfarm_template_0000",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "Standalone Vidfarm template_0000 project for authoring, admin review, and controlled production promotion.",
6
+ "type": "module",
7
+ "scripts": {
8
+ "check": "tsc -p tsconfig.json --noEmit",
9
+ "build": "tsc -p tsconfig.json",
10
+ "studio": "remotion studio src/remotion/index.tsx",
11
+ "admin:create-site": "node ./scripts/create-site.mjs",
12
+ "admin:render:cloud": "node ./scripts/render-cloud.mjs"
13
+ },
14
+ "dependencies": {
15
+ "@remotion/bundler": "4.0.355",
16
+ "@remotion/cli": "4.0.355",
17
+ "@remotion/lambda": "4.0.355",
18
+ "@remotion/renderer": "4.0.355",
19
+ "react": "^18.3.1",
20
+ "react-dom": "^18.3.1",
21
+ "remotion": "4.0.355",
22
+ "sharp": "^0.34.2"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^24.0.1",
26
+ "@types/react": "^18.3.23",
27
+ "@types/react-dom": "^18.3.7",
28
+ "typescript": "^5.8.3"
29
+ }
30
+ }
@@ -0,0 +1,7 @@
1
+ # Source Notes
2
+
3
+ - original format URL:
4
+ - creator/account:
5
+ - why it wins:
6
+ - what must survive adaptation:
7
+ - what can change for new brands:
@@ -0,0 +1,27 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { execFileSync } from "node:child_process";
5
+
6
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+ const config = JSON.parse(readFileSync(path.join(root, "template.config.json"), "utf8"));
8
+ const remotion = config.remotion;
9
+
10
+ execFileSync(
11
+ path.join(root, "node_modules", ".bin", "remotion"),
12
+ [
13
+ "lambda",
14
+ "sites",
15
+ "create",
16
+ remotion.entry_point,
17
+ "--site-name",
18
+ remotion.site_name,
19
+ "--region",
20
+ remotion.region
21
+ ],
22
+ {
23
+ cwd: root,
24
+ stdio: "inherit",
25
+ env: process.env
26
+ }
27
+ );
@@ -0,0 +1,72 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { execFileSync } from "node:child_process";
5
+
6
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+ const config = JSON.parse(readFileSync(path.join(root, "template.config.json"), "utf8"));
8
+ const remotion = config.remotion;
9
+ const args = process.argv.slice(2);
10
+ const manifestArgIndex = args.findIndex((value) => value === "--manifest");
11
+ const propsPath = manifestArgIndex >= 0 && args[manifestArgIndex + 1]
12
+ ? await buildPropsFileFromManifest(args[manifestArgIndex + 1])
13
+ : remotion.props_file;
14
+
15
+ execFileSync(
16
+ path.join(root, "node_modules", ".bin", "remotion"),
17
+ [
18
+ "lambda",
19
+ "render",
20
+ remotion.serve_url,
21
+ remotion.composition_id,
22
+ "--props",
23
+ propsPath,
24
+ "--region",
25
+ remotion.region,
26
+ "--function-name",
27
+ remotion.function_name,
28
+ "--timeout",
29
+ String(remotion.timeout_ms),
30
+ "--frames-per-lambda",
31
+ String(remotion.frames_per_lambda)
32
+ ],
33
+ {
34
+ cwd: root,
35
+ stdio: "inherit",
36
+ env: process.env
37
+ }
38
+ );
39
+
40
+ async function buildPropsFileFromManifest(manifestInput) {
41
+ const manifest = await readManifest(manifestInput);
42
+ const slides = Array.isArray(manifest.slides)
43
+ ? manifest.slides
44
+ .map((slide) => slide?.frameImageUrl ? { imageUrl: slide.frameImageUrl } : null)
45
+ .filter(Boolean)
46
+ : [];
47
+
48
+ if (!slides.length) {
49
+ throw new Error(`Manifest did not contain any frameImageUrl slides: ${manifestPath}`);
50
+ }
51
+
52
+ const propsPath = path.join(root, ".tmp-render-cloud-props.json");
53
+ const props = {
54
+ slides,
55
+ secondsPerSlide: manifest.secondsPerSlide ?? 4
56
+ };
57
+ writeFileSync(propsPath, JSON.stringify(props, null, 2));
58
+ return propsPath;
59
+ }
60
+
61
+ async function readManifest(manifestInput) {
62
+ if (/^https?:\/\//i.test(manifestInput)) {
63
+ const response = await fetch(manifestInput);
64
+ if (!response.ok) {
65
+ throw new Error(`Could not fetch manifest URL ${manifestInput}: ${response.status}`);
66
+ }
67
+ return await response.json();
68
+ }
69
+
70
+ const manifestPath = path.isAbsolute(manifestInput) ? manifestInput : path.resolve(process.cwd(), manifestInput);
71
+ return JSON.parse(readFileSync(manifestPath, "utf8"));
72
+ }
@@ -0,0 +1,284 @@
1
+ import sharp from "sharp";
2
+
3
+ export async function normalizeToPortraitFrame(
4
+ input: Uint8Array,
5
+ target = { width: 1080, height: 1920 }
6
+ ) {
7
+ const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
8
+ const oriented = sharp(buffer, { density: 144 }).rotate();
9
+ const trimmed = await trimFlatBorders(oriented);
10
+ const targetAspect = target.width / target.height;
11
+ // If the model already returned a near-native vertical frame, avoid the
12
+ // activity crop pass because it can misread low-detail edges as padding.
13
+ const shouldPreserveFraming = await isCloseToAspect(trimmed, targetAspect, 0.025);
14
+ const cropped = shouldPreserveFraming ? trimmed : await cropToActiveImageRegion(trimmed);
15
+ const portraitCrop = await cropToPortraitAspect(cropped, targetAspect);
16
+ const output = await portraitCrop
17
+ .resize(target.width, target.height, {
18
+ fit: "cover",
19
+ position: sharp.strategy.attention
20
+ })
21
+ .png()
22
+ .toBuffer();
23
+ const exact = await ensureExactPixelSize(output, target);
24
+
25
+ return {
26
+ bytes: exact,
27
+ contentType: "image/png" as const,
28
+ width: target.width,
29
+ height: target.height
30
+ };
31
+ }
32
+
33
+ async function isCloseToAspect(image: sharp.Sharp, targetAspect: number, tolerance: number) {
34
+ const metadata = await image.metadata();
35
+ const width = metadata.width ?? 0;
36
+ const height = metadata.height ?? 0;
37
+ if (!width || !height) {
38
+ return false;
39
+ }
40
+ return Math.abs(width / height - targetAspect) <= tolerance;
41
+ }
42
+
43
+ async function trimFlatBorders(image: sharp.Sharp) {
44
+ const metadata = await image.metadata();
45
+ const sourceWidth = metadata.width ?? 0;
46
+ const sourceHeight = metadata.height ?? 0;
47
+ if (!sourceWidth || !sourceHeight || sourceWidth < 3 || sourceHeight < 3) {
48
+ return image;
49
+ }
50
+
51
+ const { data, info } = await image
52
+ .clone()
53
+ .trim({ threshold: 16 })
54
+ .png()
55
+ .toBuffer({ resolveWithObject: true });
56
+
57
+ const widthRatio = info.width / sourceWidth;
58
+ const heightRatio = info.height / sourceHeight;
59
+ if (widthRatio > 0.985 && heightRatio > 0.985) {
60
+ return image;
61
+ }
62
+
63
+ return sharp(data).rotate();
64
+ }
65
+
66
+ async function cropToActiveImageRegion(image: sharp.Sharp) {
67
+ const metadata = await image.metadata();
68
+ const sourceWidth = metadata.width ?? 0;
69
+ const sourceHeight = metadata.height ?? 0;
70
+ if (!sourceWidth || !sourceHeight) {
71
+ return image;
72
+ }
73
+
74
+ const sampleWidth = 96;
75
+ const sampleHeight = 170;
76
+ const sample = await image
77
+ .clone()
78
+ .resize(sampleWidth, sampleHeight, { fit: "fill" })
79
+ .grayscale()
80
+ .raw()
81
+ .toBuffer();
82
+
83
+ const bounds = detectActiveBounds(sample, sampleWidth, sampleHeight);
84
+ if (!bounds) {
85
+ return image;
86
+ }
87
+
88
+ const left = Math.max(0, Math.floor((bounds.left / sampleWidth) * sourceWidth));
89
+ const top = Math.max(0, Math.floor((bounds.top / sampleHeight) * sourceHeight));
90
+ const width = Math.min(sourceWidth - left, Math.max(1, Math.ceil((bounds.width / sampleWidth) * sourceWidth)));
91
+ const height = Math.min(sourceHeight - top, Math.max(1, Math.ceil((bounds.height / sampleHeight) * sourceHeight)));
92
+
93
+ return image.extract({ left, top, width, height });
94
+ }
95
+
96
+ async function cropToPortraitAspect(image: sharp.Sharp, targetAspect: number) {
97
+ const metadata = await image.metadata();
98
+ const sourceWidth = metadata.width ?? 0;
99
+ const sourceHeight = metadata.height ?? 0;
100
+ if (!sourceWidth || !sourceHeight) {
101
+ return image;
102
+ }
103
+
104
+ const sourceAspect = sourceWidth / sourceHeight;
105
+ if (Math.abs(sourceAspect - targetAspect) < 0.015) {
106
+ return image;
107
+ }
108
+
109
+ const focus = await detectFocusPoint(image, sourceWidth, sourceHeight);
110
+ if (sourceAspect > targetAspect) {
111
+ const cropWidth = Math.max(1, Math.min(sourceWidth, Math.round(sourceHeight * targetAspect)));
112
+ const left = clamp(Math.round(focus.x - cropWidth / 2), 0, sourceWidth - cropWidth);
113
+ return image.extract({ left, top: 0, width: cropWidth, height: sourceHeight });
114
+ }
115
+
116
+ const cropHeight = Math.max(1, Math.min(sourceHeight, Math.round(sourceWidth / targetAspect)));
117
+ const top = clamp(Math.round(focus.y - cropHeight / 2), 0, sourceHeight - cropHeight);
118
+ return image.extract({ left: 0, top, width: sourceWidth, height: cropHeight });
119
+ }
120
+
121
+ function detectActiveBounds(sample: Buffer, width: number, height: number) {
122
+ const rowActivity = new Array<number>(height).fill(0);
123
+ const colActivity = new Array<number>(width).fill(0);
124
+
125
+ for (let y = 0; y < height; y += 1) {
126
+ for (let x = 0; x < width; x += 1) {
127
+ const index = y * width + x;
128
+ const current = sample[index] ?? 0;
129
+ const left = x > 0 ? sample[index - 1] ?? current : current;
130
+ const up = y > 0 ? sample[index - width] ?? current : current;
131
+ const right = x < width - 1 ? sample[index + 1] ?? current : current;
132
+ const down = y < height - 1 ? sample[index + width] ?? current : current;
133
+ const energy =
134
+ Math.abs(current - left) +
135
+ Math.abs(current - right) +
136
+ Math.abs(current - up) +
137
+ Math.abs(current - down);
138
+ rowActivity[y] += energy;
139
+ colActivity[x] += energy;
140
+ }
141
+ }
142
+
143
+ const smoothedRows = smoothSeries(rowActivity.map((value) => value / width), 9);
144
+ const smoothedCols = smoothSeries(colActivity.map((value) => value / height), 7);
145
+ const rowBounds = findActiveRange(smoothedRows, Math.round(height * 0.18));
146
+ const colBounds = findActiveRange(smoothedCols, Math.round(width * 0.16));
147
+ if (!rowBounds || !colBounds) {
148
+ return null;
149
+ }
150
+
151
+ const topMargin = rowBounds.start / height;
152
+ const bottomMargin = (height - rowBounds.end - 1) / height;
153
+ const leftMargin = colBounds.start / width;
154
+ const rightMargin = (width - colBounds.end - 1) / width;
155
+ const croppedWidth = colBounds.end - colBounds.start + 1;
156
+ const croppedHeight = rowBounds.end - rowBounds.start + 1;
157
+ const croppedAreaRatio = (croppedWidth * croppedHeight) / (width * height);
158
+ const significantMargins =
159
+ topMargin > 0.11 || bottomMargin > 0.11 || leftMargin > 0.08 || rightMargin > 0.08;
160
+
161
+ if (!significantMargins && croppedAreaRatio > 0.9) {
162
+ return null;
163
+ }
164
+
165
+ return {
166
+ left: colBounds.start,
167
+ top: rowBounds.start,
168
+ width: croppedWidth,
169
+ height: croppedHeight
170
+ };
171
+ }
172
+
173
+ async function detectFocusPoint(image: sharp.Sharp, sourceWidth: number, sourceHeight: number) {
174
+ const sampleWidth = 120;
175
+ const sampleHeight = Math.max(1, Math.round((sourceHeight / Math.max(sourceWidth, 1)) * sampleWidth));
176
+ const sample = await image
177
+ .clone()
178
+ .resize(sampleWidth, sampleHeight, { fit: "fill" })
179
+ .grayscale()
180
+ .raw()
181
+ .toBuffer();
182
+
183
+ let weightedX = 0;
184
+ let weightedY = 0;
185
+ let totalWeight = 0;
186
+
187
+ for (let y = 0; y < sampleHeight; y += 1) {
188
+ for (let x = 0; x < sampleWidth; x += 1) {
189
+ const index = y * sampleWidth + x;
190
+ const current = sample[index] ?? 0;
191
+ const left = x > 0 ? sample[index - 1] ?? current : current;
192
+ const up = y > 0 ? sample[index - sampleWidth] ?? current : current;
193
+ const right = x < sampleWidth - 1 ? sample[index + 1] ?? current : current;
194
+ const down = y < sampleHeight - 1 ? sample[index + sampleWidth] ?? current : current;
195
+ const energy =
196
+ Math.abs(current - left) +
197
+ Math.abs(current - right) +
198
+ Math.abs(current - up) +
199
+ Math.abs(current - down);
200
+ const centerBiasX = 1 - Math.abs((x + 0.5) / sampleWidth - 0.5) * 0.45;
201
+ const centerBiasY = 1 - Math.abs((y + 0.5) / sampleHeight - 0.5) * 0.35;
202
+ const weight = Math.max(energy, 1) * centerBiasX * centerBiasY;
203
+ weightedX += (x + 0.5) * weight;
204
+ weightedY += (y + 0.5) * weight;
205
+ totalWeight += weight;
206
+ }
207
+ }
208
+
209
+ if (totalWeight <= 0) {
210
+ return { x: sourceWidth / 2, y: sourceHeight / 2 };
211
+ }
212
+
213
+ return {
214
+ x: (weightedX / totalWeight / sampleWidth) * sourceWidth,
215
+ y: (weightedY / totalWeight / sampleHeight) * sourceHeight
216
+ };
217
+ }
218
+
219
+ function findActiveRange(values: number[], minSpan: number) {
220
+ const mean = values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1);
221
+ const max = values.reduce((best, value) => Math.max(best, value), 0);
222
+ if (max <= 0) {
223
+ return null;
224
+ }
225
+
226
+ const threshold = mean + (max - mean) * 0.18;
227
+ let start = 0;
228
+ while (start < values.length && values[start] < threshold) {
229
+ start += 1;
230
+ }
231
+
232
+ let end = values.length - 1;
233
+ while (end >= 0 && values[end] < threshold) {
234
+ end -= 1;
235
+ }
236
+
237
+ if (start >= end) {
238
+ return null;
239
+ }
240
+
241
+ const desiredSpan = Math.max(minSpan, end - start + 1);
242
+ const extra = desiredSpan - (end - start + 1);
243
+ const expandStart = Math.floor(extra / 2);
244
+ const expandEnd = extra - expandStart;
245
+ start = Math.max(0, start - expandStart - 4);
246
+ end = Math.min(values.length - 1, end + expandEnd + 4);
247
+ return { start, end };
248
+ }
249
+
250
+ function smoothSeries(values: number[], radius: number) {
251
+ return values.map((_, index) => {
252
+ let total = 0;
253
+ let count = 0;
254
+ for (let offset = -radius; offset <= radius; offset += 1) {
255
+ const value = values[index + offset];
256
+ if (value === undefined) {
257
+ continue;
258
+ }
259
+ total += value;
260
+ count += 1;
261
+ }
262
+ return total / Math.max(count, 1);
263
+ });
264
+ }
265
+
266
+ function clamp(value: number, min: number, max: number) {
267
+ return Math.min(Math.max(value, min), max);
268
+ }
269
+
270
+ async function ensureExactPixelSize(input: Buffer, target: { width: number; height: number }) {
271
+ const metadata = await sharp(input).metadata();
272
+ if (metadata.width === target.width && metadata.height === target.height) {
273
+ return input;
274
+ }
275
+
276
+ const exactCrop = await cropToPortraitAspect(sharp(input), target.width / target.height);
277
+ return exactCrop
278
+ .resize(target.width, target.height, {
279
+ fit: "cover",
280
+ position: sharp.strategy.attention
281
+ })
282
+ .png()
283
+ .toBuffer();
284
+ }
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AbsoluteFill, Composition, Img, Sequence, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
3
+ export const RemotionRoot = () => {
4
+ return (_jsx(Composition, { id: "template-0000", component: TemplateVideo, width: 1080, height: 1920, fps: 30, durationInFrames: 120, defaultProps: { slides: [] }, calculateMetadata: ({ props }) => ({
5
+ width: 1080,
6
+ height: 1920,
7
+ durationInFrames: Math.max(1, sumSlideFrames(props.slides, 30))
8
+ }) }));
9
+ };
10
+ const TemplateVideo = ({ slides }) => {
11
+ const { fps } = useVideoConfig();
12
+ let currentFrame = 0;
13
+ return (_jsx(AbsoluteFill, { style: { backgroundColor: "#120f0b" }, children: slides.map((slide, index) => {
14
+ const durationInFrames = msToFrames(slide.durationMs, fps);
15
+ const sequence = (_jsx(Sequence, { from: currentFrame, durationInFrames: durationInFrames, children: _jsx(SlideFrame, { slide: slide }) }, `${slide.imageUrl}-${index}`));
16
+ currentFrame += durationInFrames;
17
+ return sequence;
18
+ }) }));
19
+ };
20
+ const SlideFrame = ({ slide }) => {
21
+ const frame = useCurrentFrame();
22
+ const { fps } = useVideoConfig();
23
+ const entrance = spring({ fps, frame, config: { damping: 200, stiffness: 140 } });
24
+ const opacity = interpolate(entrance, [0, 1], [0.35, 1]);
25
+ const lift = interpolate(entrance, [0, 1], [22, 0]);
26
+ return (_jsxs(AbsoluteFill, { children: [_jsx(AbsoluteFill, { style: { opacity, transform: `translateY(${lift}px)` }, children: _jsx(Img, { src: slide.imageUrl, style: { width: "100%", height: "100%", objectFit: "fill" } }) }), _jsx(AbsoluteFill, { style: { background: "linear-gradient(180deg, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.06) 36%, rgba(0,0,0,0.34) 100%)" } })] }));
27
+ };
28
+ function msToFrames(durationMs, fps) {
29
+ return Math.max(1, Math.round((durationMs / 1000) * fps));
30
+ }
31
+ function sumSlideFrames(slides, fps) {
32
+ return slides.reduce((total, slide) => total + msToFrames(slide.durationMs, fps), 0);
33
+ }
@@ -0,0 +1,75 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, Composition, Img, Sequence, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
3
+
4
+ type Slide = {
5
+ imageUrl: string;
6
+ durationMs: number;
7
+ };
8
+
9
+ type TemplateVideoProps = {
10
+ slides: Slide[];
11
+ };
12
+
13
+ export const RemotionRoot: React.FC = () => {
14
+ return (
15
+ <Composition
16
+ id="template-0000"
17
+ component={TemplateVideo}
18
+ width={1080}
19
+ height={1920}
20
+ fps={30}
21
+ durationInFrames={120}
22
+ defaultProps={{ slides: [] }}
23
+ calculateMetadata={({ props }) => ({
24
+ width: 1080,
25
+ height: 1920,
26
+ durationInFrames: Math.max(1, sumSlideFrames(props.slides, 30))
27
+ })}
28
+ />
29
+ );
30
+ };
31
+
32
+ const TemplateVideo: React.FC<TemplateVideoProps> = ({ slides }) => {
33
+ const { fps } = useVideoConfig();
34
+ let currentFrame = 0;
35
+
36
+ return (
37
+ <AbsoluteFill style={{ backgroundColor: "#120f0b" }}>
38
+ {slides.map((slide, index) => {
39
+ const durationInFrames = msToFrames(slide.durationMs, fps);
40
+ const sequence = (
41
+ <Sequence key={`${slide.imageUrl}-${index}`} from={currentFrame} durationInFrames={durationInFrames}>
42
+ <SlideFrame slide={slide} />
43
+ </Sequence>
44
+ );
45
+ currentFrame += durationInFrames;
46
+ return sequence;
47
+ })}
48
+ </AbsoluteFill>
49
+ );
50
+ };
51
+
52
+ const SlideFrame: React.FC<{ slide: Slide }> = ({ slide }) => {
53
+ const frame = useCurrentFrame();
54
+ const { fps } = useVideoConfig();
55
+ const entrance = spring({ fps, frame, config: { damping: 200, stiffness: 140 } });
56
+ const opacity = interpolate(entrance, [0, 1], [0.35, 1]);
57
+ const lift = interpolate(entrance, [0, 1], [22, 0]);
58
+
59
+ return (
60
+ <AbsoluteFill>
61
+ <AbsoluteFill style={{ opacity, transform: `translateY(${lift}px)` }}>
62
+ <Img src={slide.imageUrl} style={{ width: "100%", height: "100%", objectFit: "fill" }} />
63
+ </AbsoluteFill>
64
+ <AbsoluteFill style={{ background: "linear-gradient(180deg, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.06) 36%, rgba(0,0,0,0.34) 100%)" }} />
65
+ </AbsoluteFill>
66
+ );
67
+ };
68
+
69
+ function msToFrames(durationMs: number, fps: number) {
70
+ return Math.max(1, Math.round((durationMs / 1000) * fps));
71
+ }
72
+
73
+ function sumSlideFrames(slides: Slide[], fps: number) {
74
+ return slides.reduce((total, slide) => total + msToFrames(slide.durationMs, fps), 0);
75
+ }
@@ -0,0 +1,4 @@
1
+ import { registerRoot } from "remotion";
2
+ import { RemotionRoot } from "./Root.js";
3
+
4
+ registerRoot(RemotionRoot);
@@ -0,0 +1,122 @@
1
+ import type { ZodTypeAny } from "zod";
2
+
3
+ export type ProviderType = "openai" | "gemini" | "openrouter" | "perplexity";
4
+
5
+ export interface TemplateOperationDefinition {
6
+ description: string;
7
+ inputSchema: ZodTypeAny;
8
+ workflow: string;
9
+ providerHint?: ProviderType;
10
+ webhookSupport?: boolean;
11
+ smokeTestPayload?: Record<string, unknown>;
12
+ }
13
+
14
+ export interface TemplateAboutMetadata {
15
+ title: string;
16
+ description: string;
17
+ viral_dna: string;
18
+ visual_dna: string;
19
+ preview_media: string[];
20
+ link_to_original: string;
21
+ }
22
+
23
+ export interface JobExecutionResult {
24
+ progress?: number;
25
+ output?: Record<string, unknown>;
26
+ }
27
+
28
+ export interface TemplateDefinition {
29
+ id: string;
30
+ slugId: string;
31
+ version: string;
32
+ about: TemplateAboutMetadata;
33
+ configSchema: ZodTypeAny;
34
+ skillPath?: string;
35
+ operations: Record<string, TemplateOperationDefinition>;
36
+ jobs: Record<string, (ctx: TemplateJobContext, input: Record<string, unknown>) => Promise<JobExecutionResult>>;
37
+ }
38
+
39
+ export interface TemplateJobContext {
40
+ env: "development" | "production";
41
+ customer: {
42
+ id: string;
43
+ email: string;
44
+ name: string;
45
+ defaultWebhookUrl: string | null;
46
+ };
47
+ templateConfig: Record<string, unknown>;
48
+ logger: {
49
+ debug(message: string, metadata?: Record<string, unknown>): void;
50
+ info(message: string, metadata?: Record<string, unknown>): void;
51
+ warn(message: string, metadata?: Record<string, unknown>): void;
52
+ error(message: string, metadata?: Record<string, unknown>): void;
53
+ progress(progress: number, message: string, metadata?: Record<string, unknown>): void;
54
+ };
55
+ jobs: {
56
+ enqueueChild(input: {
57
+ operationName: string;
58
+ workflowName: string;
59
+ payload: Record<string, unknown>;
60
+ providerHint?: ProviderType;
61
+ }): Promise<{ jobId: string }>;
62
+ };
63
+ storage: {
64
+ putJson(key: string, value: unknown): Promise<{ key: string; url: string | null }>;
65
+ putText(key: string, value: string, contentType?: string): Promise<{ key: string; url: string | null }>;
66
+ putBuffer(
67
+ key: string,
68
+ value: Uint8Array,
69
+ options?: { contentType?: string; kind?: string; metadata?: Record<string, unknown> }
70
+ ): Promise<{ key: string; url: string | null }>;
71
+ getPublicUrl(key: string): string | null;
72
+ };
73
+ billing: {
74
+ record(input: {
75
+ type: "ai_generation" | "render" | "storage_write" | "cpu_estimate";
76
+ costUsd: number;
77
+ chargeUsd?: number;
78
+ metadata?: Record<string, unknown>;
79
+ }): Promise<void>;
80
+ };
81
+ providers: {
82
+ generateText(input: {
83
+ provider: ProviderType;
84
+ model: string;
85
+ prompt: string;
86
+ temperature?: number;
87
+ }): Promise<{ text: string; usage: { inputTokens: number; outputTokens: number; costUsd: number } }>;
88
+ generateImage(input: {
89
+ provider: ProviderType;
90
+ model: string;
91
+ prompt: string;
92
+ promptAttachments?: string[];
93
+ size?: string;
94
+ aspectRatio?: string;
95
+ imageSize?: "1K" | "2K" | "4K";
96
+ }): Promise<{ bytes: Uint8Array; contentType: string; revisedPrompt: string | null }>;
97
+ analyzeImageLayout(input: {
98
+ provider: ProviderType;
99
+ model: string;
100
+ imageUrl: string;
101
+ overlayText: string;
102
+ }): Promise<{
103
+ zone: "top" | "center" | "bottom";
104
+ align: "left" | "center" | "right";
105
+ maxWidthPercent: number;
106
+ justification: string;
107
+ }>;
108
+ };
109
+ remotion: {
110
+ render(input: {
111
+ compositionId: string;
112
+ serveUrl?: string;
113
+ entryPoint?: string;
114
+ outputKey?: string;
115
+ inputProps: Record<string, unknown>;
116
+ }): Promise<{ renderId: string; outputUrl: string | null; metadata: Record<string, unknown> }>;
117
+ };
118
+ }
119
+
120
+ export function defineTemplate(definition: TemplateDefinition): TemplateDefinition {
121
+ return definition;
122
+ }