@mevdragon/vidfarm-devcli 0.7.0 → 0.8.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 (37) hide show
  1. package/SKILL.director.md +43 -10
  2. package/SKILL.platform.md +5 -3
  3. package/demo/dist/app.js +107 -57
  4. package/dist/src/app.js +1115 -17
  5. package/dist/src/cli.js +304 -43
  6. package/dist/src/composition-runtime.js +26 -0
  7. package/dist/src/config.js +6 -0
  8. package/dist/src/devcli/clip-store.js +335 -0
  9. package/dist/src/devcli/clips.js +803 -0
  10. package/dist/src/devcli/composition-edit.js +12 -0
  11. package/dist/src/editor-chat.js +1 -0
  12. package/dist/src/frontend/homepage-client.js +61 -6
  13. package/dist/src/frontend/homepage-view.js +22 -4
  14. package/dist/src/homepage.js +46 -0
  15. package/dist/src/hyperframes/composition.js +87 -0
  16. package/dist/src/primitive-registry.js +136 -0
  17. package/dist/src/services/billing.js +1 -0
  18. package/dist/src/services/clip-curation/cost.js +112 -0
  19. package/dist/src/services/clip-curation/ffmpeg.js +258 -0
  20. package/dist/src/services/clip-curation/gemini.js +342 -0
  21. package/dist/src/services/clip-curation/index.js +15 -0
  22. package/dist/src/services/clip-curation/presets.js +20 -0
  23. package/dist/src/services/clip-curation/presets.v1.json +59 -0
  24. package/dist/src/services/clip-curation/query.js +163 -0
  25. package/dist/src/services/clip-curation/refine.js +136 -0
  26. package/dist/src/services/clip-curation/scan.js +137 -0
  27. package/dist/src/services/clip-curation/taxonomy.js +71 -0
  28. package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
  29. package/dist/src/services/clip-curation/types.js +7 -0
  30. package/dist/src/services/clip-records.js +209 -0
  31. package/dist/src/services/clip-search.js +47 -0
  32. package/dist/src/services/clip-vectors.js +125 -0
  33. package/dist/src/services/hyperframes.js +373 -1
  34. package/dist/src/services/scene-annotations.js +32 -0
  35. package/dist/src/services/storage.js +6 -0
  36. package/package.json +5 -1
  37. package/public/assets/homepage-client-app.js +18 -18
@@ -6,6 +6,9 @@
6
6
  // pushes them identically to a browser-made edit. Pure DOM (linkedom) — no
7
7
  // ffmpeg, no network.
8
8
  import { parseHTML } from "linkedom";
9
+ const KEN_BURNS_PRESETS = new Set([
10
+ "zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right"
11
+ ]);
9
12
  const round3 = (v) => Number(v.toFixed(3));
10
13
  const clampPercent = (v) => Math.min(100, Math.max(0, v));
11
14
  function serialize(document) {
@@ -209,6 +212,15 @@ function buildMediaClip(document, opts, id, track, geom) {
209
212
  node.setAttribute("data-playback-start", ps);
210
213
  styles.push("background:#050604");
211
214
  }
215
+ else if (opts.kenBurns && KEN_BURNS_PRESETS.has(opts.kenBurns)) {
216
+ // Ken Burns lives on the <img> itself (see src/hyperframes/composition.ts):
217
+ // data-kenburns picks the keyframes; the inline vars supply span + strength.
218
+ node.setAttribute("data-kenburns", opts.kenBurns);
219
+ styles.push(`--vf-kb-dur:${round3(geom.duration)}s`);
220
+ if (opts.kenBurnsIntensity !== undefined && Number.isFinite(opts.kenBurnsIntensity)) {
221
+ styles.push(`--vf-kb-amount:${Math.max(0.04, Math.min(0.5, opts.kenBurnsIntensity))}`);
222
+ }
223
+ }
212
224
  node.setAttribute("style", `${styles.join(";")};`);
213
225
  return node;
214
226
  }
@@ -77,6 +77,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
77
77
  "If the composition brief's cast is absent or empty, or the referenced person is not in it, fall back to extracting a still yourself: call GET /api/v1/compositions/:forkId/remove-video-captions to pick the correct source video, then POST /api/v1/primitives/videos/extract-frame with source_video_url set to that URL and at_ms at a moment the person is clearly on screen (use video_context scene timings), then feed the returned image URL as generate_layer input_references (video) or prompt_attachments (image) exactly as above. Prefer the cast reference_url when present because it is already isolated from other people and reusable.",
78
78
  "When placing generated character media on the timeline, prefer generate_layer (it applies the placement rules for you): to drop the character into a blank moment use intent='fill_gap' with start/duration on a timeline_gaps span; to swap them into an existing scene use intent='replace_layer' with replace_layer_key (its timing and full-canvas geometry are copied automatically). Always set aspect_ratio to the canvas (e.g. 9:16 for vertical) so the clip fills the frame. Only when you already have a finished media URL should you fall back to add_layer with explicit collision-free start/duration and full-canvas geometry (x=0, y=0, width=100, height=100) for a timeline-proxy/full-canvas replacement.",
79
79
  "Use set_layer_visual for geometry (x, y, width, height in %, plus font_size and border_radius in px). Use set_layer_style for text/color styling (color, background, background_style plain|outline|highlight-solid|highlight-translucent, font_family, font_weight, italic, underline, text_align, border_radius, font_size). Use set_layer_media to swap src, volume, muted, playback_start, or object_fit on media layers. Use set_layer_timing for start, duration, track index, playback_start.",
80
+ "KEN BURNS / ANIMATE STILL IMAGES: every still-image layer supports a built-in Ken Burns effect — a slow pan or zoom that spans the clip's full duration in both the preview and the final render. Apply it with editor_action set_layer_media and the ken_burns field (presets: zoom-in, zoom-out, pan-left, pan-right, pan-up, pan-down, zoom-in-left, zoom-in-right; 'none' removes it), optionally ken_burns_intensity (0.04-0.5, default 0.18; ~0.1 subtle, ~0.3 dramatic). When the user says 'animate the images', 'make the photos move', 'ken burns', or similar, apply a preset to EVERY still-image layer in one pass (one set_layer_media call per layer), varying presets across adjacent clips — e.g. zoom-in, then pan-left, then zoom-out — so consecutive stills don't repeat the same motion. Zoom presets suit subjects centered in frame (products, faces); pan presets suit wide scenery or tall screenshots. You can also seed ken_burns directly on add_layer (kind=image) or generate_layer (media_type=image) so freshly placed stills arrive already animated. It only applies to image layers — never set it on video, text, or audio.",
80
81
  "Use duplicate_layer to clone an existing layer; supply layer_key for the source, and optionally start/duration/track/label on the duplicate. Use split_layer with layer_key and split_time to cut at a moment in the timeline. Use group_layers with layer_keys (>=2) and optional group_label; ungroup_layers with layer_keys of any grouped members.",
81
82
  "Prefer one editor_action call per mutation. For a multi-layer composition, sequence calls: generate or fetch each asset, then add or edit each layer with its own editor_action call. Chain edits by reusing the layer_key you assigned during add_layer.",
82
83
  "Layer/track ordering is the ONLY thing that controls visual stacking. `track` is both the timeline layer index AND the CSS z-index — the editor forces `z-index = track` on every layer at publish time. Higher track draws visually on top; lower track draws underneath. There is no separate z-index override — if the AI wants a layer above another, give it a higher track. If html/text overlays should sit ABOVE a video/image, their track must be a larger integer than the visual media below them.",
@@ -142,21 +142,76 @@ function HomepageClientApp({ boot }) {
142
142
  }, [refreshFeed]);
143
143
  const handleAddTemplate = useCallback(async (submission) => {
144
144
  try {
145
+ // Upload path: presign → PUT the bytes (straight to S3, or the local
146
+ // multipart fallback) → finalize with the storage key. The URL path
147
+ // posts source_url directly, exactly as before.
148
+ let uploadRef = null;
149
+ if (submission.file) {
150
+ const file = submission.file;
151
+ const presignResponse = await fetch("/discover/templates/upload/presign", {
152
+ method: "POST",
153
+ credentials: "same-origin",
154
+ headers: { "content-type": "application/json", Accept: "application/json" },
155
+ body: JSON.stringify({
156
+ file_name: file.name,
157
+ content_type: file.type || null,
158
+ size_bytes: file.size
159
+ })
160
+ });
161
+ const presign = await presignResponse.json().catch(() => ({}));
162
+ if (!presignResponse.ok) {
163
+ return { ok: false, error: presign.error || "Unable to start the video upload." };
164
+ }
165
+ if (presign.transport === "presigned" && presign.upload?.url && presign.storage_key) {
166
+ const put = await fetch(presign.upload.url, {
167
+ method: presign.upload.method || "PUT",
168
+ headers: presign.upload.headers || {},
169
+ body: file
170
+ });
171
+ if (!put.ok) {
172
+ return { ok: false, error: `Video upload failed (${put.status}). Try again.` };
173
+ }
174
+ uploadRef = { storage_key: presign.storage_key, file_name: presign.file_name || file.name };
175
+ }
176
+ else {
177
+ const form = new FormData();
178
+ form.append("file", file, presign.file_name || file.name);
179
+ const direct = await fetch(presign.upload?.url || "/discover/templates/upload", {
180
+ method: "POST",
181
+ credentials: "same-origin",
182
+ body: form
183
+ });
184
+ const directPayload = await direct.json().catch(() => ({}));
185
+ if (!direct.ok || !directPayload.storage_key) {
186
+ return { ok: false, error: directPayload.error || "Video upload failed. Try again." };
187
+ }
188
+ uploadRef = { storage_key: directPayload.storage_key, file_name: directPayload.file_name || file.name };
189
+ }
190
+ }
145
191
  const response = await fetch("/discover/templates", {
146
192
  method: "POST",
147
193
  credentials: "same-origin",
148
194
  headers: { "content-type": "application/json", Accept: "application/json" },
149
- body: JSON.stringify({
150
- source_url: submission.sourceUrl,
151
- tagline: submission.tagline || null,
152
- notes: submission.notes || null
153
- })
195
+ body: JSON.stringify(uploadRef
196
+ ? {
197
+ upload: uploadRef,
198
+ title: submission.title || null,
199
+ tagline: submission.tagline || null,
200
+ notes: submission.notes || null
201
+ }
202
+ : {
203
+ source_url: submission.sourceUrl,
204
+ tagline: submission.tagline || null,
205
+ notes: submission.notes || null
206
+ })
154
207
  });
155
208
  const payload = await response.json().catch(() => ({}));
156
209
  if (!response.ok) {
157
210
  return { ok: false, error: typeof payload.error === "string" && payload.error ? payload.error : "Unable to add that video." };
158
211
  }
159
- debugLog("homepage.template_added", { source_url: submission.sourceUrl });
212
+ debugLog("homepage.template_added", uploadRef
213
+ ? { storage_key: uploadRef.storage_key }
214
+ : { source_url: submission.sourceUrl });
160
215
  await refreshFeed();
161
216
  return { ok: true };
162
217
  }
@@ -11,6 +11,9 @@ function LibraryIcon() {
11
11
  function CalendarIcon() {
12
12
  return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("circle", { cx: "10", cy: "10", r: "7.2" }), _jsx("path", { d: "M10 6.3v4.1l2.8 1.7" })] });
13
13
  }
14
+ function ClipsIcon() {
15
+ return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { x: "3", y: "4.5", width: "14", height: "11", rx: "1.5" }), _jsx("path", { d: "M3 8h14" }), _jsx("path", { d: "M6.2 4.7 4.6 8" }), _jsx("path", { d: "M9.6 4.7 8 8" }), _jsx("path", { d: "M13 4.7 11.4 8" })] });
16
+ }
14
17
  function ChatIcon() {
15
18
  return _jsxs("svg", { viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M4.2 5.2a2 2 0 0 1 2-2h7.6a2 2 0 0 1 2 2v5.6a2 2 0 0 1-2 2H9.2l-4.1 3.1v-3.1a2 2 0 0 1-.9-1.7Z" }), _jsx("path", { d: "M7.3 7.1h5.4" }), _jsx("path", { d: "M7.3 9.6h3.7" })] });
16
19
  }
@@ -55,11 +58,13 @@ function PrimaryNav({ account }) {
55
58
  ? [
56
59
  { id: "discover", href: withAccountQuery("/discover", account.userId), label: "Discover", icon: _jsx(DiscoverIcon, {}) },
57
60
  { id: "library", href: withAccountQuery("/library", account.userId), label: "Library", icon: _jsx(LibraryIcon, {}) },
61
+ { id: "clips", href: "/clips", label: "Clips", icon: _jsx(ClipsIcon, {}) },
58
62
  { id: "calendar", href: withAccountQuery("/calendar", account.userId), label: "Calendar", icon: _jsx(CalendarIcon, {}) },
59
63
  { id: "settings", href: withAccountQuery("/settings", account.userId), label: "Settings", icon: _jsx(SettingsIcon, {}) }
60
64
  ]
61
65
  : [
62
66
  { id: "discover", href: "/discover", label: "Discover", icon: _jsx(DiscoverIcon, {}) },
67
+ { id: "clips", href: "/clips", label: "Clips", icon: _jsx(ClipsIcon, {}) },
63
68
  { id: "login", href: "/login", label: "Login", icon: _jsx(SettingsIcon, {}) }
64
69
  ];
65
70
  const menuItems = account.isLoggedIn
@@ -425,6 +430,8 @@ function PreviewModal(input) {
425
430
  function AddTemplateModal(input) {
426
431
  const { onClose } = input;
427
432
  const [sourceUrl, setSourceUrl] = useState("");
433
+ const [file, setFile] = useState(null);
434
+ const [title, setTitle] = useState("");
428
435
  const [tagline, setTagline] = useState("");
429
436
  const [notes, setNotes] = useState("");
430
437
  const [submitting, setSubmitting] = useState(false);
@@ -445,13 +452,17 @@ function AddTemplateModal(input) {
445
452
  }, [onClose]);
446
453
  const handleSubmit = async () => {
447
454
  const trimmedUrl = sourceUrl.trim();
448
- if (!trimmedUrl) {
449
- setError("Paste a TikTok, YouTube, Twitter/X, or Instagram video URL.");
455
+ if (!trimmedUrl && !file) {
456
+ setError("Paste a TikTok, YouTube, Twitter/X, or Instagram video URL — or upload a video file.");
457
+ return;
458
+ }
459
+ if (file && file.size > 200 * 1024 * 1024) {
460
+ setError("Video uploads can be at most 200 MB.");
450
461
  return;
451
462
  }
452
463
  setSubmitting(true);
453
464
  setError(null);
454
- const result = await (input.onAddTemplate?.({ sourceUrl: trimmedUrl, tagline: tagline.trim(), notes: notes.trim() })
465
+ const result = await (input.onAddTemplate?.({ sourceUrl: file ? "" : trimmedUrl, file, title: title.trim(), tagline: tagline.trim(), notes: notes.trim() })
455
466
  ?? Promise.resolve({ ok: false, error: "Adding templates is unavailable right now." }));
456
467
  setSubmitting(false);
457
468
  if (result.ok) {
@@ -464,7 +475,14 @@ function AddTemplateModal(input) {
464
475
  return (_jsx("div", { className: "discover-media-modal-backdrop", role: "presentation", onClick: onClose, children: _jsxs("div", { className: "discover-media-modal discover-add-template-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "discover-add-template-title", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "discover-media-modal-head", children: [_jsx("h2", { id: "discover-add-template-title", children: "Add Template" }), _jsx("div", { className: "discover-media-modal-actions", children: _jsx("button", { className: "discover-media-modal-action", type: "button", "aria-label": "Close", title: "Close", onClick: onClose, children: _jsx(CloseIcon, {}) }) })] }), _jsxs("form", { className: "discover-add-template-form", onSubmit: (event) => {
465
476
  event.preventDefault();
466
477
  void handleSubmit();
467
- }, children: [_jsx("p", { className: "discover-add-template-hint", children: "Paste a link to a TikTok, YouTube, Twitter/X, or Instagram video. We download it and add it to your Discover feed \u2014 visible only to your account." }), _jsxs("div", { className: "discover-add-template-field", children: [_jsx("label", { className: "label", htmlFor: "add-template-url", children: "Video URL" }), _jsx("input", { id: "add-template-url", type: "url", required: true, placeholder: "https://www.tiktok.com/@creator/video/\u2026", autoComplete: "off", value: sourceUrl, onChange: (event) => setSourceUrl(event.target.value) })] }), _jsxs("div", { className: "discover-add-template-field", children: [_jsxs("label", { className: "label", htmlFor: "add-template-tagline", children: ["Tagline ", _jsx("span", { className: "discover-add-template-optional", children: "optional" })] }), _jsx("input", { id: "add-template-tagline", type: "text", placeholder: "What makes this video work?", autoComplete: "off", maxLength: 140, value: tagline, onChange: (event) => setTagline(event.target.value) })] }), _jsxs("div", { className: "discover-add-template-field", children: [_jsxs("label", { className: "label", htmlFor: "add-template-notes", children: ["Notes ", _jsx("span", { className: "discover-add-template-optional", children: "optional" })] }), _jsx("textarea", { id: "add-template-notes", rows: 4, placeholder: "Ideas for how you want to remix this\u2026", maxLength: 2000, value: notes, onChange: (event) => setNotes(event.target.value) })] }), error ? _jsx("p", { className: "discover-add-template-error", role: "alert", children: error }) : null, _jsxs("div", { className: "toolbar discover-add-template-actions", children: [_jsx("button", { type: "button", className: "secondary", onClick: onClose, disabled: submitting, children: "Cancel" }), _jsx("button", { type: "submit", className: "cta-button discover-add-template-submit", disabled: submitting, children: submitting ? "Adding…" : "Add Template" })] })] })] }) }));
478
+ }, children: [_jsx("p", { className: "discover-add-template-hint", children: "Paste a link to a TikTok, YouTube, Twitter/X, or Instagram video \u2014 or upload a video file from your computer. We add it to your Discover feed, visible only to your account." }), _jsxs("div", { className: "discover-add-template-field", children: [_jsx("label", { className: "label", htmlFor: "add-template-url", children: "Video URL" }), _jsx("input", { id: "add-template-url", type: "url", required: !file, disabled: Boolean(file), placeholder: "https://www.tiktok.com/@creator/video/\u2026", autoComplete: "off", value: sourceUrl, onChange: (event) => setSourceUrl(event.target.value) })] }), _jsxs("div", { className: "discover-add-template-field", children: [_jsxs("label", { className: "label", htmlFor: "add-template-file", children: ["Or upload a video ", _jsx("span", { className: "discover-add-template-optional", children: "MP4 / MOV / WebM, up to 200 MB" })] }), file ? (_jsxs("div", { className: "discover-add-template-file-chip", children: [_jsx("span", { className: "discover-add-template-file-name", children: file.name }), _jsxs("span", { className: "discover-add-template-file-size", children: [(file.size / (1024 * 1024)).toFixed(1), " MB"] }), _jsx("button", { type: "button", className: "discover-add-template-file-clear", "aria-label": "Remove selected file", onClick: () => setFile(null), children: "\u2715" })] })) : (_jsx("input", { id: "add-template-file", type: "file", accept: "video/mp4,video/quicktime,video/webm,.mp4,.m4v,.mov,.webm", onChange: (event) => {
479
+ const selected = event.target.files?.[0] ?? null;
480
+ if (selected) {
481
+ setFile(selected);
482
+ setSourceUrl("");
483
+ setError(null);
484
+ }
485
+ } }))] }), file ? (_jsxs("div", { className: "discover-add-template-field", children: [_jsxs("label", { className: "label", htmlFor: "add-template-title", children: ["Title ", _jsx("span", { className: "discover-add-template-optional", children: "optional \u2014 defaults to the template id" })] }), _jsx("input", { id: "add-template-title", type: "text", placeholder: "Name this template\u2026", autoComplete: "off", maxLength: 140, value: title, onChange: (event) => setTitle(event.target.value) })] })) : null, _jsxs("div", { className: "discover-add-template-field", children: [_jsxs("label", { className: "label", htmlFor: "add-template-tagline", children: ["Tagline ", _jsx("span", { className: "discover-add-template-optional", children: "optional" })] }), _jsx("input", { id: "add-template-tagline", type: "text", placeholder: "What makes this video work?", autoComplete: "off", maxLength: 140, value: tagline, onChange: (event) => setTagline(event.target.value) })] }), _jsxs("div", { className: "discover-add-template-field", children: [_jsxs("label", { className: "label", htmlFor: "add-template-notes", children: ["Notes ", _jsx("span", { className: "discover-add-template-optional", children: "optional" })] }), _jsx("textarea", { id: "add-template-notes", rows: 4, placeholder: "Ideas for how you want to remix this\u2026", maxLength: 2000, value: notes, onChange: (event) => setNotes(event.target.value) })] }), error ? _jsx("p", { className: "discover-add-template-error", role: "alert", children: error }) : null, _jsxs("div", { className: "toolbar discover-add-template-actions", children: [_jsx("button", { type: "button", className: "secondary", onClick: onClose, disabled: submitting, children: "Cancel" }), _jsx("button", { type: "submit", className: "cta-button discover-add-template-submit", disabled: submitting, children: submitting ? (file ? "Uploading…" : "Adding…") : "Add Template" })] })] })] }) }));
468
486
  }
469
487
  export function HomepageShell(input) {
470
488
  const showEmpty = !input.loading && !input.error && input.templates.length > 0 && input.visibleTemplates.length === 0;
@@ -205,6 +205,52 @@ export function renderHomepage(input) {
205
205
  letter-spacing: normal;
206
206
  }
207
207
 
208
+ .discover-add-template-field input[type="file"] {
209
+ padding: 8px 10px;
210
+ cursor: pointer;
211
+ }
212
+
213
+ .discover-add-template-file-chip {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 10px;
217
+ padding: 10px 14px;
218
+ border: 1px solid rgba(209, 219, 233, 0.96);
219
+ border-radius: 14px;
220
+ background: rgba(255, 255, 255, 0.94);
221
+ color: #2d3748;
222
+ font-size: 0.94rem;
223
+ }
224
+
225
+ .discover-add-template-file-name {
226
+ flex: 1;
227
+ min-width: 0;
228
+ overflow: hidden;
229
+ text-overflow: ellipsis;
230
+ white-space: nowrap;
231
+ }
232
+
233
+ .discover-add-template-file-size {
234
+ color: #9aa6ba;
235
+ font-size: 0.86rem;
236
+ white-space: nowrap;
237
+ }
238
+
239
+ .discover-add-template-file-clear {
240
+ border: none;
241
+ background: none;
242
+ color: #6a7890;
243
+ font-size: 0.9rem;
244
+ cursor: pointer;
245
+ padding: 2px 6px;
246
+ border-radius: 8px;
247
+ }
248
+
249
+ .discover-add-template-file-clear:hover {
250
+ background: rgba(209, 219, 233, 0.5);
251
+ color: #2d3748;
252
+ }
253
+
208
254
  .discover-add-template-error {
209
255
  margin: 0;
210
256
  color: #a14444;
@@ -1,3 +1,80 @@
1
+ export const KEN_BURNS_PRESETS = [
2
+ "zoom-in",
3
+ "zoom-out",
4
+ "pan-left",
5
+ "pan-right",
6
+ "pan-up",
7
+ "pan-down",
8
+ "zoom-in-left",
9
+ "zoom-in-right"
10
+ ];
11
+ // Ken Burns is stored declaratively: the still-image layer element (the <img>
12
+ // that carries data-start) also carries data-kenburns="<preset>" plus optional
13
+ // inline vars (--vf-kb-amount / --vf-kb-origin / --vf-kb-dur). The animation
14
+ // runs directly on that <img> via CSS transform, so the ONE element that owns
15
+ // the timeline (data-start/duration) is also the one that animates — which is
16
+ // exactly what the render's per-frame CSS-animation adapter keys off (it seeks
17
+ // each animation by time-minus-that-element's-data-start). No wrapper, so every
18
+ // existing "image layer is an <img>" invariant in the editor and timeline holds.
19
+ // This static stylesheet supplies the keyframes; any surface that toggles the
20
+ // attribute (builder, editor, chat agent, devcli) gets identical motion in the
21
+ // preview runtime and both render pipelines. The marker comment is the
22
+ // idempotency key for serve/publish-time injection into stored HTML.
23
+ export const KEN_BURNS_STYLE_MARKER = "__VF_KENBURNS_V1__";
24
+ export const KEN_BURNS_CSS = `/*${KEN_BURNS_STYLE_MARKER}*/
25
+ @keyframes vf-kb-zoom-in { from { transform: translate(0%, 0%) scale(1); } to { transform: translate(0%, 0%) scale(calc(1 + var(--vf-kb-amount, 0.18))); } }
26
+ @keyframes vf-kb-zoom-out { from { transform: translate(0%, 0%) scale(calc(1 + var(--vf-kb-amount, 0.18))); } to { transform: translate(0%, 0%) scale(1); } }
27
+ @keyframes vf-kb-pan-left { from { transform: translate(calc(var(--vf-kb-amount, 0.18) * 45%), 0%) scale(calc(1 + var(--vf-kb-amount, 0.18))); } to { transform: translate(calc(var(--vf-kb-amount, 0.18) * -45%), 0%) scale(calc(1 + var(--vf-kb-amount, 0.18))); } }
28
+ @keyframes vf-kb-pan-right { from { transform: translate(calc(var(--vf-kb-amount, 0.18) * -45%), 0%) scale(calc(1 + var(--vf-kb-amount, 0.18))); } to { transform: translate(calc(var(--vf-kb-amount, 0.18) * 45%), 0%) scale(calc(1 + var(--vf-kb-amount, 0.18))); } }
29
+ @keyframes vf-kb-pan-up { from { transform: translate(0%, calc(var(--vf-kb-amount, 0.18) * 45%)) scale(calc(1 + var(--vf-kb-amount, 0.18))); } to { transform: translate(0%, calc(var(--vf-kb-amount, 0.18) * -45%)) scale(calc(1 + var(--vf-kb-amount, 0.18))); } }
30
+ @keyframes vf-kb-pan-down { from { transform: translate(0%, calc(var(--vf-kb-amount, 0.18) * -45%)) scale(calc(1 + var(--vf-kb-amount, 0.18))); } to { transform: translate(0%, calc(var(--vf-kb-amount, 0.18) * 45%)) scale(calc(1 + var(--vf-kb-amount, 0.18))); } }
31
+ @keyframes vf-kb-zoom-in-left { from { transform: translate(0%, 0%) scale(1); } to { transform: translate(calc(var(--vf-kb-amount, 0.18) * -45%), 0%) scale(calc(1 + var(--vf-kb-amount, 0.18))); } }
32
+ @keyframes vf-kb-zoom-in-right { from { transform: translate(0%, 0%) scale(1); } to { transform: translate(calc(var(--vf-kb-amount, 0.18) * 45%), 0%) scale(calc(1 + var(--vf-kb-amount, 0.18))); } }
33
+ [data-kenburns] {
34
+ transform-origin: var(--vf-kb-origin, 50% 50%);
35
+ animation-duration: var(--vf-kb-dur, 5s);
36
+ animation-timing-function: var(--vf-kb-ease, linear);
37
+ animation-fill-mode: both;
38
+ animation-play-state: paused;
39
+ will-change: transform;
40
+ backface-visibility: hidden;
41
+ }
42
+ [data-kenburns="zoom-in"] { animation-name: vf-kb-zoom-in; }
43
+ [data-kenburns="zoom-out"] { animation-name: vf-kb-zoom-out; }
44
+ [data-kenburns="pan-left"] { animation-name: vf-kb-pan-left; }
45
+ [data-kenburns="pan-right"] { animation-name: vf-kb-pan-right; }
46
+ [data-kenburns="pan-up"] { animation-name: vf-kb-pan-up; }
47
+ [data-kenburns="pan-down"] { animation-name: vf-kb-pan-down; }
48
+ [data-kenburns="zoom-in-left"] { animation-name: vf-kb-zoom-in-left; }
49
+ [data-kenburns="zoom-in-right"] { animation-name: vf-kb-zoom-in-right; }
50
+ `;
51
+ // Serializes a Ken Burns config into the inline CSS custom properties the
52
+ // stylesheet reads. Kept next to the CSS so presets and vars never drift.
53
+ export function kenBurnsInlineVars(kenBurns, durationSeconds) {
54
+ const vars = [];
55
+ if (durationSeconds !== undefined && Number.isFinite(durationSeconds) && durationSeconds > 0) {
56
+ vars.push(`--vf-kb-dur:${Number(durationSeconds.toFixed(3))}s`);
57
+ }
58
+ if (kenBurns.intensity !== undefined)
59
+ vars.push(`--vf-kb-amount:${Number(kenBurns.intensity.toFixed(4))}`);
60
+ if (kenBurns.origin)
61
+ vars.push(`--vf-kb-origin:${kenBurns.origin}`);
62
+ return vars.join(";");
63
+ }
64
+ export function normalizeKenBurns(value) {
65
+ if (!value)
66
+ return null;
67
+ const raw = typeof value === "string" ? { preset: value } : value;
68
+ if (!KEN_BURNS_PRESETS.includes(raw.preset))
69
+ return null;
70
+ const intensity = Number(raw.intensity);
71
+ const origin = typeof raw.origin === "string" && raw.origin.trim() ? raw.origin.trim() : undefined;
72
+ return {
73
+ preset: raw.preset,
74
+ intensity: Number.isFinite(intensity) ? Math.max(0.04, Math.min(0.5, intensity)) : undefined,
75
+ origin
76
+ };
77
+ }
1
78
  export function buildHyperframeCompositionHtml(input) {
2
79
  const duration = positiveNumber(input.duration, inferDuration(input.layers));
3
80
  const width = positiveInteger(input.width, 720);
@@ -15,6 +92,7 @@ export function buildHyperframeCompositionHtml(input) {
15
92
  html, body { width:100%; height:100%; margin:0; overflow:hidden; background:${background}; }
16
93
  [data-composition-id] { position:relative; width:100vw; height:100vh; overflow:hidden; background:${background}; }
17
94
  audio { display:none; }
95
+ ${KEN_BURNS_CSS}
18
96
  ${input.css ?? ""}
19
97
  </style>
20
98
  </head>
@@ -50,6 +128,15 @@ function renderLayer(layer) {
50
128
  return `<audio ${attrs} src="${escapeAttr(layer.src || "")}" data-timeline-role="music" data-volume="${num(positiveNumber(layer.volume, 1))}" preload="metadata"></audio>`;
51
129
  }
52
130
  if (layer.kind === "image") {
131
+ const kenBurns = normalizeKenBurns(layer.kenBurns);
132
+ if (kenBurns) {
133
+ // The animation lives directly on the <img> — the same node that carries
134
+ // data-start — so the render's CSS adapter seeks it by (frameTime -
135
+ // data-start) and it plays across exactly the clip's span.
136
+ const vars = kenBurnsInlineVars(kenBurns, positiveNumber(layer.duration, 0));
137
+ const kbStyle = vars ? `${style};${vars}` : style;
138
+ return `<img ${attrs} data-kenburns="${escapeAttr(kenBurns.preset)}" src="${escapeAttr(layer.src || "")}" alt="" style="${escapeAttr(kbStyle)}" />`;
139
+ }
53
140
  return `<img ${attrs} src="${escapeAttr(layer.src || "")}" alt="" style="${escapeAttr(style)}" />`;
54
141
  }
55
142
  if (layer.kind === "html") {
@@ -412,6 +412,14 @@ const downloadVideoPayloadSchema = z.preprocess((raw) => {
412
412
  quality: z.enum(["best", "hd", "full_hd"]).default("best"),
413
413
  save_manifest: z.boolean().default(true)
414
414
  }));
415
+ // Ingest of an already-uploaded video file (Add Template → upload). The URL is
416
+ // server-minted (our own storage), so no social-host restrictions apply.
417
+ const ingestVideoPayloadSchema = z.object({
418
+ source_video_url: z.string().url(),
419
+ title: z.string().min(1).max(300).optional(),
420
+ file_name: z.string().min(1).max(300).optional(),
421
+ save_manifest: z.boolean().default(true)
422
+ });
415
423
  const brainstormHooksPayloadSchema = z.preprocess(normalizeOfferDescriptionPayload, z.object({
416
424
  offer_description: z.string().min(10).max(12_000),
417
425
  count: brainstormCountSchema,
@@ -480,6 +488,7 @@ const PRIMITIVE_VIDEO_GENERATE_ID = "primitive:video_generate";
480
488
  const PRIMITIVE_VIDEO_RENDER_SLIDES_ID = "primitive:video_render_slides";
481
489
  const PRIMITIVE_VIDEO_NORMALIZE_ID = "primitive:video_normalize";
482
490
  const PRIMITIVE_VIDEO_DOWNLOAD_ID = "primitive:video_download";
491
+ const PRIMITIVE_VIDEO_INGEST_ID = "primitive:video_ingest";
483
492
  const PRIMITIVE_VIDEO_TRIM_ID = "primitive:video_trim";
484
493
  const PRIMITIVE_VIDEO_REMOVE_CAPTIONS_ID = "primitive:video_remove_captions";
485
494
  const PRIMITIVE_VIDEO_EXTRACT_AUDIO_ID = "primitive:video_extract_audio";
@@ -1188,6 +1197,132 @@ const videoDownloadPrimitive = definePrimitive({
1188
1197
  }
1189
1198
  }
1190
1199
  });
1200
+ // Upload-based sibling of video_download: the source video already sits in our
1201
+ // storage (uploaded via the Add Template flow), so there is no social lookup —
1202
+ // just mirror it into a durable job artifact, probe duration, and grab a
1203
+ // thumbnail frame. Output shape matches video_download so the inspiration
1204
+ // finalizer treats both identically.
1205
+ const videoIngestPrimitive = definePrimitive({
1206
+ id: PRIMITIVE_VIDEO_INGEST_ID,
1207
+ kind: "video_ingest",
1208
+ operations: {
1209
+ run: {
1210
+ description: "Ingest an uploaded source video file into a durable reusable platform MP4 with probed metadata and a thumbnail.",
1211
+ inputSchema: ingestVideoPayloadSchema,
1212
+ workflow: "run"
1213
+ }
1214
+ },
1215
+ jobs: {
1216
+ async run(ctx, input) {
1217
+ const payload = ingestVideoPayloadSchema.parse(input);
1218
+ const sourceUrl = payload.source_video_url;
1219
+ const sourceHost = safeUrlHost(sourceUrl);
1220
+ ctx.logger.progress(0.12, "Fetching uploaded source video", { sourceUrl, sourceHost });
1221
+ const remoteVideo = await fetchRemoteBinary(sourceUrl, "video/mp4");
1222
+ const contentType = normalizeVideoContentType(remoteVideo.contentType);
1223
+ const extension = videoExtensionForContentType(contentType);
1224
+ const storedVideo = await storePrimitiveVideo(ctx, {
1225
+ key: `source-video.${extension}`,
1226
+ bytes: remoteVideo.bytes,
1227
+ contentType,
1228
+ metadata: {
1229
+ primitive: "video_ingest",
1230
+ operation: "ingest-video",
1231
+ source_url: sourceUrl,
1232
+ source_host: sourceHost,
1233
+ file_name: payload.file_name ?? null
1234
+ }
1235
+ });
1236
+ // Best-effort enrichment: a probe past the duration cap or a failed
1237
+ // thumbnail must not sink the ingest — the card still finalizes and the
1238
+ // template can decompose later.
1239
+ let durationSeconds = null;
1240
+ ctx.logger.progress(0.55, "Probing uploaded video metadata");
1241
+ try {
1242
+ const probed = await executePrimitiveMediaOperation(ctx, {
1243
+ operation: "video_probe",
1244
+ payload: { source_video_url: storedVideo.url },
1245
+ outputKey: "probe.json"
1246
+ });
1247
+ const probedDuration = Number(probed.metadata?.durationSeconds);
1248
+ durationSeconds = Number.isFinite(probedDuration) && probedDuration > 0 ? probedDuration : null;
1249
+ }
1250
+ catch (error) {
1251
+ ctx.logger.progress(0.6, "Video probe skipped", {
1252
+ error: error instanceof Error ? error.message : String(error)
1253
+ });
1254
+ }
1255
+ let thumbnailUrl = null;
1256
+ ctx.logger.progress(0.7, "Extracting thumbnail frame");
1257
+ try {
1258
+ const frame = await executePrimitiveMediaOperation(ctx, {
1259
+ operation: "video_extract_frame",
1260
+ payload: { source_video_url: storedVideo.url, at_ms: 0, output_format: "jpeg" },
1261
+ outputKey: "thumbnail.jpg"
1262
+ });
1263
+ thumbnailUrl = frame.publicUrl;
1264
+ }
1265
+ catch (error) {
1266
+ ctx.logger.progress(0.75, "Thumbnail extraction skipped", {
1267
+ error: error instanceof Error ? error.message : String(error)
1268
+ });
1269
+ }
1270
+ const manifest = {
1271
+ primitive: "video_ingest",
1272
+ operation: "ingest-video",
1273
+ sourceUrl,
1274
+ sourceHost,
1275
+ // Caller-chosen title only — never the file name. Untitled ingests
1276
+ // finalize with title null so the template displays as its id.
1277
+ title: payload.title ?? null,
1278
+ thumbnail: thumbnailUrl,
1279
+ duration: durationSeconds,
1280
+ source: "upload",
1281
+ videoUrl: storedVideo.url
1282
+ };
1283
+ const manifestArtifact = payload.save_manifest
1284
+ ? await ctx.storage.putJson("manifest.json", manifest)
1285
+ : null;
1286
+ await ctx.billing.record({
1287
+ type: "cpu_estimate",
1288
+ costUsd: config.RAPIDAPI_VIDEO_DOWNLOAD_LAMBDA_COST_USD,
1289
+ costCenterSlug: "video_ingest_lambda",
1290
+ idempotencyKey: `video_ingest_lambda:${sourceUrl}:${storedVideo.key}`,
1291
+ occurredAtMs: Date.now(),
1292
+ metadata: {
1293
+ operation: "ingest-video",
1294
+ source_url: sourceUrl,
1295
+ source_host: sourceHost,
1296
+ output_key: storedVideo.key,
1297
+ content_type: contentType,
1298
+ bytes: remoteVideo.bytes.length
1299
+ }
1300
+ });
1301
+ ctx.logger.progress(1, "Video ingest complete", {
1302
+ videoUrl: storedVideo.url,
1303
+ durationSeconds,
1304
+ thumbnailUrl
1305
+ });
1306
+ return {
1307
+ progress: 1,
1308
+ output: {
1309
+ files: compactUrls([storedVideo.url, thumbnailUrl, manifestArtifact?.url]),
1310
+ sourceUrl,
1311
+ sourceHost,
1312
+ videoUrl: storedVideo.url,
1313
+ sourceVideoUrl: storedVideo.url,
1314
+ primary_file_url: storedVideo.url,
1315
+ title: manifest.title,
1316
+ thumbnail: thumbnailUrl,
1317
+ duration: durationSeconds,
1318
+ // Manifest CONTENT (not the artifact record) so the inspiration
1319
+ // finalizer can read thumbnail/title/duration directly.
1320
+ manifest
1321
+ }
1322
+ };
1323
+ }
1324
+ }
1325
+ });
1191
1326
  const videoTrimPrimitive = definePrimitive({
1192
1327
  id: PRIMITIVE_VIDEO_TRIM_ID,
1193
1328
  kind: "video_trim",
@@ -1987,6 +2122,7 @@ class PrimitiveRegistry {
1987
2122
  [mediaDedupePrimitive.id, mediaDedupePrimitive],
1988
2123
  [videoNormalizePrimitive.id, videoNormalizePrimitive],
1989
2124
  [videoDownloadPrimitive.id, videoDownloadPrimitive],
2125
+ [videoIngestPrimitive.id, videoIngestPrimitive],
1990
2126
  [videoTrimPrimitive.id, videoTrimPrimitive],
1991
2127
  [videoRemoveCaptionsPrimitive.id, videoRemoveCaptionsPrimitive],
1992
2128
  [videoExtractAudioPrimitive.id, videoExtractAudioPrimitive],
@@ -162,6 +162,7 @@ function resolveCostCenterSlug(input) {
162
162
  || explicitSlug === "video_normalization"
163
163
  || explicitSlug === "rapidapi_video_download"
164
164
  || explicitSlug === "video_download_lambda"
165
+ || explicitSlug === "video_ingest_lambda"
165
166
  || explicitSlug === "rapidapi_remove_background"
166
167
  || explicitSlug === "primitive_media_lambda"
167
168
  || explicitSlug === "job_runner_lambda"