@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.
- package/SKILL.director.md +43 -10
- package/SKILL.platform.md +5 -3
- package/demo/dist/app.js +107 -57
- package/dist/src/app.js +1115 -17
- package/dist/src/cli.js +304 -43
- package/dist/src/composition-runtime.js +26 -0
- package/dist/src/config.js +6 -0
- package/dist/src/devcli/clip-store.js +335 -0
- package/dist/src/devcli/clips.js +803 -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 +22 -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/clip-curation/cost.js +112 -0
- package/dist/src/services/clip-curation/ffmpeg.js +258 -0
- package/dist/src/services/clip-curation/gemini.js +342 -0
- package/dist/src/services/clip-curation/index.js +15 -0
- package/dist/src/services/clip-curation/presets.js +20 -0
- package/dist/src/services/clip-curation/presets.v1.json +59 -0
- package/dist/src/services/clip-curation/query.js +163 -0
- package/dist/src/services/clip-curation/refine.js +136 -0
- package/dist/src/services/clip-curation/scan.js +137 -0
- package/dist/src/services/clip-curation/taxonomy.js +71 -0
- package/dist/src/services/clip-curation/taxonomy.v1.json +90 -0
- package/dist/src/services/clip-curation/types.js +7 -0
- package/dist/src/services/clip-records.js +209 -0
- package/dist/src/services/clip-search.js +47 -0
- package/dist/src/services/clip-vectors.js +125 -0
- package/dist/src/services/hyperframes.js +373 -1
- package/dist/src/services/scene-annotations.js +32 -0
- package/dist/src/services/storage.js +6 -0
- package/package.json +5 -1
- 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
|
}
|
package/dist/src/editor-chat.js
CHANGED
|
@@ -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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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",
|
|
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
|
|
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;
|
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"
|