@mevdragon/vidfarm-devcli 0.6.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.
@@ -146,6 +146,31 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
146
146
  }
147
147
  }
148
148
 
149
+ // Ken Burns image clips carry a CSS transform animation (vf-kb-* keyframes)
150
+ // directly on the [data-kenburns] <img>. CSS animations run on the wall clock,
151
+ // not player time, so the runtime scrubs them: pause + set currentTime from
152
+ // normalized clip progress. Normalizing against data-duration (instead of
153
+ // trusting the inline --vf-kb-dur) keeps the preview correct even while a
154
+ // timeline resize has not yet re-synced the inline duration.
155
+ function syncKenBurnsClip(clip, timing) {
156
+ if (typeof clip.getAnimations !== "function") return;
157
+ const progress = timing.duration > 0
158
+ ? Math.max(0, Math.min(1, (time - timing.start) / timing.duration))
159
+ : 0;
160
+ let anims = [];
161
+ try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
162
+ for (const anim of anims) {
163
+ if (typeof anim.animationName !== "string" || anim.animationName.indexOf("vf-kb-") !== 0) continue;
164
+ let total = 0;
165
+ try { total = Number(anim.effect?.getComputedTiming?.().duration) || 0; } catch {}
166
+ if (!total) continue;
167
+ try {
168
+ anim.pause();
169
+ anim.currentTime = progress * total;
170
+ } catch {}
171
+ }
172
+ }
173
+
149
174
  function apply(nextTime, options = {}) {
150
175
  const forceSeek = Boolean(options.forceSeek);
151
176
  const fromClock = Boolean(options.fromClock);
@@ -160,6 +185,7 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
160
185
  } else {
161
186
  setVisibility(clip, active);
162
187
  }
188
+ if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
163
189
  }
164
190
  if (clip instanceof HTMLMediaElement) {
165
191
  applyMediaState(clip);
@@ -68,6 +68,13 @@ const schema = z.object({
68
68
  GHOSTCUT_KEY: z.string().optional(),
69
69
  GHOSTCUT_SECRET: z.string().optional(),
70
70
  GHOSTCUT_USD_PER_30S: z.coerce.number().min(0).default(0.10),
71
+ // Cloud passthrough for `vidfarm serve`: the local box keeps editing/render
72
+ // fully local but lists the upstream (prod) catalog in /discover and
73
+ // /library, seeds forks on demand, and can hand a render off to the cloud.
74
+ // Set by the serve command from --host + the ambient cloud VIDFARM_API_KEY;
75
+ // never set on deployed environments.
76
+ VIDFARM_UPSTREAM_HOST: z.string().optional(),
77
+ VIDFARM_UPSTREAM_API_KEY: z.string().optional(),
71
78
  RAPIDAPI_KEY: z.string().optional(),
72
79
  RAPIDAPI_VIDEO_DOWNLOAD_URL: z.string().url().default("https://snap-video3.p.rapidapi.com/download"),
73
80
  RAPIDAPI_VIDEO_DOWNLOAD_HOST: z.string().default("snap-video3.p.rapidapi.com"),
@@ -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
  }
@@ -41,9 +41,9 @@ export function buildTemplateEditorChatSystemPrompt(input) {
41
41
  "Use POST /api/v1/primitives/images/edit only when the user wants to revise the attached/source image itself; its body is { tracer, payload: { source_image_url, instruction, reference_attachments? } }. Use POST /api/v1/primitives/images/generate when they want a new image inspired by or similar to the attachment.",
42
42
  "If the user asks to modify an attached image and the message includes an attachment URL, call POST /api/v1/primitives/images/edit with source_image_url inside payload set to that exact URL and instruction inside payload set to the requested edit.",
43
43
  "If the user asks to generate a new AI video from text or reference images, call POST /api/v1/primitives/videos/generate with body { tracer, payload: { prompt, input_references?, duration? } }. AI video duration is seconds; use duration: 4 for a 4-second video and never use duration_ms on /videos/generate. input_references must be direct image asset URLs, not webpage or social post URLs. Use frame_images for exact first/last-frame image-to-video.",
44
- "If the user asks to render HTML/design into an image, call POST /api/v1/primitives/images/render-html. If the user asks to turn existing media URLs into a slideshow/video, call POST /api/v1/primitives/videos/render-slides. If the user asks to dedupe, camouflage, or apply small zoom/tilt/rotate/filter/tint transforms to an image or video for reuse, call POST /api/v1/primitives/media/dedupe with body { tracer, payload: { source_media_url, media_type?, effects?: { zoom?, tilt?, rotate?, saturation?, speed?, horizontal_flip?, contrast?, brightness?, hue_rotate?, blur? }, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Primitive media utility routes are also allowed directly here, including /videos/trim, /videos/extract-audio, /videos/probe, /videos/extract-frame, /videos/mute, /videos/replace-audio, /videos/concat, /audio/trim, /audio/probe, /audio/concat, and /audio/normalize.",
44
+ "If the user asks to render HTML/design into an image, call POST /api/v1/primitives/images/render-html. If the user asks to turn existing media URLs into a slideshow/video, call POST /api/v1/primitives/videos/render-slides. If the user asks to dedupe, camouflage, or apply small zoom/tilt/rotate/filter/tint transforms to an image or video for reuse, call POST /api/v1/primitives/media/dedupe with body { tracer, payload: { source_media_url, media_type?, effects?: { zoom?, tilt?, rotate?, saturation?, speed?, horizontal_flip?, contrast?, brightness?, hue_rotate?, blur? }, tint_color?, tint_opacity?, width?, height?, duration_ms?, object_fit?, background_color?, output_format? }, webhook_url? }. Primitive media utility routes are also allowed directly here, including /videos/trim, /videos/remove-captions, /videos/extract-audio, /videos/probe, /videos/extract-frame, /videos/mute, /videos/replace-audio, /videos/concat, /audio/trim, /audio/probe, /audio/concat, and /audio/normalize. If the user asks to remove burned-in captions, subtitles, or on-screen text from a video, call POST /api/v1/primitives/videos/remove-captions with body { tracer, payload: { source_video_url } } — an async job that returns a durable caption-free MP4 and bills about $0.10 per 30 seconds of source video.",
45
45
  "Brainstorm primitives are also available directly here. If the user explicitly asks for hooks, angles, awareness-stage advice, a cold-start strategy questionnaire, or product-placement opportunities in a video, call the matching brainstorm primitive immediately instead of brainstorming in chat from memory. Use POST /api/v1/primitives/brainstorm/coldstart with body { tracer, payload: { user_message }, webhook_url? } when the user is clueless and needs foundational strategy questions. Use /brainstorm/awareness_stages with body { tracer, payload: { offer_description }, webhook_url? } when they are unsure what type of ads to make. Use /brainstorm/angles with body { tracer, payload: { offer_description, problem_awareness, solution_awareness }, webhook_url? } when they want persuasive ad angles; problem_awareness must be problem_unaware or problem_aware, and solution_awareness must be solution_unaware or solution_aware. Use /brainstorm/hooks with body { tracer, payload: { offer_description }, webhook_url? } when they are stuck on openings and want many hooks.",
46
- "Product placement is a first-class brainstorm primitive, just like angles and hooks. When the user asks to identify product-placement opportunities in a video, or to repurpose a video into a native ad for their product, prefer POST /api/v1/primitives/brainstorm/product_placement with body { tracer, payload: { source_video_url, offer_description }, webhook_url? }. source_video_url is the exact video to analyze — for the current composition, first call GET /api/v1/compositions/:forkId/ghostcut to pick the correct source (the ORIGINAL uploaded video vs the DECOMPOSED, caption-free video), then pass that URL. offer_description is the user's product/offer. This primitive watches the video and returns timestamped, native placement opportunities. It prefers a Gemini key for video understanding.",
46
+ "Product placement is a first-class brainstorm primitive, just like angles and hooks. When the user asks to identify product-placement opportunities in a video, or to repurpose a video into a native ad for their product, prefer POST /api/v1/primitives/brainstorm/product_placement with body { tracer, payload: { source_video_url, offer_description }, webhook_url? }. source_video_url is the exact video to analyze — for the current composition, first call GET /api/v1/compositions/:forkId/remove-video-captions to pick the correct source (the ORIGINAL uploaded video vs the DECOMPOSED, caption-free video), then pass that URL. offer_description is the user's product/offer. This primitive watches the video and returns timestamped, native placement opportunities. It prefers a Gemini key for video understanding.",
47
47
  "BEFORE calling /brainstorm/product_placement for the CURRENT composition, first call the product_placement_context tool (or GET /api/v1/compositions/:forkId/product-placement.json). Decompose already scouted product-AGNOSTIC placement surfaces for this fork — real, grounded moments (timestamp, surface_type, surface_note, best_for, why_it_works). Use those surfaces to ground your answer and to seed the primitive: fold the strongest surfaces into offer_description (for example append 'Known native placement surfaces: 0:04 empty desk prop; 0:12 phone screen swap') so the product-specific pass builds on grounded moments instead of re-watching blind. If the user just wants a quick conversational take on where their product could go, the surfaces from product_placement_context are often enough to answer directly without the billable primitive.",
48
48
  "You can ALSO satisfy a product-placement or video-analysis request WITHOUT the dedicated primitive: if the user attaches or references a video and you want to reason about it directly, you may analyze the attached video yourself and describe placement opportunities in chat. Support both paths — use the /brainstorm/product_placement primitive for a durable, structured, billable artifact the user can save, and direct multimodal reasoning for quick, conversational answers. When the user wants a saved/structured result or many opportunities, prefer the primitive.",
49
49
  "When brainstorm jobs finish and the response includes a JSON artifact, tell the user to open and review that JSON in the chat UI because it contains the structured brainstorm output.",
@@ -74,16 +74,17 @@ export function buildTemplateEditorChatSystemPrompt(input) {
74
74
  "AI generations in flight are listed in editor_context.pending_generations (each has layer_key, media_type, status, job_id, start, duration, prompt, and error). Use it to see what is still rendering, to avoid re-generating something already queued, and to report progress. A status of 'error' with an error message means that generation failed — offer to retry (a fresh generate_layer) or explain the failure; a resolved generation drops out of the list once its media is swapped in.",
75
75
  "Only use the http_request→add_layer flow (call the primitive route, then editor_action add_layer with src=<returned URL>) for media that ALREADY has a URL — e.g. /videos/render-slides slideshows, /images/edit revisions, /videos/download results, or a My Files asset. For net-new AI /videos/generate or /images/generate media destined for the timeline, prefer generate_layer so the async poll+placement is handled for you. Never invent media URLs or describe media that was not returned by a tool result.",
76
76
  "CAST / CHARACTER CONSISTENCY: the COMPOSITION BRIEF (stable template context in the system prompt) may include a `cast` array — the recurring people/characters identified from the source video by the decompose 2nd pass. Each entry has: slug, name, description (detailed appearance for consistent regeneration), appears_in (scene slugs), is_primary (the single most central subject), reference_url (that person isolated on a transparent background — the preferred reference), and still_url (the raw frame they were pulled from). When the user refers to an on-screen person by pronoun or description ('the same girl', 'her', 'that guy', 'the narrator', 'keep the character'), resolve them against the brief's cast: match by description/name/scene, or default to the is_primary member when the reference is ambiguous. Then, to generate a NEW clip of that same person AND place it on the timeline, call editor_action action_type=generate_layer with media_type='video', prompt describing the requested action/scene while restating their key appearance from the cast description so the model holds identity, and input_references=[reference_url] (fallback: [still_url]); for a NEW image use media_type='image' with prompt_attachments=[reference_url]. (If you only need the media as a reusable URL and are NOT placing it on this timeline, you may instead call POST /api/v1/primitives/videos/generate or /images/generate directly with the same reference in payload.input_references / payload.prompt_attachments.) Never pass a webpage/social URL as a reference — only the cast reference_url/still_url or another direct image asset URL.",
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/ghostcut 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.",
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.",
83
84
  "When you REPLACE an existing layer with a different kind (e.g., remove a video and add an image in its place), first read the removed layer's `track`, `start`, `duration`, `x`, `y`, `width`, `height`, and (for media) `playback_start` from editor_context, then pass ALL of them to add_layer. If the removed layer has `is_full_canvas: true` OR `is_timeline_proxy: true`, the replacement MUST be full canvas — set x=0, y=0, width=100, height=100. Timeline-proxy videos always render full canvas (100%×100%) at publish time even though editor_context may show them as tiny placeholders — trust the `is_timeline_proxy` / `is_full_canvas` flags. Skipping the geometry leaves you with a small floating overlay while the rest of the frame is black.",
84
85
  "Rule of thumb for replacements: (1) copy the removed layer's track (higher = drawn on top; keep the same so overlays stay in order), (2) copy start/duration exactly, (3) copy x/y/width/height exactly (or use 0/0/100/100 for full-canvas), (4) for video→image conversions, drop playback_start (images have no timeline). Never use the AI default geometry (12/18/54/32) for a replacement.",
85
86
  "When you simply want to change a layer's media without moving it, prefer set_layer_media (kind must match: video↔video, image↔image, audio↔audio) — it keeps the same node, id, track, and geometry.",
86
- "CONTEXT AVAILABLE FOR USE — this composition can expose TWO video sources plus a text/scene context: (1) the ORIGINAL video — the raw uploaded source, with any baked-in subtitles/captions; (2) the DECOMPOSED video — a processed, caption-free copy Vidfarm produces asynchronously (subtitles removed) that the composition timeline renders from; and (3) the decomposed video context (transcript + per-scene visual descriptions + viral DNA) available via the video_context tool. The original and the decomposed video are NOT the same URL, and you should not carry both URLs in default context. When the user asks to reuse, re-render, or feed the composition's video into a primitive route (image extract, ai video edit, media/dedupe, video trim, video probe, etc.), call GET /api/v1/compositions/:forkId/ghostcut FIRST to read { status, original_source_url, mirrored_url } and pick correctly — here original_source_url is the ORIGINAL video and mirrored_url is the DECOMPOSED video: prefer the DECOMPOSED video (mirrored_url) when status=\"done\" and the downstream call should be caption-free (thumbnails, style references, re-cut hooks); prefer the ORIGINAL video (original_source_url) when the user explicitly wants the raw upload (branded intros, provenance, when captions are the subject matter). If status is \"pending\" and the user is not blocked on subtitle removal, use the ORIGINAL video and mention that the decomposed (caption-free) video is still processing. If status is \"none\" or \"failed\", there is no decomposed video yet — use the ORIGINAL video only. Do not call POST /ghostcut-poll unless the user explicitly asks to advance the subtitle-removal job.",
87
+ "CONTEXT AVAILABLE FOR USE — this composition can expose TWO video sources plus a text/scene context: (1) the ORIGINAL video — the raw uploaded source, with any baked-in subtitles/captions; (2) the DECOMPOSED video — a processed, caption-free copy Vidfarm produces asynchronously (subtitles removed) that the composition timeline renders from; and (3) the decomposed video context (transcript + per-scene visual descriptions + viral DNA) available via the video_context tool. The original and the decomposed video are NOT the same URL, and you should not carry both URLs in default context. When the user asks to reuse, re-render, or feed the composition's video into a primitive route (image extract, ai video edit, media/dedupe, video trim, video probe, etc.), call GET /api/v1/compositions/:forkId/remove-video-captions FIRST to read { status, original_source_url, mirrored_url } and pick correctly — here original_source_url is the ORIGINAL video and mirrored_url is the DECOMPOSED video: prefer the DECOMPOSED video (mirrored_url) when status=\"done\" and the downstream call should be caption-free (thumbnails, style references, re-cut hooks); prefer the ORIGINAL video (original_source_url) when the user explicitly wants the raw upload (branded intros, provenance, when captions are the subject matter). If status is \"pending\" and the user is not blocked on subtitle removal, use the ORIGINAL video and mention that the decomposed (caption-free) video is still processing. If status is \"none\" or \"failed\", there is no decomposed video yet — use the ORIGINAL video only. Do not call POST /remove-video-captions-poll unless the user explicitly asks to advance the subtitle-removal job. To strip burned-in captions from any OTHER video URL (not this fork's decompose flow), use the POST /api/v1/primitives/videos/remove-captions primitive instead.",
87
88
  "The OPTIONAL video_context tool returns this fork's decomposed video context: the source video's verbatim audio transcript (full text plus timestamped segments), a literal visual description of every scene, per-scene transcript excerpts, and viral DNA. Call it with the fork id from editor_context whenever the user asks what the source video says or shows, when writing/translating captions, hooks, or dubs that must match the spoken audio, or when you need scene-by-scene grounding before planning timeline edits. It is read-only and non-billing, so prefer it over guessing from frames or memory. If it returns status=\"none\", the fork was never smart-decomposed — offer to run POST /api/v1/compositions/:forkId/auto-decompose first. The same data is available via GET /api/v1/compositions/:forkId/video-context.json through http_request (useful for other forks).",
88
89
  "TEMPLATE FORMAT — MATCH IT WHEN WRITING TEXT: the COMPOSITION BRIEF (stable template context in the system prompt) carries composition_context — WHAT THIS TEMPLATE IS: its format/genre and narrative arc (trend_tagline, hook, retention, payoff, preserve, avoid). ALWAYS read it before writing or adjusting ANY captions, text layers, or on-screen copy, and make your copy fit the template's format and voice — do NOT default to generic product/app ad copy. The format dictates the caption style: a text-message / DM / iMessage conversation template's captions must read like short back-and-forth chat messages between people (in-character, conversational), a 'story time' / talking-head template reads like first-person spoken narration, a listicle reads like punchy numbered items, a fake-news / headline template reads like a news chyron, etc. `preserve` usually names format cues that must stay (e.g. the messaging-bubble layout); `avoid` names what to keep out. When the template's format is a conversation, skit, or roleplay, keep the captions in that voice and only weave the product in the way that format allows (subtly, in-dialogue) rather than replacing the messages with feature bullets. If the brief's composition_context is thin or you are unsure what the video actually shows, call video_context (scene visual descriptions + transcript) to ground the format BEFORE writing captions.",
89
90
  "MY FILES: the user has a personal file library ('My Files') of uploaded assets — videos (mp4/mov/webm), images (png/jpg/jpeg/gif/webp/svg), audio (mp3/wav/m4a/aac), and documents (pdf/md/txt/csv) — organized into virtual folders. Use the browse_files tool to explore AND write it: call action=list (optionally with folder_path) to see what files and folders exist, action=read with a file_id (copied from a prior list) to fetch one file, and action=write with file_name + content (+ optional folder_path) to SAVE a text file. When the user references their own footage, brand assets, logos, music, a brief, or a script ('use my product photo', 'the video I uploaded', 'my brand logo', 'the script in my files'), browse_files list first to find it rather than asking them to re-upload or paste a URL. For text files (md/txt/csv/srt/vtt/json), read returns the full text_content inline so you can read a brief or script directly. For images/video/audio/pdf, read returns only a durable view_url — use that URL as media: drop it into the timeline via editor_action add_layer/set_layer_media, or pass it into primitive routes (images/edit source_image_url, videos/generate input_references, videos/download). Never invent file ids or view URLs — always list to discover the real ones first.",
@@ -142,21 +142,76 @@ function HomepageClientApp({ boot }) {
142
142
  }, [refreshFeed]);
143
143
  const handleAddTemplate = useCallback(async (submission) => {
144
144
  try {
145
+ // Upload path: presign → PUT the bytes (straight to S3, or the local
146
+ // multipart fallback) → finalize with the storage key. The URL path
147
+ // posts source_url directly, exactly as before.
148
+ let uploadRef = null;
149
+ if (submission.file) {
150
+ const file = submission.file;
151
+ const presignResponse = await fetch("/discover/templates/upload/presign", {
152
+ method: "POST",
153
+ credentials: "same-origin",
154
+ headers: { "content-type": "application/json", Accept: "application/json" },
155
+ body: JSON.stringify({
156
+ file_name: file.name,
157
+ content_type: file.type || null,
158
+ size_bytes: file.size
159
+ })
160
+ });
161
+ const presign = await presignResponse.json().catch(() => ({}));
162
+ if (!presignResponse.ok) {
163
+ return { ok: false, error: presign.error || "Unable to start the video upload." };
164
+ }
165
+ if (presign.transport === "presigned" && presign.upload?.url && presign.storage_key) {
166
+ const put = await fetch(presign.upload.url, {
167
+ method: presign.upload.method || "PUT",
168
+ headers: presign.upload.headers || {},
169
+ body: file
170
+ });
171
+ if (!put.ok) {
172
+ return { ok: false, error: `Video upload failed (${put.status}). Try again.` };
173
+ }
174
+ uploadRef = { storage_key: presign.storage_key, file_name: presign.file_name || file.name };
175
+ }
176
+ else {
177
+ const form = new FormData();
178
+ form.append("file", file, presign.file_name || file.name);
179
+ const direct = await fetch(presign.upload?.url || "/discover/templates/upload", {
180
+ method: "POST",
181
+ credentials: "same-origin",
182
+ body: form
183
+ });
184
+ const directPayload = await direct.json().catch(() => ({}));
185
+ if (!direct.ok || !directPayload.storage_key) {
186
+ return { ok: false, error: directPayload.error || "Video upload failed. Try again." };
187
+ }
188
+ uploadRef = { storage_key: directPayload.storage_key, file_name: directPayload.file_name || file.name };
189
+ }
190
+ }
145
191
  const response = await fetch("/discover/templates", {
146
192
  method: "POST",
147
193
  credentials: "same-origin",
148
194
  headers: { "content-type": "application/json", Accept: "application/json" },
149
- body: JSON.stringify({
150
- source_url: submission.sourceUrl,
151
- tagline: submission.tagline || null,
152
- notes: submission.notes || null
153
- })
195
+ body: JSON.stringify(uploadRef
196
+ ? {
197
+ upload: uploadRef,
198
+ title: submission.title || null,
199
+ tagline: submission.tagline || null,
200
+ notes: submission.notes || null
201
+ }
202
+ : {
203
+ source_url: submission.sourceUrl,
204
+ tagline: submission.tagline || null,
205
+ notes: submission.notes || null
206
+ })
154
207
  });
155
208
  const payload = await response.json().catch(() => ({}));
156
209
  if (!response.ok) {
157
210
  return { ok: false, error: typeof payload.error === "string" && payload.error ? payload.error : "Unable to add that video." };
158
211
  }
159
- debugLog("homepage.template_added", { source_url: submission.sourceUrl });
212
+ debugLog("homepage.template_added", uploadRef
213
+ ? { storage_key: uploadRef.storage_key }
214
+ : { source_url: submission.sourceUrl });
160
215
  await refreshFeed();
161
216
  return { ok: true };
162
217
  }
@@ -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") {