@mevdragon/vidfarm-devcli 0.11.0 → 0.13.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 +21 -3
- package/SKILL.platform.md +17 -3
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +251 -66
- package/dist/src/app.js +788 -110
- package/dist/src/cli.js +57 -6
- package/dist/src/composition-runtime.js +136 -2
- package/dist/src/devcli/captions.js +310 -0
- package/dist/src/devcli/clips.js +5 -0
- package/dist/src/devcli/composition-edit.js +142 -0
- package/dist/src/devcli/speech.js +4 -1
- package/dist/src/editor-chat.js +4 -2
- package/dist/src/frontend/homepage-view.js +1 -1
- package/dist/src/frontend/template-editor-chat.js +3 -1
- package/dist/src/homepage.js +7 -0
- package/dist/src/hyperframes/composition.js +315 -0
- package/dist/src/primitive-registry.js +250 -49
- package/dist/src/services/captions.js +123 -0
- package/dist/src/services/hyperframes.js +113 -1
- package/dist/src/services/provider-errors.js +3 -0
- package/dist/src/services/providers.js +11 -4
- package/dist/src/services/serverless-records.js +12 -2
- package/dist/src/services/speech.js +166 -4
- package/dist/src/services/storage.js +12 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +23 -23
- package/public/assets/page-runtime-client-app.js +1 -1
package/dist/src/devcli/clips.js
CHANGED
|
@@ -166,6 +166,9 @@ async function runScan(argv) {
|
|
|
166
166
|
const guidance = buildEffectiveGuidance({ contentPrompt: guidancePromptRaw, avoidText }) || undefined;
|
|
167
167
|
// ── BACKUP path: run the hunt on the deployed pipeline (--cloud) ──────────
|
|
168
168
|
if (values.cloud) {
|
|
169
|
+
const apiProvider = values.provider && !["agent", "local", "local-agent"].includes(values.provider)
|
|
170
|
+
? normalizeProvider(values.provider)
|
|
171
|
+
: undefined; // unset → the cloud auto-picks from the account's saved keys
|
|
169
172
|
return runScanCloud({
|
|
170
173
|
videoArg: positionals[0],
|
|
171
174
|
sourceUrl: values.url?.trim() || undefined,
|
|
@@ -173,6 +176,7 @@ async function runScan(argv) {
|
|
|
173
176
|
apiKey: values["api-key"],
|
|
174
177
|
tracer: values.tracer,
|
|
175
178
|
prompt: guidancePromptRaw,
|
|
179
|
+
provider: apiProvider,
|
|
176
180
|
windows: rawWindows,
|
|
177
181
|
durationBand,
|
|
178
182
|
aspect,
|
|
@@ -431,6 +435,7 @@ async function runScanCloud(input) {
|
|
|
431
435
|
body: JSON.stringify({
|
|
432
436
|
...(input.sourceUrl ? { source_url: input.sourceUrl } : { temp_file_id: tempFileId, filename: fileName }),
|
|
433
437
|
prompt: input.prompt ?? "",
|
|
438
|
+
...(input.provider ? { provider: input.provider } : {}),
|
|
434
439
|
...(input.tracer ? { tracer: input.tracer } : {}),
|
|
435
440
|
hunt_spec: {
|
|
436
441
|
...(input.windows.length ? { windows: input.windows } : {}),
|
|
@@ -6,9 +6,14 @@
|
|
|
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
|
+
import { CAPTION_ANIMATIONS, CAPTION_STYLE_PRESETS, captionStylePresetById, estimateCaptionWords, renderHyperframeLayerHtml } from "../hyperframes/composition.js";
|
|
9
10
|
const KEN_BURNS_PRESETS = new Set([
|
|
10
11
|
"zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right"
|
|
11
12
|
]);
|
|
13
|
+
const TRANSITION_PRESET_SET = new Set([
|
|
14
|
+
"crossfade", "fade-black", "slide-left", "slide-right", "slide-up", "slide-down",
|
|
15
|
+
"wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open"
|
|
16
|
+
]);
|
|
12
17
|
const round3 = (v) => Number(v.toFixed(3));
|
|
13
18
|
const clampPercent = (v) => Math.min(100, Math.max(0, v));
|
|
14
19
|
function serialize(document) {
|
|
@@ -221,6 +226,18 @@ function buildMediaClip(document, opts, id, track, geom) {
|
|
|
221
226
|
styles.push(`--vf-kb-amount:${Math.max(0.04, Math.min(0.5, opts.kenBurnsIntensity))}`);
|
|
222
227
|
}
|
|
223
228
|
}
|
|
229
|
+
if (opts.transition && TRANSITION_PRESET_SET.has(opts.transition)) {
|
|
230
|
+
// Scene transition (see src/hyperframes/composition.ts): data-transition
|
|
231
|
+
// picks the keyframes; data-transition-duration + --vf-tr-dur set the span.
|
|
232
|
+
// The overlap with the previous clip is granted by the runtime/publish
|
|
233
|
+
// normalizer — the stored timeline stays butt-cut.
|
|
234
|
+
const transitionDuration = Number.isFinite(opts.transitionDuration) && opts.transitionDuration > 0
|
|
235
|
+
? Math.max(0.1, Math.min(2.5, opts.transitionDuration))
|
|
236
|
+
: 0.5;
|
|
237
|
+
node.setAttribute("data-transition", opts.transition);
|
|
238
|
+
node.setAttribute("data-transition-duration", String(round3(transitionDuration)));
|
|
239
|
+
styles.push(`--vf-tr-dur:${round3(transitionDuration)}s`);
|
|
240
|
+
}
|
|
224
241
|
node.setAttribute("style", `${styles.join(";")};`);
|
|
225
242
|
return node;
|
|
226
243
|
}
|
|
@@ -254,6 +271,131 @@ export function insertMediaLayer(html, opts) {
|
|
|
254
271
|
root.append(node);
|
|
255
272
|
return { html: serialize(document), layerKey: id };
|
|
256
273
|
}
|
|
274
|
+
const CAPTION_DEFAULT_FRAME = { x: 10, y: 70, width: 80, height: 14 };
|
|
275
|
+
function resolveCaptionLook(opts) {
|
|
276
|
+
const preset = captionStylePresetById(opts.style)
|
|
277
|
+
?? (opts.animation ? null : CAPTION_STYLE_PRESETS[0]);
|
|
278
|
+
const animation = (opts.animation ?? preset?.animation);
|
|
279
|
+
if (!animation || !CAPTION_ANIMATIONS.includes(animation)) {
|
|
280
|
+
throw new Error(`Unknown caption animation "${opts.animation ?? opts.style}". Use a preset (${CAPTION_STYLE_PRESETS.map((p) => p.id).join(", ")}) or an animation (${CAPTION_ANIMATIONS.join(", ")}).`);
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
animation,
|
|
284
|
+
activeColor: opts.activeColor ?? preset?.activeColor,
|
|
285
|
+
highlightColor: opts.highlightColor ?? preset?.highlightColor,
|
|
286
|
+
uppercase: opts.uppercase ?? preset?.uppercase,
|
|
287
|
+
color: opts.color ?? preset?.color ?? "#ffffff",
|
|
288
|
+
background: opts.background ?? preset?.background ?? "#000000",
|
|
289
|
+
backgroundStyle: (opts.backgroundStyle ?? preset?.textBackgroundStyle ?? "plain"),
|
|
290
|
+
fontFamily: opts.fontFamily ?? "Montserrat",
|
|
291
|
+
fontWeight: opts.fontWeight ?? preset?.fontWeight ?? 800,
|
|
292
|
+
fontSize: opts.fontSize ?? 38,
|
|
293
|
+
presetId: preset?.id ?? animation
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/** Read the animated-caption cues already in a composition (for restyle/list). */
|
|
297
|
+
export function readCaptionCues(html) {
|
|
298
|
+
const { root } = readCompositionDoc(html);
|
|
299
|
+
return Array.from(root.querySelectorAll("[data-caption-animation]")).map((node) => {
|
|
300
|
+
const words = Array.from(node.querySelectorAll("[data-cap-word]"))
|
|
301
|
+
.map((span) => ({
|
|
302
|
+
text: String(span.textContent || "").trim(),
|
|
303
|
+
start: Number.parseFloat(span.getAttribute("data-word-start") ?? "") || 0,
|
|
304
|
+
end: Number.parseFloat(span.getAttribute("data-word-end") ?? "") || 0
|
|
305
|
+
}))
|
|
306
|
+
.filter((word) => word.text);
|
|
307
|
+
return {
|
|
308
|
+
layerKey: node.getAttribute("data-hf-id") || node.id || "",
|
|
309
|
+
track: Number.isFinite(numAttr(node, "data-track-index")) ? numAttr(node, "data-track-index") : 0,
|
|
310
|
+
text: words.length ? words.map((word) => word.text).join(" ") : String(node.textContent || "").trim(),
|
|
311
|
+
start: Number.isFinite(numAttr(node, "data-start")) ? numAttr(node, "data-start") : 0,
|
|
312
|
+
duration: Number.isFinite(numAttr(node, "data-duration")) ? numAttr(node, "data-duration") : 0,
|
|
313
|
+
words: words.length ? words : undefined
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/** Remove every animated-caption layer. Returns the count removed. */
|
|
318
|
+
export function clearCaptionLayers(html) {
|
|
319
|
+
const { document, root } = readCompositionDoc(html);
|
|
320
|
+
const nodes = Array.from(root.querySelectorAll("[data-caption-animation]"));
|
|
321
|
+
for (const node of nodes)
|
|
322
|
+
node.parentNode?.removeChild(node);
|
|
323
|
+
return { html: serialize(document), removed: nodes.length };
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Insert an animated-caption cue run: one caption layer per cue on a shared
|
|
327
|
+
* collision-free track. Replaces any existing animated captions (set
|
|
328
|
+
* semantics — matching the editor's set_captions action).
|
|
329
|
+
*/
|
|
330
|
+
export function insertCaptionLayers(html, cues, opts = {}) {
|
|
331
|
+
const usable = cues
|
|
332
|
+
.filter((cue) => cue.text?.trim() && Number.isFinite(cue.start) && Number.isFinite(cue.duration) && cue.duration > 0)
|
|
333
|
+
.sort((a, b) => a.start - b.start);
|
|
334
|
+
if (!usable.length)
|
|
335
|
+
throw new Error("insertCaptionLayers needs at least one cue with text, start, and duration.");
|
|
336
|
+
const look = resolveCaptionLook(opts);
|
|
337
|
+
const { document, root } = readCompositionDoc(html);
|
|
338
|
+
const existing = Array.from(root.querySelectorAll("[data-caption-animation]"));
|
|
339
|
+
for (const node of existing)
|
|
340
|
+
node.parentNode?.removeChild(node);
|
|
341
|
+
const clips = collectClips(root);
|
|
342
|
+
const runStart = usable[0].start;
|
|
343
|
+
const runEnd = usable.reduce((max, cue) => Math.max(max, cue.start + cue.duration), runStart);
|
|
344
|
+
const initialTrack = opts.track !== undefined && Number.isFinite(opts.track)
|
|
345
|
+
? Math.max(0, Math.floor(opts.track))
|
|
346
|
+
: nextAutoTrack(clips);
|
|
347
|
+
const track = findNonCollidingTrack(clips, { start: runStart, duration: Math.max(0.1, runEnd - runStart), track: initialTrack }, "");
|
|
348
|
+
const stamp = Date.now().toString(36);
|
|
349
|
+
const layerKeys = [];
|
|
350
|
+
usable.forEach((cue, index) => {
|
|
351
|
+
const id = `element_cap_${stamp}_${index}`;
|
|
352
|
+
layerKeys.push(id);
|
|
353
|
+
const layer = {
|
|
354
|
+
id,
|
|
355
|
+
kind: "caption",
|
|
356
|
+
label: cue.text.slice(0, 48),
|
|
357
|
+
start: cue.start,
|
|
358
|
+
duration: cue.duration,
|
|
359
|
+
track,
|
|
360
|
+
z: track,
|
|
361
|
+
x: opts.x ?? CAPTION_DEFAULT_FRAME.x,
|
|
362
|
+
y: opts.y ?? CAPTION_DEFAULT_FRAME.y,
|
|
363
|
+
width: opts.width ?? CAPTION_DEFAULT_FRAME.width,
|
|
364
|
+
height: opts.height ?? CAPTION_DEFAULT_FRAME.height,
|
|
365
|
+
text: cue.text,
|
|
366
|
+
color: look.color,
|
|
367
|
+
background: look.background,
|
|
368
|
+
textBackgroundStyle: look.backgroundStyle,
|
|
369
|
+
fontFamily: look.fontFamily,
|
|
370
|
+
fontWeight: look.fontWeight,
|
|
371
|
+
fontSize: look.fontSize,
|
|
372
|
+
captionAnimation: {
|
|
373
|
+
animation: look.animation,
|
|
374
|
+
words: cue.words?.length ? cue.words : estimateCaptionWords(cue.text, cue.duration),
|
|
375
|
+
activeColor: look.activeColor,
|
|
376
|
+
highlightColor: look.highlightColor,
|
|
377
|
+
uppercase: look.uppercase
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const container = document.createElement("div");
|
|
381
|
+
container.innerHTML = renderHyperframeLayerHtml(layer);
|
|
382
|
+
const node = container.firstElementChild;
|
|
383
|
+
if (!node)
|
|
384
|
+
throw new Error(`Failed to render caption cue ${index}.`);
|
|
385
|
+
node.classList?.add?.("clip");
|
|
386
|
+
root.append(node);
|
|
387
|
+
});
|
|
388
|
+
return { html: serialize(document), layerKeys, replaced: existing.length, styleId: look.presetId };
|
|
389
|
+
}
|
|
390
|
+
/** Restyle existing animated captions in place: same cues/timings/track, new look. */
|
|
391
|
+
export function restyleCaptionLayers(html, opts) {
|
|
392
|
+
const cues = readCaptionCues(html);
|
|
393
|
+
if (!cues.length)
|
|
394
|
+
throw new Error("No animated caption layers found. Run `captions generate` (or add captions in the editor) first.");
|
|
395
|
+
const track = opts.track ?? cues[0].track;
|
|
396
|
+
const result = insertCaptionLayers(html, cues, { ...opts, track });
|
|
397
|
+
return { html: result.html, layerKeys: result.layerKeys, styleId: result.styleId };
|
|
398
|
+
}
|
|
257
399
|
// Replace an existing scene/layer with a generated media clip, taking over its
|
|
258
400
|
// timing + geometry (full-canvas when it was a proxy / full-canvas fill).
|
|
259
401
|
export function replaceLayerWithMedia(html, targetKey, opts) {
|
|
@@ -79,7 +79,9 @@ export async function localGenerateSpeech(input) {
|
|
|
79
79
|
});
|
|
80
80
|
return { ...result, provider: input.auth.provider, model };
|
|
81
81
|
}
|
|
82
|
-
/** Local STT: audio bytes → transcript (diarized on gemini) on the user's own key.
|
|
82
|
+
/** Local STT: audio bytes → transcript (diarized on gemini) on the user's own key.
|
|
83
|
+
* wordTimestamps requests word-level timings (real on openai/whisper-1; other
|
|
84
|
+
* providers stay segment-level and downstream callers estimate word windows). */
|
|
83
85
|
export async function localTranscribeSpeech(input) {
|
|
84
86
|
const model = input.model?.trim() || defaultSpeechModelFor("stt", input.auth.provider);
|
|
85
87
|
if (!model) {
|
|
@@ -93,6 +95,7 @@ export async function localTranscribeSpeech(input) {
|
|
|
93
95
|
prompt: input.prompt,
|
|
94
96
|
language: input.language,
|
|
95
97
|
diarize: input.diarize,
|
|
98
|
+
wordTimestamps: input.wordTimestamps,
|
|
96
99
|
apiKey: input.auth.apiKey
|
|
97
100
|
});
|
|
98
101
|
return { ...result, provider: input.auth.provider, model, srt: buildSrtFromSegments(result.segments) };
|
package/dist/src/editor-chat.js
CHANGED
|
@@ -42,7 +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/...). 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
|
+
"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.",
|
|
46
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.",
|
|
47
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.",
|
|
48
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.",
|
|
@@ -64,7 +64,7 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
64
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.",
|
|
65
65
|
"If multiple tracers are available, mention which tracer you are using when it matters.",
|
|
66
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.",
|
|
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, 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.",
|
|
68
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.",
|
|
69
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.",
|
|
70
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.",
|
|
@@ -79,6 +79,8 @@ export function buildTemplateEditorChatSystemPrompt(input) {
|
|
|
79
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.",
|
|
80
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.",
|
|
81
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, pick the cue source by where the speech lives: (a) narration that needs TRANSCRIBING — a TTS voiceover layer, replaced audio, or when word-accurate karaoke timing matters — call the captions primitive: POST /api/v1/primitives/audio/captions with body { tracer, payload: { source_url: <the narration audio/video URL from editor_context>, style } }; the finished job's result carries captions (the cue array) and set_captions_action (fully-formed editor_action arguments) — apply them verbatim with editor_action action_type=set_captions. Real word timestamps need an OpenAI key (tried first; gemini/openrouter degrade to estimated word windows). (b) speech that is the SOURCE VIDEO's own audio on an already-decomposed fork — video_context already has the transcript's timestamped segments, so skip the job and call editor_action action_type=set_captions directly 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. There is also a ONE-STEP server route, POST /api/v1/compositions/:forkId/captions { audio_url?|text?, style?, ... }, which transcribes the fork's own narration and writes the caption layers into the working composition server-side — use it ONLY for headless/automation flows, never while this editor session is open (the editor's next auto-save would overwrite the server-side change; in the editor always mutate through editor_action set_captions).",
|
|
82
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.",
|
|
83
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.",
|
|
84
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.",
|
|
@@ -378,7 +378,7 @@ function TemplateCard(input) {
|
|
|
378
378
|
? _jsx(LazyVideo, { src: template.previewUrl, controls: false })
|
|
379
379
|
: _jsx("img", { className: "preview", src: template.previewUrl, alt: `${template.title} preview`, loading: "lazy", decoding: "async" })
|
|
380
380
|
: _jsx("div", { className: "preview preview-empty", children: template.status === "processing" ? "Downloading…" : "No preview" });
|
|
381
|
-
return (_jsxs("li", { className: "discover-card row", "data-template-id": template.templateId, children: [template.previewUrl ? (_jsxs("button", { className: "discover-card-media discover-card-media-button", type: "button", "aria-label": `Open ${template.title} preview`, onClick: () => input.onOpenPreview?.(template), children: [media, _jsx("span", { className: "discover-card-media-affordance", "aria-hidden": "true", children: "View" })] })) : (_jsx("div", { className: "discover-card-media", children: media })), _jsxs("div", { className: "discover-card-copy", children: [_jsxs("div", { className: "discover-card-head", children: [_jsxs("div", { className: "discover-card-title-wrap", children: [_jsx("h2", { children: template.title }), _jsx("p", { className: "discover-card-meta", children: formatTemplateMeta(template) })] }), _jsxs("div", { className: "discover-card-actions", children: [_jsx(BookmarkButton, { templateId: template.templateId, title: template.title, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark }), isPending ? (_jsx("span", { className: "cta-button discover-card-cta-disabled", "aria-disabled": "true", children: template.status === "failed" ? "Failed" : "Preparing…" })) : (_jsx(CreateButton, { templateId: template.templateId, accountUserId: input.accountUserId })), _jsx(CardMenu, { template: template, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.accountUserId })] })] }), _jsx("div", { className: "discover-card-body", children: _jsx("p", { children: String(template.viralDna || "").trim() || "No layer strategy provided for this composition yet." }) }), _jsxs("div", { className: "discover-card-tags", children: [template.visibility === "private" ? (_jsx("div", { className: "pill-tag private-pill", title: "Only visible to your account", children: "Only you" })) : null, template.status === "processing" ? (_jsx("div", { className: "pill-tag processing-pill", children: "Processing" })) : template.status === "failed" ? (_jsx("div", { className: "pill-tag failed-pill", children: "Download failed" })) : null, _jsx("div", { className: "pill-tag difficulty-pill", "data-difficulty": template.difficulty, children: formatDifficulty(template.difficulty) }), _jsx("div", { className: "pill-tag", children: template.sourceType || (isVideoPreview(template.previewUrl) ? "Video source" : "Image sequence") }), _jsx(CopyInlineButton, { text: template.templateId, label: template.templateId })] })] })] }));
|
|
381
|
+
return (_jsxs("li", { className: "discover-card row", "data-template-id": template.templateId, children: [template.previewUrl ? (_jsxs("button", { className: "discover-card-media discover-card-media-button", type: "button", "aria-label": `Open ${template.title} preview`, onClick: () => input.onOpenPreview?.(template), children: [media, _jsx("span", { className: "discover-card-media-affordance", "aria-hidden": "true", children: "View" })] })) : (_jsx("div", { className: "discover-card-media", children: media })), _jsxs("div", { className: "discover-card-copy", children: [_jsxs("div", { className: "discover-card-head", children: [_jsxs("div", { className: "discover-card-title-wrap", children: [_jsx("h2", { children: template.title }), _jsx("p", { className: "discover-card-meta", children: formatTemplateMeta(template) })] }), _jsxs("div", { className: "discover-card-actions", children: [_jsx(BookmarkButton, { templateId: template.templateId, title: template.title, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark }), isPending ? (_jsx("span", { className: "cta-button discover-card-cta-disabled", "aria-disabled": "true", children: template.status === "failed" ? "Failed" : "Preparing…" })) : (_jsx(CreateButton, { templateId: template.templateId, accountUserId: input.accountUserId })), _jsx(CardMenu, { template: template, isBookmarked: input.isBookmarked, onToggleBookmark: input.onToggleBookmark, onDeleteTemplate: input.onDeleteTemplate, accountUserId: input.accountUserId })] })] }), _jsx("div", { className: "discover-card-body", children: _jsx("p", { children: String(template.viralDna || "").trim() || "No layer strategy provided for this composition yet." }) }), _jsxs("div", { className: "discover-card-tags", children: [template.origin === "raw" ? (_jsx("div", { className: "pill-tag origin-pill", title: "Your own project \u2014 created from a prompt or your raw clips, not an imported inspiration", children: "Your project" })) : null, template.visibility === "private" ? (_jsx("div", { className: "pill-tag private-pill", title: "Only visible to your account", children: "Only you" })) : null, template.status === "processing" ? (_jsx("div", { className: "pill-tag processing-pill", children: "Processing" })) : template.status === "failed" ? (_jsx("div", { className: "pill-tag failed-pill", children: "Download failed" })) : null, _jsx("div", { className: "pill-tag difficulty-pill", "data-difficulty": template.difficulty, children: formatDifficulty(template.difficulty) }), _jsx("div", { className: "pill-tag", children: template.sourceType || (isVideoPreview(template.previewUrl) ? "Video source" : "Image sequence") }), _jsx(CopyInlineButton, { text: template.templateId, label: template.templateId })] })] })] }));
|
|
382
382
|
}
|
|
383
383
|
function PreviewModal(input) {
|
|
384
384
|
const { onClose, template } = input;
|
|
@@ -3624,10 +3624,12 @@ export function TemplateEditorChat({ boot }) {
|
|
|
3624
3624
|
}
|
|
3625
3625
|
if (!storageKey)
|
|
3626
3626
|
throw new Error("upload did not return a storage key.");
|
|
3627
|
+
// origin:"raw" — a prompt-generated video is the user's own new project,
|
|
3628
|
+
// not an imported inspiration; the editor must not auto-prompt Decompose.
|
|
3627
3629
|
const addRes = await fetch(new URL("/discover/templates", origin), {
|
|
3628
3630
|
method: "POST",
|
|
3629
3631
|
headers: jsonHeaders,
|
|
3630
|
-
body: JSON.stringify({ upload: { storage_key: storageKey, file_name: uploadedName }, title })
|
|
3632
|
+
body: JSON.stringify({ upload: { storage_key: storageKey, file_name: uploadedName }, title, origin: "raw" })
|
|
3631
3633
|
});
|
|
3632
3634
|
const add = await readJson(addRes);
|
|
3633
3635
|
if (!addRes.ok)
|
package/dist/src/homepage.js
CHANGED
|
@@ -366,6 +366,13 @@ export function renderHomepage(input) {
|
|
|
366
366
|
font-weight: 800;
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
.origin-pill {
|
|
370
|
+
border-color: rgba(46, 160, 100, 0.35);
|
|
371
|
+
background: rgba(46, 160, 100, 0.1);
|
|
372
|
+
color: #1f7a4d;
|
|
373
|
+
font-weight: 800;
|
|
374
|
+
}
|
|
375
|
+
|
|
369
376
|
.processing-pill {
|
|
370
377
|
border-color: rgba(88, 132, 196, 0.4);
|
|
371
378
|
background: rgba(88, 132, 196, 0.12);
|