@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.
- package/SKILL.director.md +43 -10
- package/SKILL.platform.md +5 -3
- package/demo/dist/app.js +106 -56
- package/dist/src/app.js +252 -15
- package/dist/src/cli.js +245 -40
- package/dist/src/composition-runtime.js +26 -0
- package/dist/src/devcli/composition-edit.js +12 -0
- package/dist/src/editor-chat.js +1 -0
- package/dist/src/frontend/homepage-client.js +61 -6
- package/dist/src/frontend/homepage-view.js +17 -4
- package/dist/src/homepage.js +46 -0
- package/dist/src/hyperframes/composition.js +87 -0
- package/dist/src/primitive-registry.js +136 -0
- package/dist/src/services/billing.js +1 -0
- package/dist/src/services/hyperframes.js +26 -1
- package/dist/src/services/storage.js +6 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +18 -18
|
@@ -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
|
|
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;
|
package/dist/src/homepage.js
CHANGED
|
@@ -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 " 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.
|
|
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": {
|