@mevdragon/vidfarm-devcli 0.7.0 → 0.7.1

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.
@@ -425,6 +425,8 @@ function PreviewModal(input) {
425
425
  function AddTemplateModal(input) {
426
426
  const { onClose } = input;
427
427
  const [sourceUrl, setSourceUrl] = useState("");
428
+ const [file, setFile] = useState(null);
429
+ const [title, setTitle] = useState("");
428
430
  const [tagline, setTagline] = useState("");
429
431
  const [notes, setNotes] = useState("");
430
432
  const [submitting, setSubmitting] = useState(false);
@@ -445,13 +447,17 @@ function AddTemplateModal(input) {
445
447
  }, [onClose]);
446
448
  const handleSubmit = async () => {
447
449
  const trimmedUrl = sourceUrl.trim();
448
- if (!trimmedUrl) {
449
- setError("Paste a TikTok, YouTube, Twitter/X, or Instagram video URL.");
450
+ if (!trimmedUrl && !file) {
451
+ setError("Paste a TikTok, YouTube, Twitter/X, or Instagram video URL — or upload a video file.");
452
+ return;
453
+ }
454
+ if (file && file.size > 200 * 1024 * 1024) {
455
+ setError("Video uploads can be at most 200 MB.");
450
456
  return;
451
457
  }
452
458
  setSubmitting(true);
453
459
  setError(null);
454
- const result = await (input.onAddTemplate?.({ sourceUrl: trimmedUrl, tagline: tagline.trim(), notes: notes.trim() })
460
+ const result = await (input.onAddTemplate?.({ sourceUrl: file ? "" : trimmedUrl, file, title: title.trim(), tagline: tagline.trim(), notes: notes.trim() })
455
461
  ?? Promise.resolve({ ok: false, error: "Adding templates is unavailable right now." }));
456
462
  setSubmitting(false);
457
463
  if (result.ok) {
@@ -464,7 +470,14 @@ function AddTemplateModal(input) {
464
470
  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
471
  event.preventDefault();
466
472
  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" })] })] })] }) }));
473
+ }, 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) => {
474
+ const selected = event.target.files?.[0] ?? null;
475
+ if (selected) {
476
+ setFile(selected);
477
+ setSourceUrl("");
478
+ setError(null);
479
+ }
480
+ } }))] }), 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
481
  }
469
482
  export function HomepageShell(input) {
470
483
  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"
@@ -13,7 +13,7 @@ import ffprobeStatic from "ffprobe-static";
13
13
  import { parseHTML } from "linkedom";
14
14
  import { config } from "../config.js";
15
15
  import { devErrorFields, devLog } from "../lib/dev-log.js";
16
- import { buildHyperframeCompositionHtml } from "../hyperframes/composition.js";
16
+ import { KEN_BURNS_CSS, KEN_BURNS_STYLE_MARKER, buildHyperframeCompositionHtml } from "../hyperframes/composition.js";
17
17
  import { applyMarkupUsd } from "./billing-pricing.js";
18
18
  let stackCache = null;
19
19
  const SMART_DECOMPOSE_TEMP_PREFIX = "vidfarm-smart-decompose-";
@@ -2027,6 +2027,31 @@ export function normalizePublishHtml(html) {
2027
2027
  continue;
2028
2028
  html.style.zIndex = String(trackIndex);
2029
2029
  }
2030
+ // Ken Burns motion must span exactly the clip: timeline edits touch
2031
+ // data-duration only, so re-derive the inline --vf-kb-dur (which drives
2032
+ // animation-duration) from data-duration on every [data-kenburns] layer.
2033
+ // Also make sure the shared keyframes stylesheet travels with compositions
2034
+ // stored before the builder started embedding it.
2035
+ const kenBurnsLayers = Array.from(document.querySelectorAll("[data-kenburns]"));
2036
+ for (const el of kenBurnsLayers) {
2037
+ const duration = Number.parseFloat(el.getAttribute("data-duration") || "");
2038
+ if (!Number.isFinite(duration) || duration <= 0)
2039
+ continue;
2040
+ const styleValue = el.getAttribute("style") || "";
2041
+ const durDecl = `--vf-kb-dur:${Number(duration.toFixed(3))}s`;
2042
+ const nextStyle = /(^|;)\s*--vf-kb-dur\s*:[^;]*/i.test(styleValue)
2043
+ ? styleValue.replace(/(^|;)\s*--vf-kb-dur\s*:[^;]*/i, (_match, lead) => `${lead}${durDecl}`)
2044
+ : styleValue.length === 0 || styleValue.endsWith(";")
2045
+ ? `${styleValue}${durDecl}`
2046
+ : `${styleValue};${durDecl}`;
2047
+ if (nextStyle !== styleValue)
2048
+ el.setAttribute("style", nextStyle);
2049
+ }
2050
+ if (kenBurnsLayers.length > 0 && !html.includes(KEN_BURNS_STYLE_MARKER)) {
2051
+ const styleEl = document.createElement("style");
2052
+ styleEl.textContent = KEN_BURNS_CSS;
2053
+ (document.head ?? document.documentElement)?.appendChild(styleEl);
2054
+ }
2030
2055
  // Editor sessions occasionally leave inline style attributes with double
2031
2056
  // quotes inside CSS values (font-family: "TikTok Sans"). When serialized into
2032
2057
  // an HTML attribute, those quotes become &quot; entities. The render lambda
@@ -30,6 +30,12 @@ export class StorageService {
30
30
  legacyUserAttachmentKey(customerId, attachmentId, fileName) {
31
31
  return joinStorageKey("users", customerId, attachmentId, fileName);
32
32
  }
33
+ // Add Template → upload a video file: staging area for uploaded inspiration
34
+ // sources. Lives under users/ so the bucket's public-read policy covers it —
35
+ // the video_ingest job and the editor fetch it by plain URL.
36
+ inspirationUploadKey(customerId, uploadId, fileName) {
37
+ return joinStorageKey("users", customerId, "inspiration-uploads", uploadId, fileName);
38
+ }
33
39
  userTemporaryFileKey(customerId, fileId, fileName, folderPath) {
34
40
  return joinStorageKey("users", customerId, "temporary", folderPath || "", fileId, fileName);
35
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevdragon/vidfarm-devcli",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {