@mevdragon/vidfarm-devcli 0.10.0 → 0.12.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/README.md +19 -1
- package/SKILL.director.md +83 -2
- package/SKILL.platform.md +18 -3
- package/demo/dist/app.js +191 -65
- package/dist/src/app.js +69 -5
- package/dist/src/cli.js +424 -10
- package/dist/src/composition-runtime.js +77 -1
- package/dist/src/devcli/captions.js +310 -0
- package/dist/src/devcli/composition-edit.js +142 -0
- package/dist/src/devcli/speech.js +178 -0
- package/dist/src/editor-chat.js +5 -2
- package/dist/src/hyperframes/composition.js +315 -0
- package/dist/src/primitive-context.js +16 -0
- package/dist/src/primitive-registry.js +243 -0
- package/dist/src/services/captions.js +123 -0
- package/dist/src/services/hyperframes.js +113 -1
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/provider-errors.js +77 -0
- package/dist/src/services/providers.js +38 -431
- package/dist/src/services/speech.js +629 -0
- package/package.json +1 -1
package/dist/src/editor-chat.js
CHANGED
|
@@ -34,7 +34,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
34
34
|
"Execute REST calls sequentially. Make one API call, inspect the response, then decide the next step.",
|
|
35
35
|
"A queued async POST job is enough inspection for that request. Do not poll it in the same response; continue launching any remaining requested outputs, then summarize the queued jobs.",
|
|
36
36
|
"Primitive routes under /api/v1/primitives are first-class allowed routes in this editor, even when the primitive is not specific to the current template.",
|
|
37
|
-
"Never say you cannot use primitive image generation, image edit, inpaint, HTML render, AI video generation, or video render routes directly from this editor chat; use the http_request tool when the requested primitive route exists.",
|
|
37
|
+
"Never say you cannot use primitive image generation, image edit, inpaint, HTML render, AI video generation, text-to-speech, speech transcription, or video render routes directly from this editor chat; use the http_request tool when the requested primitive route exists.",
|
|
38
38
|
"All primitive POST routes require the same outer JSON envelope: top-level tracer, top-level payload containing the primitive-specific fields, and optional webhook_url.",
|
|
39
39
|
"When the user explicitly asks to use primitives or timeline rendering, do not redirect them to old per-template operation routes unless the requested primitive route cannot accept the available inputs.",
|
|
40
40
|
"If the user asks to create, generate, or make a new image similar to an attached image, call POST /api/v1/primitives/images/generate with body { tracer, payload: { prompt, prompt_attachments: [exact attachment URL] } }.",
|
|
@@ -42,6 +42,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
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
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. Caption removal is capped at ~15 minutes of source and must NEVER be used on long-form video: when the user wants short clips out of a long video (a podcast, stream VOD, webinar, or a YouTube/TikTok/IG/X URL), call POST /clips/scan instead with body { source_url | temp_file_id | s3_key, prompt, ranges?: [\"MM:SS-MM:SS\"], target_duration_sec?, aspect?: \"9:16\"|\"16:9\"|\"4:3\"|\"1:1\", avoid_text?, tracer? } — target_duration_sec is a SOFT range (30 → 20-40s), ranges restrict the hunt to those source windows (big cost saver), aspect crops every clip, and avoid_text prefers text-free scenes via scene SELECTION (never caption removal on the source). The hunt is async (202 + scan_id): poll GET /clips/scan/:scanId, then browse GET /clips/feed?source=<source_video_id> or POST /clips/search. Hunts bill AWS compute only — the AI tagging runs on the user's own saved provider keys. To erase captions from a finished HUNTED clip, pass that short mp4 to /videos/remove-captions afterwards.",
|
|
45
|
+
"SPEECH PRIMITIVES (TTS/STT) are first-class here. VOICEOVER / NARRATION: when the user wants a script, hook, caption, or dub read aloud as AI audio, call POST /api/v1/primitives/audio/speech with body { tracer, payload: { text, voice?, instructions?, provider?, model?, output_format? } } — instructions is a free-form voice-STYLE prompt (tone, pacing, accent, emotion, persona; e.g. 'calm warm bedtime narrator' or 'excited sports announcer, fast paced'), and voice picks a provider preset (OpenAI: alloy/ash/coral/...; Gemini: Kore/Puck/Charon/...). When sending a voice you may omit provider — it is inferred from the voice preset; a voice paired with the WRONG provider (e.g. voice 'Kore' with provider 'openai') is rejected with a 400, so never mix families. The finished job returns a durable audio URL — place it on the timeline with editor_action add_layer (kind=audio, src=that URL), typically starting where the narration should begin. TRANSCRIPTION: when the user wants a transcript, subtitles/captions from speech, a translation source, or to know who says what, call POST /api/v1/primitives/audio/transcribe with body { tracer, payload: { source_url, diarize?, language?, prompt? } } — source_url may be a VIDEO or AUDIO URL (video audio is demuxed automatically; pick the fork's source via GET /api/v1/compositions/:forkId/remove-video-captions first when transcribing the current composition). One job returns BOTH formats: a simple subtitle version (plain transcript text + timed SRT cues, stored as transcript.txt/transcript.srt) and an advanced multi-speaker version (speaker-attributed timed segments in output.segments and transcript.json) for multi-narration sources like podcasts and interviews. diarize defaults to true and speaker attribution needs a Gemini key — openai/openrouter keys degrade to a plain single-speaker transcript. For transcript-grounded caption work on THIS fork, the read-only video_context tool may already contain the decompose-time transcript; prefer it before running a new billable-key transcription, and use /audio/transcribe for other URLs, multi-speaker attribution, or SRT output.",
|
|
45
46
|
"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
47
|
"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
48
|
"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.",
|
|
@@ -63,7 +64,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
63
64
|
"If the user asks for each output to have its own chat thread, call frontend_action with action=create_thread for each tracer before or alongside the corresponding POST request.",
|
|
64
65
|
"If multiple tracers are available, mention which tracer you are using when it matters.",
|
|
65
66
|
"If the user asks to add, remove, switch tracers, or create a separate chat thread for a tracer, use the frontend_action tool so the page state updates directly.",
|
|
66
|
-
"If the user asks to add, remove, retime, reposition, resize, restyle, swap media, tag, note, group, ungroup, duplicate, or split layers on the composition timeline, call the editor_action tool. Supported action_type values are add_layer, remove_layer, set_layer_timing, set_layer_visual, set_layer_style, set_layer_media, set_layer_text, set_layer_identity, duplicate_layer, split_layer, group_layers, ungroup_layers, generate_layer, replace_composition_html, and
|
|
67
|
+
"If the user asks to add, remove, retime, reposition, resize, restyle, swap media, tag, note, group, ungroup, duplicate, or split layers on the composition timeline, call the editor_action tool. Supported action_type values are add_layer, remove_layer, set_layer_timing, set_layer_visual, set_layer_style, set_layer_media, set_layer_text, set_layer_identity, duplicate_layer, split_layer, group_layers, ungroup_layers, generate_layer, replace_composition_html, export_composition, and set_captions.",
|
|
67
68
|
"If the user asks to export, render, publish, or produce the final MP4 for this composition (phrases like 'export it', 'render this', 'kick off the export', 'make the mp4'), call editor_action with action_type=export_composition and a brief explanation. This is the same action as the editor's Export button — it queues the composition on production AWS Lambda. Do NOT try to hit any /api/v1/compositions/:id/export route via http_request for this; use the editor_action tool. After calling export_composition, tell the user the export has started and that the finished MP4 URL will appear once the render succeeds; do not fabricate a URL or claim success until a later turn shows last_export_url populated in editor_context.",
|
|
68
69
|
"The editor_context block includes last_export_url, last_export_title, last_export_status, and last_export_render_id when a render has succeeded in this session. Treat last_export_url as the current 'finished Export MP4' for this composition. If the user asks to approve, package, share, or schedule the exported video without providing an explicit URL, use last_export_url as the primary MP4 media (kind=video, role=primary) for POST /api/v1/approved/posts. If last_export_url is null, tell the user to Export first (or offer to call editor_action with action_type=export_composition on their behalf) instead of guessing a URL.",
|
|
69
70
|
"Each layer in editor_context includes element_id (data-hf-id), optional slug (data-hf-slug), and optional note (data-hf-note). layer_key on any editor_action may be EITHER the element_id or the slug — prefer the slug when the user (or a viral DNA blurb) refers to a layer by its human name like 'the hero_image'. Use set_layer_identity to add/change a layer's slug or note. Slugs are the stable AI-friendly handle: when planning viral DNA notes, reference the element by its slug (e.g., 'raise the {{hero_image}} to full canvas at 4s') so future turns can resolve it deterministically.",
|
|
@@ -78,6 +79,8 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
78
79
|
"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
80
|
"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
81
|
"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.",
|
|
82
|
+
"SCENE TRANSITIONS: every visual clip supports a first-class transition at its start — the clip animates IN over the previous clip on its track, which is automatically held on screen beneath it for the transition window, in both the preview and the final render. Apply it with editor_action set_layer_media and the transition field ON THE INCOMING CLIP — the clip AFTER the cut, never the one before it (presets: crossfade, fade-black, slide-left/right/up/down, wipe-left/right/up/down, zoom-in, zoom-out, circle-open; 'none' removes it), optionally transition_duration (0.1-2.5s, default 0.5; ~0.3 snappy, ~1.0 slow cinematic). When the user says 'add transitions', 'smooth out the cuts', 'crossfade between scenes', or similar, apply a transition to EVERY scene clip except the first in one pass (one set_layer_media call per clip). Match the preset to the template's energy: crossfade/fade-black read calm and cinematic, slide/wipe read energetic (vary directions across cuts), zoom-in/zoom-out/circle-open read punchy and meme-y; keep ONE family per video unless asked otherwise. You can also seed transition directly on add_layer or generate_layer so a newly placed scene arrives with its entrance. Transitions belong on video/image scene clips — never on audio; on text overlays prefer them only when the user asks. Timing is handled for you — never move clip starts or durations to 'make room' for a transition.",
|
|
83
|
+
"ANIMATED CAPTIONS (word-by-word, TikTok/CapCut style): the composition supports first-class animated captions — caption layers whose words animate one at a time in both the preview and the final render. Available preset looks (caption_style): spotlight (active word gets a rounded highlight pill on bold outlined text — the classic Hormozi/CapCut look), karaoke (words fill with a color as they are spoken and stay lit), word-pop (one word at a time, punchy scale-in), stack-up (words fade/rise in and accumulate), neon (active word pulses with a glow), bounce (active word bounces). When the user asks to 'add captions', 'add animated captions', 'add subtitles', or similar: FIRST call video_context to get the transcript's timestamped segments, then call editor_action action_type=set_captions with captions=[{text, start, duration, words?}] cues derived from those segments — split segment text into pages of 3-5 words, keep each cue inside its segment's time window, and pass word timings when the transcript provides them (otherwise omit words and the editor estimates). Pick caption_style to match the template's voice (composition_context), or spotlight by default; override colors with caption_active_color / caption_highlight_color / color / background / background_style, and caption_uppercase for shouty formats. set_captions REPLACES all existing animated caption layers in one call — use it for 'restyle all the captions' too (same cues, new style) when cue text/timing is already in editor_context (each caption layer's text and caption_animation are listed there). For a single cue, set_layer_style with caption_* fields restyles just that layer, set_layer_text rewrites its words (timings re-estimate), and caption_animation='none' flattens it back to static text. Never hand-build word-by-word captions out of many add_layer text layers — always use set_captions.",
|
|
81
84
|
"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.",
|
|
82
85
|
"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.",
|
|
83
86
|
"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.",
|
|
@@ -75,6 +75,269 @@ export function normalizeKenBurns(value) {
|
|
|
75
75
|
origin
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Scene transitions. Same declarative contract as Ken Burns: the INCOMING
|
|
80
|
+
// clip (the element that owns data-start) carries data-transition="<preset>"
|
|
81
|
+
// plus data-transition-duration and the inline --vf-tr-dur var, and a static
|
|
82
|
+
// vf-tr-* keyframe animates the clip IN over its first data-transition-duration
|
|
83
|
+
// seconds. Both preview runtimes and the render producer's CSS adapter scrub
|
|
84
|
+
// the animation by (time - data-start), so the motion is identical everywhere.
|
|
85
|
+
//
|
|
86
|
+
// The one thing CSS cannot express is the OUTGOING clip staying on screen
|
|
87
|
+
// beneath the incoming one: stored compositions keep scenes butt-cut (A ends
|
|
88
|
+
// exactly where B starts) so timeline edits and agent edits stay trivial. The
|
|
89
|
+
// preview runtime extends the previous same-track clip's active window live,
|
|
90
|
+
// and normalizePublishHtml materializes the same overlap into data-duration at
|
|
91
|
+
// publish/render time (recorded in data-transition-hold for idempotency),
|
|
92
|
+
// because the render engine derives visibility strictly from data-start/
|
|
93
|
+
// data-duration. Presets animate opacity / clip-path / standalone translate /
|
|
94
|
+
// scale — never `transform` — so they compose with a Ken Burns transform on
|
|
95
|
+
// the same element. Keyframes deliberately omit the `to` state so the
|
|
96
|
+
// animation settles into the element's own inline styles instead of
|
|
97
|
+
// clobbering a custom opacity.
|
|
98
|
+
export const TRANSITION_PRESETS = [
|
|
99
|
+
"crossfade",
|
|
100
|
+
"fade-black",
|
|
101
|
+
"slide-left",
|
|
102
|
+
"slide-right",
|
|
103
|
+
"slide-up",
|
|
104
|
+
"slide-down",
|
|
105
|
+
"wipe-left",
|
|
106
|
+
"wipe-right",
|
|
107
|
+
"wipe-up",
|
|
108
|
+
"wipe-down",
|
|
109
|
+
"zoom-in",
|
|
110
|
+
"zoom-out",
|
|
111
|
+
"circle-open"
|
|
112
|
+
];
|
|
113
|
+
export const TRANSITION_DEFAULT_DURATION = 0.5;
|
|
114
|
+
export const TRANSITION_MIN_DURATION = 0.1;
|
|
115
|
+
export const TRANSITION_MAX_DURATION = 2.5;
|
|
116
|
+
// Two butt-cut clips can drift by a few ms through timeline edits; treat ends
|
|
117
|
+
// within this window as adjacent when granting the outgoing clip's hold.
|
|
118
|
+
export const TRANSITION_ADJACENCY_EPSILON = 0.05;
|
|
119
|
+
export const TRANSITION_STYLE_MARKER = "__VF_TRANSITIONS_V1__";
|
|
120
|
+
export const TRANSITION_CSS = `/*${TRANSITION_STYLE_MARKER}*/
|
|
121
|
+
@keyframes vf-tr-crossfade { from { opacity: 0; } }
|
|
122
|
+
@keyframes vf-tr-fade-black { 0% { opacity: 0; filter: brightness(0); } 35% { opacity: 1; filter: brightness(0); } }
|
|
123
|
+
@keyframes vf-tr-slide-left { from { translate: 100% 0; } }
|
|
124
|
+
@keyframes vf-tr-slide-right { from { translate: -100% 0; } }
|
|
125
|
+
@keyframes vf-tr-slide-up { from { translate: 0 100%; } }
|
|
126
|
+
@keyframes vf-tr-slide-down { from { translate: 0 -100%; } }
|
|
127
|
+
@keyframes vf-tr-wipe-left { from { clip-path: inset(0 0 0 100%); } to { clip-path: inset(0 0 0 0); } }
|
|
128
|
+
@keyframes vf-tr-wipe-right { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
|
|
129
|
+
@keyframes vf-tr-wipe-up { from { clip-path: inset(100% 0 0 0); } to { clip-path: inset(0 0 0 0); } }
|
|
130
|
+
@keyframes vf-tr-wipe-down { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0 0); } }
|
|
131
|
+
@keyframes vf-tr-zoom-in { from { opacity: 0; scale: 1.18; } }
|
|
132
|
+
@keyframes vf-tr-zoom-out { from { opacity: 0; scale: 0.82; } }
|
|
133
|
+
@keyframes vf-tr-circle-open { from { clip-path: circle(0% at 50% 50%); } to { clip-path: circle(75% at 50% 50%); } }
|
|
134
|
+
[data-transition] {
|
|
135
|
+
animation-duration: var(--vf-tr-dur, 0.5s);
|
|
136
|
+
animation-timing-function: var(--vf-tr-ease, ease-in-out);
|
|
137
|
+
animation-fill-mode: both;
|
|
138
|
+
animation-play-state: paused;
|
|
139
|
+
}
|
|
140
|
+
[data-transition="crossfade"] { animation-name: vf-tr-crossfade; }
|
|
141
|
+
[data-transition="fade-black"] { animation-name: vf-tr-fade-black; }
|
|
142
|
+
[data-transition="slide-left"] { animation-name: vf-tr-slide-left; }
|
|
143
|
+
[data-transition="slide-right"] { animation-name: vf-tr-slide-right; }
|
|
144
|
+
[data-transition="slide-up"] { animation-name: vf-tr-slide-up; }
|
|
145
|
+
[data-transition="slide-down"] { animation-name: vf-tr-slide-down; }
|
|
146
|
+
[data-transition="wipe-left"] { animation-name: vf-tr-wipe-left; }
|
|
147
|
+
[data-transition="wipe-right"] { animation-name: vf-tr-wipe-right; }
|
|
148
|
+
[data-transition="wipe-up"] { animation-name: vf-tr-wipe-up; }
|
|
149
|
+
[data-transition="wipe-down"] { animation-name: vf-tr-wipe-down; }
|
|
150
|
+
[data-transition="zoom-in"] { animation-name: vf-tr-zoom-in; }
|
|
151
|
+
[data-transition="zoom-out"] { animation-name: vf-tr-zoom-out; }
|
|
152
|
+
[data-transition="circle-open"] { animation-name: vf-tr-circle-open; }
|
|
153
|
+
`;
|
|
154
|
+
export function clampTransitionDuration(value) {
|
|
155
|
+
const next = Number(value);
|
|
156
|
+
if (!Number.isFinite(next) || next <= 0)
|
|
157
|
+
return TRANSITION_DEFAULT_DURATION;
|
|
158
|
+
return Math.max(TRANSITION_MIN_DURATION, Math.min(TRANSITION_MAX_DURATION, next));
|
|
159
|
+
}
|
|
160
|
+
export function normalizeTransition(value) {
|
|
161
|
+
if (!value)
|
|
162
|
+
return null;
|
|
163
|
+
const raw = typeof value === "string" ? { preset: value } : value;
|
|
164
|
+
if (!TRANSITION_PRESETS.includes(raw.preset))
|
|
165
|
+
return null;
|
|
166
|
+
return {
|
|
167
|
+
preset: raw.preset,
|
|
168
|
+
duration: raw.duration !== undefined ? clampTransitionDuration(raw.duration) : undefined
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Animated captions. Same declarative contract as Ken Burns: the caption layer
|
|
173
|
+
// <div> (the element that owns data-start/data-duration) carries
|
|
174
|
+
// data-caption-animation="<preset>" plus per-cue CSS vars, and each word is a
|
|
175
|
+
// <span data-cap-word> whose inline animation-delay/animation-duration encode
|
|
176
|
+
// that word's window RELATIVE TO THE LAYER START. The render producer's CSS
|
|
177
|
+
// adapter seeks every animation to (frameTime - ancestor data-start), and both
|
|
178
|
+
// preview runtimes scrub the same way, so word-level motion is deterministic
|
|
179
|
+
// and identical across preview and export. Everything is pure HTML+CSS so it
|
|
180
|
+
// survives sanitizeCompositionHtml. Word timings also round-trip through
|
|
181
|
+
// data-word-start/data-word-end so editors can re-derive them without parsing
|
|
182
|
+
// CSS.
|
|
183
|
+
export const CAPTION_ANIMATIONS = [
|
|
184
|
+
"highlight-pop",
|
|
185
|
+
"karaoke-fill",
|
|
186
|
+
"word-pop",
|
|
187
|
+
"cumulative-reveal",
|
|
188
|
+
"glow-pulse",
|
|
189
|
+
"bounce-wave"
|
|
190
|
+
];
|
|
191
|
+
export const CAPTION_STYLE_MARKER = "__VF_CAPTIONS_V1__";
|
|
192
|
+
export const CAPTION_ANIM_CSS = `/*${CAPTION_STYLE_MARKER}*/
|
|
193
|
+
[data-caption-animation] [data-cap-word] {
|
|
194
|
+
display:inline-block;
|
|
195
|
+
animation-play-state:paused;
|
|
196
|
+
animation-timing-function:ease-out;
|
|
197
|
+
animation-fill-mode:none;
|
|
198
|
+
will-change:transform,opacity;
|
|
199
|
+
}
|
|
200
|
+
[data-caption-uppercase="1"] { text-transform:uppercase; }
|
|
201
|
+
@keyframes vf-cap-highlight { 14%, 100% { background-color:var(--vf-cap-hl, #7C3AED); color:var(--vf-cap-active, #FFFFFF); transform:scale(1.06); } }
|
|
202
|
+
[data-caption-animation="highlight-pop"] [data-cap-word] { animation-name:vf-cap-highlight; padding:0.08em 0.18em; margin:-0.08em -0.05em; border-radius:0.3em; }
|
|
203
|
+
@keyframes vf-cap-karaoke { to { color:var(--vf-cap-active, #FFE14D); } }
|
|
204
|
+
[data-caption-animation="karaoke-fill"] [data-cap-word] { animation-name:vf-cap-karaoke; animation-fill-mode:forwards; animation-timing-function:linear; }
|
|
205
|
+
@keyframes vf-cap-word-pop { 0% { opacity:0; transform:scale(0.45); } 22% { opacity:1; transform:scale(1.14); } 38%, 100% { opacity:1; transform:scale(1); } }
|
|
206
|
+
[data-caption-animation="word-pop"] [data-vf-text-inline] { display:grid; place-items:center; }
|
|
207
|
+
[data-caption-animation="word-pop"] [data-cap-word] { animation-name:vf-cap-word-pop; opacity:0; grid-area:1 / 1; }
|
|
208
|
+
@keyframes vf-cap-reveal { from { opacity:0; transform:translateY(0.4em); } 40%, 100% { opacity:1; transform:none; } }
|
|
209
|
+
[data-caption-animation="cumulative-reveal"] [data-cap-word] { animation-name:vf-cap-reveal; animation-fill-mode:forwards; opacity:0; }
|
|
210
|
+
@keyframes vf-cap-glow { 20%, 80% { color:var(--vf-cap-active, #9BFF57); text-shadow:0 0 0.14em var(--vf-cap-active, #9BFF57), 0 0 0.5em var(--vf-cap-active, #9BFF57); transform:scale(1.04); } }
|
|
211
|
+
[data-caption-animation="glow-pulse"] [data-cap-word] { animation-name:vf-cap-glow; }
|
|
212
|
+
@keyframes vf-cap-bounce { 25% { transform:translateY(-0.22em) scale(1.08); } 55% { transform:translateY(0.05em); } 75% { transform:none; } }
|
|
213
|
+
[data-caption-animation="bounce-wave"] [data-cap-word] { animation-name:vf-cap-bounce; }
|
|
214
|
+
`;
|
|
215
|
+
export const CAPTION_STYLE_PRESETS = [
|
|
216
|
+
{
|
|
217
|
+
id: "spotlight",
|
|
218
|
+
label: "Spotlight",
|
|
219
|
+
animation: "highlight-pop",
|
|
220
|
+
color: "#ffffff",
|
|
221
|
+
background: "#000000",
|
|
222
|
+
textBackgroundStyle: "outline",
|
|
223
|
+
highlightColor: "#7C3AED",
|
|
224
|
+
activeColor: "#ffffff",
|
|
225
|
+
fontWeight: 800,
|
|
226
|
+
uppercase: true
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "karaoke",
|
|
230
|
+
label: "Karaoke",
|
|
231
|
+
animation: "karaoke-fill",
|
|
232
|
+
color: "#ffffff",
|
|
233
|
+
background: "#000000",
|
|
234
|
+
textBackgroundStyle: "outline",
|
|
235
|
+
activeColor: "#FFE14D",
|
|
236
|
+
fontWeight: 800
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "word-pop",
|
|
240
|
+
label: "Word Pop",
|
|
241
|
+
animation: "word-pop",
|
|
242
|
+
color: "#ffffff",
|
|
243
|
+
background: "#000000",
|
|
244
|
+
textBackgroundStyle: "outline",
|
|
245
|
+
fontWeight: 900,
|
|
246
|
+
uppercase: true
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: "stack-up",
|
|
250
|
+
label: "Stack Up",
|
|
251
|
+
animation: "cumulative-reveal",
|
|
252
|
+
color: "#ffffff",
|
|
253
|
+
background: "#000000",
|
|
254
|
+
textBackgroundStyle: "highlight-translucent",
|
|
255
|
+
fontWeight: 700
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "neon",
|
|
259
|
+
label: "Neon Glow",
|
|
260
|
+
animation: "glow-pulse",
|
|
261
|
+
color: "#eafff1",
|
|
262
|
+
background: "#04140a",
|
|
263
|
+
textBackgroundStyle: "plain",
|
|
264
|
+
activeColor: "#9BFF57",
|
|
265
|
+
fontWeight: 800,
|
|
266
|
+
uppercase: true
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "bounce",
|
|
270
|
+
label: "Bounce",
|
|
271
|
+
animation: "bounce-wave",
|
|
272
|
+
color: "#FFE14D",
|
|
273
|
+
background: "#000000",
|
|
274
|
+
textBackgroundStyle: "outline",
|
|
275
|
+
activeColor: "#FFE14D",
|
|
276
|
+
fontWeight: 900,
|
|
277
|
+
uppercase: true
|
|
278
|
+
}
|
|
279
|
+
];
|
|
280
|
+
export function captionStylePresetById(id) {
|
|
281
|
+
if (!id)
|
|
282
|
+
return null;
|
|
283
|
+
const clean = String(id).trim().toLowerCase();
|
|
284
|
+
return CAPTION_STYLE_PRESETS.find((preset) => preset.id === clean) || null;
|
|
285
|
+
}
|
|
286
|
+
export function normalizeCaptionConfig(value) {
|
|
287
|
+
if (!value)
|
|
288
|
+
return null;
|
|
289
|
+
const raw = typeof value === "string" ? { animation: value } : value;
|
|
290
|
+
if (!CAPTION_ANIMATIONS.includes(raw.animation))
|
|
291
|
+
return null;
|
|
292
|
+
const words = Array.isArray(raw.words)
|
|
293
|
+
? raw.words
|
|
294
|
+
.map((word) => ({
|
|
295
|
+
text: String(word?.text ?? "").trim(),
|
|
296
|
+
start: Math.max(0, Number(word?.start) || 0),
|
|
297
|
+
end: Math.max(0, Number(word?.end) || 0)
|
|
298
|
+
}))
|
|
299
|
+
.filter((word) => word.text && word.end > word.start)
|
|
300
|
+
: undefined;
|
|
301
|
+
return {
|
|
302
|
+
animation: raw.animation,
|
|
303
|
+
words: words && words.length ? words : undefined,
|
|
304
|
+
activeColor: typeof raw.activeColor === "string" && raw.activeColor.trim() ? raw.activeColor.trim() : undefined,
|
|
305
|
+
highlightColor: typeof raw.highlightColor === "string" && raw.highlightColor.trim() ? raw.highlightColor.trim() : undefined,
|
|
306
|
+
uppercase: raw.uppercase === true ? true : undefined
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// Char-weighted word windows across a cue when the transcript has no
|
|
310
|
+
// word-level timings (Gemini segments, hand-typed text). Leaves a hair of
|
|
311
|
+
// silence between words so pop/highlight transitions read as discrete.
|
|
312
|
+
export function estimateCaptionWords(text, durationSeconds) {
|
|
313
|
+
const tokens = String(text || "").trim().split(/\s+/).filter(Boolean);
|
|
314
|
+
const duration = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : tokens.length * 0.32;
|
|
315
|
+
if (!tokens.length)
|
|
316
|
+
return [];
|
|
317
|
+
const weights = tokens.map((token) => Math.max(2, token.length) + 1.5);
|
|
318
|
+
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
|
|
319
|
+
const words = [];
|
|
320
|
+
let cursor = 0;
|
|
321
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
322
|
+
const span = (weights[index] / totalWeight) * duration;
|
|
323
|
+
const gap = Math.min(0.03, span * 0.1);
|
|
324
|
+
words.push({
|
|
325
|
+
text: tokens[index],
|
|
326
|
+
start: Number(cursor.toFixed(3)),
|
|
327
|
+
end: Number(Math.max(cursor + 0.05, cursor + span - gap).toFixed(3))
|
|
328
|
+
});
|
|
329
|
+
cursor += span;
|
|
330
|
+
}
|
|
331
|
+
return words;
|
|
332
|
+
}
|
|
333
|
+
export function captionInlineVars(config) {
|
|
334
|
+
const vars = [];
|
|
335
|
+
if (config.activeColor)
|
|
336
|
+
vars.push(`--vf-cap-active:${config.activeColor}`);
|
|
337
|
+
if (config.highlightColor)
|
|
338
|
+
vars.push(`--vf-cap-hl:${config.highlightColor}`);
|
|
339
|
+
return vars.join(";");
|
|
340
|
+
}
|
|
78
341
|
export function buildHyperframeCompositionHtml(input) {
|
|
79
342
|
const duration = positiveNumber(input.duration, inferDuration(input.layers));
|
|
80
343
|
const width = positiveInteger(input.width, 720);
|
|
@@ -93,6 +356,8 @@ export function buildHyperframeCompositionHtml(input) {
|
|
|
93
356
|
[data-composition-id] { position:relative; width:100vw; height:100vh; overflow:hidden; background:${background}; }
|
|
94
357
|
audio { display:none; }
|
|
95
358
|
${KEN_BURNS_CSS}
|
|
359
|
+
${CAPTION_ANIM_CSS}
|
|
360
|
+
${TRANSITION_CSS}
|
|
96
361
|
${input.css ?? ""}
|
|
97
362
|
</style>
|
|
98
363
|
</head>
|
|
@@ -118,6 +383,11 @@ export function assertHyperframeTimeline(layers) {
|
|
|
118
383
|
throw new Error(`HyperFrame layer ${layer.id} must have a positive duration.`);
|
|
119
384
|
}
|
|
120
385
|
}
|
|
386
|
+
// Public single-layer serializer so out-of-band builders (devcli composition
|
|
387
|
+
// edits) emit byte-identical layer markup to the composition builder.
|
|
388
|
+
export function renderHyperframeLayerHtml(layer) {
|
|
389
|
+
return renderLayer(layer);
|
|
390
|
+
}
|
|
121
391
|
function renderLayer(layer) {
|
|
122
392
|
const attrs = timedAttrs(layer);
|
|
123
393
|
const style = layerStyle(layer);
|
|
@@ -152,8 +422,39 @@ function renderLayer(layer) {
|
|
|
152
422
|
"white-space:pre-wrap",
|
|
153
423
|
"overflow:visible"
|
|
154
424
|
].join(";");
|
|
425
|
+
const captionConfig = layer.kind === "caption" || layer.kind === "text" ? normalizeCaptionConfig(layer.captionAnimation) : null;
|
|
426
|
+
if (captionConfig) {
|
|
427
|
+
const words = captionConfig.words ?? estimateCaptionWords(text, positiveNumber(layer.duration, 0));
|
|
428
|
+
if (words.length) {
|
|
429
|
+
const vars = captionInlineVars(captionConfig);
|
|
430
|
+
// text-transform must be INLINE: layerStyle already writes an inline
|
|
431
|
+
// text-transform:none, which would beat the [data-caption-uppercase]
|
|
432
|
+
// stylesheet rule. The attribute stays for round-trip state reads.
|
|
433
|
+
// overflow:visible (overriding layerStyle's hidden) because caption text
|
|
434
|
+
// wrapping past the cue box must never clip mid-glyph.
|
|
435
|
+
const upper = captionConfig.uppercase ? ";text-transform:uppercase" : "";
|
|
436
|
+
const divStyle = `${style}${vars ? `;${vars}` : ""}${upper};overflow:visible`;
|
|
437
|
+
const captionAttrs = `${attrs} data-caption-animation="${escapeAttr(captionConfig.animation)}"${captionConfig.uppercase ? " data-caption-uppercase=\"1\"" : ""}`;
|
|
438
|
+
return `<div ${captionAttrs} style="${escapeAttr(divStyle)}"><span data-vf-text-lines="true" style="${escapeAttr(linesStyle)}"><span data-vf-text-inline="true" style="${escapeAttr(inlineStyle)}">${captionWordSpansHtml(words)}</span></span></div>`;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
155
441
|
return `<div ${attrs} style="${escapeAttr(style)}"><span data-vf-text-lines="true" style="${escapeAttr(linesStyle)}"><span data-vf-text-inline="true" style="${escapeAttr(inlineStyle)}">${escapeHtml(text)}</span></span></div>`;
|
|
156
442
|
}
|
|
443
|
+
// Serializes word windows into the <span data-cap-word> run that the caption
|
|
444
|
+
// stylesheet animates. Delay/duration are the word window relative to the
|
|
445
|
+
// layer start — the ONLY timing source the render adapter needs; the
|
|
446
|
+
// data-word-* attrs exist so editing surfaces can re-derive timings without
|
|
447
|
+
// parsing CSS.
|
|
448
|
+
export function captionWordSpansHtml(words) {
|
|
449
|
+
return words
|
|
450
|
+
.map((word) => {
|
|
451
|
+
const start = Math.max(0, positiveNumber(word.start, 0));
|
|
452
|
+
const end = Math.max(start + 0.05, positiveNumber(word.end, start + 0.05));
|
|
453
|
+
const wordStyle = `animation-delay:${num(start)}s;animation-duration:${num(end - start)}s`;
|
|
454
|
+
return `<span data-cap-word="true" data-word-start="${num(start)}" data-word-end="${num(end)}" style="${wordStyle}">${escapeHtml(word.text)}</span>`;
|
|
455
|
+
})
|
|
456
|
+
.join(" ");
|
|
457
|
+
}
|
|
157
458
|
function timedAttrs(layer) {
|
|
158
459
|
const attrs = [
|
|
159
460
|
`id="${escapeAttr(layer.id)}"`,
|
|
@@ -170,6 +471,12 @@ function timedAttrs(layer) {
|
|
|
170
471
|
const mediaStart = positiveNumber(layer.playbackStart ?? layer.mediaStart, 0);
|
|
171
472
|
attrs.push(`data-media-start="${num(mediaStart)}"`, `data-playback-start="${num(mediaStart)}"`);
|
|
172
473
|
}
|
|
474
|
+
if (layer.kind !== "audio") {
|
|
475
|
+
const transition = normalizeTransition(layer.transition);
|
|
476
|
+
if (transition) {
|
|
477
|
+
attrs.push(`data-transition="${escapeAttr(transition.preset)}"`, `data-transition-duration="${num(clampTransitionDuration(transition.duration ?? TRANSITION_DEFAULT_DURATION))}"`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
173
480
|
if (["caption", "text", "shape", "html"].includes(layer.kind)) {
|
|
174
481
|
attrs.push(`data-text-background-style="${escapeAttr(layer.textBackgroundStyle || "plain")}"`, `data-text-background-color="${escapeAttr(layer.background || "#000000")}"`, `data-font-family="${escapeAttr(layer.fontFamily || "TikTok Sans")}"`);
|
|
175
482
|
}
|
|
@@ -190,6 +497,14 @@ function layerStyle(layer) {
|
|
|
190
497
|
if (layer.kind === "video" || layer.kind === "image") {
|
|
191
498
|
styles.push(`object-fit:${layer.fit || "cover"}`);
|
|
192
499
|
}
|
|
500
|
+
if (layer.kind !== "audio") {
|
|
501
|
+
// data-transition picks the keyframes; the inline var supplies the span so
|
|
502
|
+
// the animation covers exactly the transition window.
|
|
503
|
+
const transition = normalizeTransition(layer.transition);
|
|
504
|
+
if (transition) {
|
|
505
|
+
styles.push(`--vf-tr-dur:${num(clampTransitionDuration(transition.duration ?? TRANSITION_DEFAULT_DURATION))}s`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
193
508
|
if (["caption", "text", "shape", "html"].includes(layer.kind)) {
|
|
194
509
|
const fontFamily = layer.fontFamily || "TikTok Sans";
|
|
195
510
|
styles.push("display:flex", "align-items:center", "justify-content:center", "padding:3px", `font-family:${fontCssFamily(fontFamily)}, 'TikTok Sans', Montserrat, Abel, sans-serif`, `font-weight:${positiveInteger(layer.fontWeight, 700)}`, `line-height:${positiveNumber(layer.lineHeight, 1.18)}`, "text-align:center", "text-transform:none", `font-size:${positiveInteger(layer.fontSize, 32)}px`, `color:${layer.color || "#ffffff"}`, layer.kind === "shape" && layer.textBackgroundStyle === "panel"
|
|
@@ -181,6 +181,22 @@ export function createPrimitiveJobContext(input) {
|
|
|
181
181
|
workerId: input.workerId,
|
|
182
182
|
...request
|
|
183
183
|
});
|
|
184
|
+
},
|
|
185
|
+
async generateSpeech(request) {
|
|
186
|
+
return input.providers.generateSpeech({
|
|
187
|
+
customerId: input.customer.id,
|
|
188
|
+
jobId: input.job.id,
|
|
189
|
+
workerId: input.workerId,
|
|
190
|
+
...request
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
async transcribeSpeech(request) {
|
|
194
|
+
return input.providers.transcribeSpeech({
|
|
195
|
+
customerId: input.customer.id,
|
|
196
|
+
jobId: input.job.id,
|
|
197
|
+
workerId: input.workerId,
|
|
198
|
+
...request
|
|
199
|
+
});
|
|
184
200
|
}
|
|
185
201
|
},
|
|
186
202
|
hyperframes: {
|