@mevdragon/vidfarm-devcli 0.13.0 → 0.14.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 +23 -4
- package/SKILL.platform.md +2 -2
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +190 -75
- package/dist/src/account-pages-legacy.js +9 -14
- package/dist/src/app.js +344 -28
- package/dist/src/cli.js +89 -5
- package/dist/src/composition-runtime.js +24 -3
- package/dist/src/devcli/composition-edit.js +176 -8
- package/dist/src/devcli/transitions.js +205 -0
- package/dist/src/editor-chat.js +5 -4
- package/dist/src/frontend/template-editor-chat.js +6 -5
- package/dist/src/homepage.js +2 -48
- package/dist/src/hyperframes/composition.js +183 -2
- package/dist/src/services/hyperframes.js +60 -15
- package/dist/src/services/serverless-records.js +7 -0
- package/dist/src/template-editor-shell.js +87 -48
- package/package.json +1 -1
- package/public/assets/page-runtime-client-app.js +31 -31
package/dist/src/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
11
11
|
import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./devcli/composition-edit.js";
|
|
12
12
|
import { runCaptionsCommand } from "./devcli/captions.js";
|
|
13
13
|
import { runClipsCommand } from "./devcli/clips.js";
|
|
14
|
+
import { runTransitionsCommand } from "./devcli/transitions.js";
|
|
14
15
|
import { MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES, loadSpeechAudioLocally, localGenerateSpeech, localTranscribeSpeech, resolveLocalSpeechAuth } from "./devcli/speech.js";
|
|
15
16
|
import { inferSpeechProviderForVoice } from "./services/speech.js";
|
|
16
17
|
import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
|
|
@@ -148,10 +149,15 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
148
149
|
--ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
|
|
149
150
|
--transition <preset> Scene transition INTO this clip (the previous
|
|
150
151
|
butt-cut clip is held beneath it automatically):
|
|
151
|
-
crossfade|fade-black|
|
|
152
|
-
slide-
|
|
152
|
+
crossfade|fade-black|fade-white|flash|smoke|blur|
|
|
153
|
+
slide-left|slide-right|slide-up|slide-down|
|
|
154
|
+
whip-left|whip-right|wipe-left|wipe-right|wipe-up|
|
|
153
155
|
wipe-down|zoom-in|zoom-out|circle-open
|
|
154
156
|
--transition-duration <s> Transition length in seconds 0.1-2.5 (default 0.5)
|
|
157
|
+
--transition-out <preset> Exit transition OUT of this clip at its end:
|
|
158
|
+
fade|fade-black|fade-white|flash|smoke|blur|
|
|
159
|
+
slide-*|wipe-*|zoom-in|zoom-out|circle-close
|
|
160
|
+
--transition-out-duration <s> Exit length in seconds 0.1-2.5 (default 0.5)
|
|
155
161
|
remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
|
|
156
162
|
original vs decomposed (caption-free),
|
|
157
163
|
non-billing (alias: ghostcut)
|
|
@@ -207,6 +213,16 @@ Animated captions (word-by-word TikTok/CapCut styles on a pulled/served composit
|
|
|
207
213
|
captions style <dir> Restyle existing cues (same text/timings)
|
|
208
214
|
captions list <dir> Show the current animated caption cues [--json]
|
|
209
215
|
captions clear <dir> Remove all animated caption layers
|
|
216
|
+
|
|
217
|
+
Scene transitions (between clips on a pulled/served composition — local file write):
|
|
218
|
+
transitions apply <dir> One pass over the whole timeline:
|
|
219
|
+
--preset <p> Junction preset at EVERY cut ("none" clears)
|
|
220
|
+
--duration <s> 0.1-2.5 (default 0.5)
|
|
221
|
+
--intro <p> --outro <p> First clip's entrance / last clip's exit
|
|
222
|
+
transitions set <dir> --layer <key> --in <p> --out <p> Configure one clip
|
|
223
|
+
transitions list <dir> Show transitions on every scene clip [--json]
|
|
224
|
+
transitions clear <dir> Remove all transitions
|
|
225
|
+
(run 'vidfarm transitions' for presets & all flags)
|
|
210
226
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
211
227
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
212
228
|
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
@@ -251,15 +267,22 @@ Files (multi-step flows the devcli handles for you):
|
|
|
251
267
|
--folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
|
|
252
268
|
--as <name> Name the saved file (required for --content/--stdin)
|
|
253
269
|
--content <text> Inline text to save (needs --as); or pipe stdin with --stdin
|
|
270
|
+
--notes <text> Metadata notes (what it is / when to use it) — vector-embedded for search
|
|
254
271
|
e.g. vidfarm put-file About.md --folder acme-skincare
|
|
255
272
|
echo "..." | vidfarm put-file --stdin --as About.md --folder acme
|
|
256
273
|
download <url> [dest] Stream any Vidfarm/media URL to disk
|
|
257
274
|
files List My Files assets + folders → GET /api/v1/user/me/attachments
|
|
258
275
|
--folder <path> Only files under this folder (e.g. a product/offer)
|
|
276
|
+
--search <query> Find files by MEANING (keyword + vector over → POST /api/v1/user/me/attachments/search
|
|
277
|
+
name/folder/notes) instead of listing everything
|
|
259
278
|
get-file <id> [dest] Resolve a My Files id to its URL and download it
|
|
260
279
|
--print Print text contents (md/txt/csv/json) instead of saving
|
|
261
|
-
|
|
280
|
+
annotate-file <id|name> Set metadata notes on one My Files entry → PATCH /api/v1/user/me/attachments/:id
|
|
281
|
+
--notes <text> The notes to attach (empty string clears); embedded for vector search
|
|
282
|
+
put-file / get-file / files / annotate-file are the My Files (persistent) set;
|
|
262
283
|
upload is the throwaway temp store for dropping media into a composition.
|
|
284
|
+
Annotate assets you'll want back — esp. character refs (character_sprite_card.png
|
|
285
|
+
+ about_character.md) — so 'files --search "the mascot"' finds them later.
|
|
263
286
|
Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
|
|
264
287
|
them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
|
|
265
288
|
to that box's local disk store, free local render). When you DO need a durable
|
|
@@ -458,12 +481,18 @@ async function main() {
|
|
|
458
481
|
case "save-file":
|
|
459
482
|
await runPutFileCommand(rest);
|
|
460
483
|
return;
|
|
484
|
+
case "annotate-file":
|
|
485
|
+
await runAnnotateFileCommand(rest);
|
|
486
|
+
return;
|
|
461
487
|
case "clips":
|
|
462
488
|
await runClipsCommand(rest);
|
|
463
489
|
return;
|
|
464
490
|
case "captions":
|
|
465
491
|
await runCaptionsCommand(rest);
|
|
466
492
|
return;
|
|
493
|
+
case "transitions":
|
|
494
|
+
await runTransitionsCommand(rest);
|
|
495
|
+
return;
|
|
467
496
|
default:
|
|
468
497
|
if (!command.startsWith("-")) {
|
|
469
498
|
// Positional template id → default to booting the local editor.
|
|
@@ -2026,6 +2055,8 @@ function placeMediaOnDisk(input) {
|
|
|
2026
2055
|
kenBurnsIntensity: input.kenBurnsIntensity,
|
|
2027
2056
|
transition: input.transition,
|
|
2028
2057
|
transitionDuration: input.transitionDuration,
|
|
2058
|
+
transitionOut: input.transitionOut,
|
|
2059
|
+
transitionOutDuration: input.transitionOutDuration,
|
|
2029
2060
|
slug: input.slug
|
|
2030
2061
|
};
|
|
2031
2062
|
const result = input.replace
|
|
@@ -2059,6 +2090,8 @@ async function runGenerateCommand(argv) {
|
|
|
2059
2090
|
"ken-burns-intensity": { type: "string" },
|
|
2060
2091
|
transition: { type: "string" },
|
|
2061
2092
|
"transition-duration": { type: "string" },
|
|
2093
|
+
"transition-out": { type: "string" },
|
|
2094
|
+
"transition-out-duration": { type: "string" },
|
|
2062
2095
|
"layer-key": { type: "string" }
|
|
2063
2096
|
}
|
|
2064
2097
|
});
|
|
@@ -2134,6 +2167,8 @@ async function runGenerateCommand(argv) {
|
|
|
2134
2167
|
kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
|
|
2135
2168
|
transition: parsed.values.transition,
|
|
2136
2169
|
transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
|
|
2170
|
+
transitionOut: parsed.values["transition-out"],
|
|
2171
|
+
transitionOutDuration: parsed.values["transition-out-duration"] ? Number(parsed.values["transition-out-duration"]) : undefined,
|
|
2137
2172
|
layerKey: parsed.values["layer-key"]
|
|
2138
2173
|
});
|
|
2139
2174
|
if (!ctx.json)
|
|
@@ -2520,6 +2555,8 @@ async function runPlaceCommand(argv) {
|
|
|
2520
2555
|
"ken-burns-intensity": { type: "string" },
|
|
2521
2556
|
transition: { type: "string" },
|
|
2522
2557
|
"transition-duration": { type: "string" },
|
|
2558
|
+
"transition-out": { type: "string" },
|
|
2559
|
+
"transition-out-duration": { type: "string" },
|
|
2523
2560
|
"layer-key": { type: "string" },
|
|
2524
2561
|
slug: { type: "string" },
|
|
2525
2562
|
// Local file support: where a serve box serves /storage from, and which
|
|
@@ -2564,6 +2601,8 @@ async function runPlaceCommand(argv) {
|
|
|
2564
2601
|
kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
|
|
2565
2602
|
transition: parsed.values.transition,
|
|
2566
2603
|
transitionDuration: num(parsed.values["transition-duration"]),
|
|
2604
|
+
transitionOut: parsed.values["transition-out"],
|
|
2605
|
+
transitionOutDuration: num(parsed.values["transition-out-duration"]),
|
|
2567
2606
|
layerKey: parsed.values["layer-key"],
|
|
2568
2607
|
slug: parsed.values.slug
|
|
2569
2608
|
});
|
|
@@ -2889,11 +2928,25 @@ async function runDownloadCommand(argv) {
|
|
|
2889
2928
|
// This is the same filesystem the /editor AI agent browses via the browse_files
|
|
2890
2929
|
// tool, so an agent CLI (Claude Code / Codex) can find assets the same way.
|
|
2891
2930
|
async function runFilesCommand(argv) {
|
|
2892
|
-
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" } } });
|
|
2931
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" }, search: { type: "string" } } });
|
|
2893
2932
|
const ctx = commonContext(parsed.values);
|
|
2933
|
+
const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
|
|
2934
|
+
if (parsed.values.search) {
|
|
2935
|
+
// Meaning-first lookup: keyword + vector search over every file's
|
|
2936
|
+
// name/folder/notes (BYOK embeddings; keyword-only without a saved key).
|
|
2937
|
+
const result = await apiRequest({
|
|
2938
|
+
method: "POST",
|
|
2939
|
+
host: ctx.host,
|
|
2940
|
+
path: "/api/v1/user/me/attachments/search",
|
|
2941
|
+
auth: ctx.auth,
|
|
2942
|
+
body: { query: String(parsed.values.search), ...(folderFilter ? { folder_path: folderFilter } : {}) }
|
|
2943
|
+
});
|
|
2944
|
+
assertApiOk(result, "files --search");
|
|
2945
|
+
emitResult(result, ctx.json);
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2894
2948
|
const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
|
|
2895
2949
|
assertApiOk(result, "files");
|
|
2896
|
-
const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
|
|
2897
2950
|
if (folderFilter && result.json && Array.isArray(result.json.attachments)) {
|
|
2898
2951
|
result.json = {
|
|
2899
2952
|
...result.json,
|
|
@@ -2902,6 +2955,34 @@ async function runFilesCommand(argv) {
|
|
|
2902
2955
|
}
|
|
2903
2956
|
emitResult(result, ctx.json);
|
|
2904
2957
|
}
|
|
2958
|
+
// Attach metadata notes to one My Files entry (by id, or file name as a
|
|
2959
|
+
// fallback). Notes describe what the file IS and when to use it; the server
|
|
2960
|
+
// vector-embeds them so `files --search` finds the asset by meaning later —
|
|
2961
|
+
// the My Files analog of the clip library's semantic search.
|
|
2962
|
+
async function runAnnotateFileCommand(argv) {
|
|
2963
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), notes: { type: "string" } } });
|
|
2964
|
+
const fileRef = parsed.positionals[0];
|
|
2965
|
+
if (!fileRef)
|
|
2966
|
+
throw new Error("annotate-file requires a file id or name (run `files` to list ids).");
|
|
2967
|
+
if (parsed.values.notes == null)
|
|
2968
|
+
throw new Error("annotate-file requires --notes <text> (empty string clears existing notes).");
|
|
2969
|
+
const ctx = commonContext(parsed.values);
|
|
2970
|
+
const list = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
|
|
2971
|
+
assertApiOk(list, "annotate-file");
|
|
2972
|
+
const attachments = Array.isArray(list.json?.attachments) ? list.json.attachments : [];
|
|
2973
|
+
const match = attachments.find((file) => file?.id === fileRef) ?? attachments.find((file) => file?.fileName === fileRef);
|
|
2974
|
+
if (!match)
|
|
2975
|
+
throw new Error(`No file with id or name "${fileRef}" in My Files. Run \`files\` to list ids.`);
|
|
2976
|
+
const result = await apiRequest({
|
|
2977
|
+
method: "PATCH",
|
|
2978
|
+
host: ctx.host,
|
|
2979
|
+
path: `/api/v1/user/me/attachments/${encodeURIComponent(match.id)}`,
|
|
2980
|
+
auth: ctx.auth,
|
|
2981
|
+
body: { notes: String(parsed.values.notes) }
|
|
2982
|
+
});
|
|
2983
|
+
assertApiOk(result, "annotate-file");
|
|
2984
|
+
emitResult(result, ctx.json, [["Notes embedded", result.json?.notes_embedded ? "yes (vector-searchable)" : "no (keyword-searchable only — add a gemini/openai provider key to embed)"]]);
|
|
2985
|
+
}
|
|
2905
2986
|
async function runGetFileCommand(argv) {
|
|
2906
2987
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), print: { type: "boolean", default: false } } });
|
|
2907
2988
|
const fileId = parsed.positionals[0];
|
|
@@ -2955,6 +3036,7 @@ async function runPutFileCommand(argv) {
|
|
|
2955
3036
|
folder: { type: "string" },
|
|
2956
3037
|
as: { type: "string" },
|
|
2957
3038
|
content: { type: "string" },
|
|
3039
|
+
notes: { type: "string" },
|
|
2958
3040
|
stdin: { type: "boolean", default: false }
|
|
2959
3041
|
}
|
|
2960
3042
|
});
|
|
@@ -2991,6 +3073,8 @@ async function runPutFileCommand(argv) {
|
|
|
2991
3073
|
form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
|
|
2992
3074
|
if (parsed.values.folder)
|
|
2993
3075
|
form.append("folder_path", String(parsed.values.folder));
|
|
3076
|
+
if (parsed.values.notes)
|
|
3077
|
+
form.append("notes", String(parsed.values.notes));
|
|
2994
3078
|
const res = await fetch(new URL("/api/v1/user/me/attachments/upload", ctx.host), {
|
|
2995
3079
|
method: "POST",
|
|
2996
3080
|
headers: buildAuthHeaders(ctx.auth),
|
|
@@ -176,7 +176,10 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
176
176
|
// previous clip on its track — so that previous clip must stay active for
|
|
177
177
|
// the transition window. Publish materializes this overlap into
|
|
178
178
|
// data-duration (normalizePublishHtml); the preview grants it live so
|
|
179
|
-
// butt-cut drafts preview exactly like the render.
|
|
179
|
+
// butt-cut drafts preview exactly like the render. A [data-transition-out]
|
|
180
|
+
// clip additionally animates AWAY (vf-tr-out-* keyframes) over its last
|
|
181
|
+
// data-transition-out-duration seconds — no hold needed, the exit window
|
|
182
|
+
// lives inside the clip's own span.
|
|
180
183
|
function collectTransitionWindows() {
|
|
181
184
|
const windows = [];
|
|
182
185
|
for (const clip of clips) {
|
|
@@ -210,8 +213,26 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
210
213
|
return hold;
|
|
211
214
|
}
|
|
212
215
|
|
|
213
|
-
function syncTransitionClip(clip, timing) {
|
|
216
|
+
function syncTransitionClip(clip, timing, hold) {
|
|
214
217
|
if (typeof clip.getAnimations !== "function") return;
|
|
218
|
+
// Exit animations sit at the clip's tail via the inline --vf-tr-out-delay
|
|
219
|
+
// var. Timeline edits touch data-duration only, so re-derive the delay
|
|
220
|
+
// (duration + any live hold − out duration) before scrubbing; publish
|
|
221
|
+
// materializes the same value for the render producer.
|
|
222
|
+
if (clip.hasAttribute("data-transition-out")) {
|
|
223
|
+
let outDuration = Number(clip.getAttribute("data-transition-out-duration"));
|
|
224
|
+
if (!Number.isFinite(outDuration) || outDuration <= 0) outDuration = 0.5;
|
|
225
|
+
outDuration = Math.min(outDuration, timing.duration);
|
|
226
|
+
const expectedDelay = Math.max(0, timing.duration + (hold || 0) - outDuration);
|
|
227
|
+
try {
|
|
228
|
+
if (Math.abs((parseFloat(clip.style.getPropertyValue("--vf-tr-out-dur")) || 0) - outDuration) > 0.002) {
|
|
229
|
+
clip.style.setProperty("--vf-tr-out-dur", outDuration.toFixed(3) + "s");
|
|
230
|
+
}
|
|
231
|
+
if (Math.abs((parseFloat(clip.style.getPropertyValue("--vf-tr-out-delay")) || 0) - expectedDelay) > 0.002) {
|
|
232
|
+
clip.style.setProperty("--vf-tr-out-delay", expectedDelay.toFixed(3) + "s");
|
|
233
|
+
}
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
215
236
|
const offsetMs = Math.max(0, (time - timing.start)) * 1000;
|
|
216
237
|
let anims = [];
|
|
217
238
|
try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
|
|
@@ -310,7 +331,7 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
310
331
|
}
|
|
311
332
|
if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
|
|
312
333
|
if (clip.hasAttribute("data-caption-animation")) syncCaptionClip(clip, timing);
|
|
313
|
-
if (clip.hasAttribute("data-transition")) syncTransitionClip(clip, timing);
|
|
334
|
+
if (clip.hasAttribute("data-transition") || clip.hasAttribute("data-transition-out")) syncTransitionClip(clip, timing, hold);
|
|
314
335
|
}
|
|
315
336
|
if (clip instanceof HTMLMediaElement) {
|
|
316
337
|
applyMediaState(clip);
|
|
@@ -6,14 +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
|
+
import { CAPTION_ANIMATIONS, CAPTION_STYLE_PRESETS, TRANSITION_OUT_PRESETS, TRANSITION_PRESETS, captionStylePresetById, clampTransitionDuration, estimateCaptionWords, renderHyperframeLayerHtml, transitionOutDelaySeconds } from "../hyperframes/composition.js";
|
|
10
10
|
const KEN_BURNS_PRESETS = new Set([
|
|
11
11
|
"zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right"
|
|
12
12
|
]);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
// Preset vocabulary comes straight from the canonical source so the devcli can
|
|
14
|
+
// never drift from the stylesheet.
|
|
15
|
+
const TRANSITION_PRESET_SET = new Set(TRANSITION_PRESETS);
|
|
16
|
+
const TRANSITION_OUT_PRESET_SET = new Set(TRANSITION_OUT_PRESETS);
|
|
17
17
|
const round3 = (v) => Number(v.toFixed(3));
|
|
18
18
|
const clampPercent = (v) => Math.min(100, Math.max(0, v));
|
|
19
19
|
function serialize(document) {
|
|
@@ -231,13 +231,19 @@ function buildMediaClip(document, opts, id, track, geom) {
|
|
|
231
231
|
// picks the keyframes; data-transition-duration + --vf-tr-dur set the span.
|
|
232
232
|
// The overlap with the previous clip is granted by the runtime/publish
|
|
233
233
|
// normalizer — the stored timeline stays butt-cut.
|
|
234
|
-
const transitionDuration =
|
|
235
|
-
? Math.max(0.1, Math.min(2.5, opts.transitionDuration))
|
|
236
|
-
: 0.5;
|
|
234
|
+
const transitionDuration = clampTransitionDuration(opts.transitionDuration ?? 0.5);
|
|
237
235
|
node.setAttribute("data-transition", opts.transition);
|
|
238
236
|
node.setAttribute("data-transition-duration", String(round3(transitionDuration)));
|
|
239
237
|
styles.push(`--vf-tr-dur:${round3(transitionDuration)}s`);
|
|
240
238
|
}
|
|
239
|
+
if (opts.transitionOut && TRANSITION_OUT_PRESET_SET.has(opts.transitionOut)) {
|
|
240
|
+
// Exit transition: the inline --vf-tr-out-delay (clip duration − out
|
|
241
|
+
// duration) parks the animation window at the clip's tail.
|
|
242
|
+
const outDuration = clampTransitionDuration(opts.transitionOutDuration ?? 0.5);
|
|
243
|
+
node.setAttribute("data-transition-out", opts.transitionOut);
|
|
244
|
+
node.setAttribute("data-transition-out-duration", String(round3(outDuration)));
|
|
245
|
+
styles.push(`--vf-tr-out-dur:${round3(outDuration)}s`, `--vf-tr-out-delay:${transitionOutDelaySeconds(geom.duration, outDuration)}s`);
|
|
246
|
+
}
|
|
241
247
|
node.setAttribute("style", `${styles.join(";")};`);
|
|
242
248
|
return node;
|
|
243
249
|
}
|
|
@@ -438,4 +444,166 @@ export function replaceLayerWithMedia(html, targetKey, opts) {
|
|
|
438
444
|
void targetId;
|
|
439
445
|
return { html: serialize(document), layerKey: id };
|
|
440
446
|
}
|
|
447
|
+
function upsertStyleDecl(node, name, value) {
|
|
448
|
+
const styleValue = node.getAttribute("style") || "";
|
|
449
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
450
|
+
const pattern = new RegExp(`(^|;)\\s*${escaped}\\s*:[^;]*`, "i");
|
|
451
|
+
let nextStyle;
|
|
452
|
+
if (value === null) {
|
|
453
|
+
nextStyle = styleValue.replace(pattern, (_match, lead) => lead);
|
|
454
|
+
}
|
|
455
|
+
else if (pattern.test(styleValue)) {
|
|
456
|
+
nextStyle = styleValue.replace(pattern, (_match, lead) => `${lead}${name}:${value}`);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
nextStyle = styleValue.length === 0 || styleValue.endsWith(";")
|
|
460
|
+
? `${styleValue}${name}:${value}`
|
|
461
|
+
: `${styleValue};${name}:${value}`;
|
|
462
|
+
}
|
|
463
|
+
if (nextStyle !== styleValue)
|
|
464
|
+
node.setAttribute("style", nextStyle);
|
|
465
|
+
}
|
|
466
|
+
// Scene clips are what junction transitions connect: video/image layers and
|
|
467
|
+
// timeline proxies. Audio, captions, text overlays and the backing source
|
|
468
|
+
// video are excluded (same filter as the editor's timeline chips).
|
|
469
|
+
function isSceneClipNode(node) {
|
|
470
|
+
if (node?.hasAttribute?.("data-vf-playback-source"))
|
|
471
|
+
return false;
|
|
472
|
+
const tag = String(node?.tagName || "").toLowerCase();
|
|
473
|
+
if (tag === "audio")
|
|
474
|
+
return false;
|
|
475
|
+
const kind = node?.getAttribute?.("data-layer-kind");
|
|
476
|
+
return tag === "video" || tag === "img"
|
|
477
|
+
|| node?.getAttribute?.("data-vf-timeline-proxy") === "video"
|
|
478
|
+
|| kind === "video" || kind === "image";
|
|
479
|
+
}
|
|
480
|
+
function setNodeTransition(node, preset, durationSeconds) {
|
|
481
|
+
if (!preset || preset === "none") {
|
|
482
|
+
node.removeAttribute("data-transition");
|
|
483
|
+
node.removeAttribute("data-transition-duration");
|
|
484
|
+
upsertStyleDecl(node, "--vf-tr-dur", null);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (!TRANSITION_PRESET_SET.has(preset)) {
|
|
488
|
+
throw new Error(`Unknown transition preset "${preset}". Use one of: none, ${TRANSITION_PRESETS.join(", ")}.`);
|
|
489
|
+
}
|
|
490
|
+
const current = Number.parseFloat(node.getAttribute("data-transition-duration") ?? "");
|
|
491
|
+
const duration = clampTransitionDuration(durationSeconds ?? (Number.isFinite(current) ? current : undefined));
|
|
492
|
+
node.setAttribute("data-transition", preset);
|
|
493
|
+
node.setAttribute("data-transition-duration", String(round3(duration)));
|
|
494
|
+
upsertStyleDecl(node, "--vf-tr-dur", `${round3(duration)}s`);
|
|
495
|
+
}
|
|
496
|
+
function setNodeTransitionOut(node, preset, durationSeconds) {
|
|
497
|
+
if (!preset || preset === "none") {
|
|
498
|
+
node.removeAttribute("data-transition-out");
|
|
499
|
+
node.removeAttribute("data-transition-out-duration");
|
|
500
|
+
upsertStyleDecl(node, "--vf-tr-out-dur", null);
|
|
501
|
+
upsertStyleDecl(node, "--vf-tr-out-delay", null);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (!TRANSITION_OUT_PRESET_SET.has(preset)) {
|
|
505
|
+
throw new Error(`Unknown exit transition preset "${preset}". Use one of: none, ${TRANSITION_OUT_PRESETS.join(", ")}.`);
|
|
506
|
+
}
|
|
507
|
+
const current = Number.parseFloat(node.getAttribute("data-transition-out-duration") ?? "");
|
|
508
|
+
const duration = clampTransitionDuration(durationSeconds ?? (Number.isFinite(current) ? current : undefined));
|
|
509
|
+
const layerDuration = numAttr(node, "data-duration");
|
|
510
|
+
node.setAttribute("data-transition-out", preset);
|
|
511
|
+
node.setAttribute("data-transition-out-duration", String(round3(duration)));
|
|
512
|
+
upsertStyleDecl(node, "--vf-tr-out-dur", `${round3(duration)}s`);
|
|
513
|
+
upsertStyleDecl(node, "--vf-tr-out-delay", `${transitionOutDelaySeconds(Number.isFinite(layerDuration) ? layerDuration : 0, duration)}s`);
|
|
514
|
+
}
|
|
515
|
+
// Bulk pass over the whole timeline — the devcli twin of the AI's
|
|
516
|
+
// set_transitions action and the editor's junction chips.
|
|
517
|
+
export function applyTransitionsAcross(html, opts) {
|
|
518
|
+
if (opts.junction === undefined && opts.intro === undefined && opts.outro === undefined) {
|
|
519
|
+
throw new Error("applyTransitionsAcross needs a junction preset, an intro, and/or an outro.");
|
|
520
|
+
}
|
|
521
|
+
const { document, root } = readCompositionDoc(html);
|
|
522
|
+
const scenes = collectClips(root).filter(isSceneClipNode).map((node) => ({
|
|
523
|
+
node,
|
|
524
|
+
start: numAttr(node, "data-start") || 0,
|
|
525
|
+
duration: numAttr(node, "data-duration") || 0,
|
|
526
|
+
track: numAttr(node, "data-track-index") || 0
|
|
527
|
+
})).sort((a, b) => (a.start - b.start) || (a.track - b.track));
|
|
528
|
+
if (!scenes.length)
|
|
529
|
+
throw new Error("No scene clips (video/image layers) found in the composition.");
|
|
530
|
+
const firstByTrack = new Map();
|
|
531
|
+
for (const scene of scenes) {
|
|
532
|
+
const first = firstByTrack.get(scene.track);
|
|
533
|
+
if (!first || scene.start < first.start)
|
|
534
|
+
firstByTrack.set(scene.track, scene);
|
|
535
|
+
}
|
|
536
|
+
let junctions = 0;
|
|
537
|
+
if (opts.junction !== undefined) {
|
|
538
|
+
for (const scene of scenes) {
|
|
539
|
+
if (firstByTrack.get(scene.track) === scene)
|
|
540
|
+
continue;
|
|
541
|
+
setNodeTransition(scene.node, opts.junction, opts.duration);
|
|
542
|
+
junctions += 1;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const firstScene = scenes[0];
|
|
546
|
+
const lastScene = scenes.reduce((latest, scene) => (scene.start + scene.duration > latest.start + latest.duration ? scene : latest), scenes[0]);
|
|
547
|
+
if (opts.intro !== undefined && firstScene) {
|
|
548
|
+
setNodeTransition(firstScene.node, opts.intro, opts.duration);
|
|
549
|
+
}
|
|
550
|
+
if (opts.outro !== undefined && lastScene) {
|
|
551
|
+
setNodeTransitionOut(lastScene.node, opts.outro, opts.outroDuration ?? opts.duration);
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
html: serialize(document),
|
|
555
|
+
junctions,
|
|
556
|
+
intro: opts.intro !== undefined && opts.intro !== "none" && opts.intro !== null,
|
|
557
|
+
outro: opts.outro !== undefined && opts.outro !== "none" && opts.outro !== null
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
// Per-layer control — the devcli twin of set_layer_media's transition fields.
|
|
561
|
+
export function setLayerTransitions(html, layerKey, opts) {
|
|
562
|
+
if (opts.transition === undefined && opts.transitionOut === undefined) {
|
|
563
|
+
throw new Error("setLayerTransitions needs --in and/or --out.");
|
|
564
|
+
}
|
|
565
|
+
const { document, root } = readCompositionDoc(html);
|
|
566
|
+
const node = resolveLayerNode(root, layerKey);
|
|
567
|
+
if (!node)
|
|
568
|
+
throw new Error(`Layer not found: ${layerKey}`);
|
|
569
|
+
if (!isSceneClipNode(node) && String(node.tagName || "").toLowerCase() === "audio") {
|
|
570
|
+
throw new Error("Transitions cannot be applied to audio layers.");
|
|
571
|
+
}
|
|
572
|
+
if (opts.transition !== undefined)
|
|
573
|
+
setNodeTransition(node, opts.transition, opts.transitionDuration);
|
|
574
|
+
if (opts.transitionOut !== undefined)
|
|
575
|
+
setNodeTransitionOut(node, opts.transitionOut, opts.transitionOutDuration);
|
|
576
|
+
return { html: serialize(document), layerKey: node.getAttribute("data-hf-id") || node.id || layerKey };
|
|
577
|
+
}
|
|
578
|
+
export function clearAllTransitions(html) {
|
|
579
|
+
const { document, root } = readCompositionDoc(html);
|
|
580
|
+
let cleared = 0;
|
|
581
|
+
for (const node of collectClips(root)) {
|
|
582
|
+
const had = node.hasAttribute("data-transition") || node.hasAttribute("data-transition-out");
|
|
583
|
+
if (!had)
|
|
584
|
+
continue;
|
|
585
|
+
setNodeTransition(node, null);
|
|
586
|
+
setNodeTransitionOut(node, null);
|
|
587
|
+
cleared += 1;
|
|
588
|
+
}
|
|
589
|
+
return { html: serialize(document), cleared };
|
|
590
|
+
}
|
|
591
|
+
export function readTransitions(html) {
|
|
592
|
+
const { root } = readCompositionDoc(html);
|
|
593
|
+
return collectClips(root).filter(isSceneClipNode).map((node) => {
|
|
594
|
+
const duration = Number.parseFloat(node.getAttribute("data-transition-duration") ?? "");
|
|
595
|
+
const outDuration = Number.parseFloat(node.getAttribute("data-transition-out-duration") ?? "");
|
|
596
|
+
return {
|
|
597
|
+
layerKey: node.getAttribute("data-hf-id") || node.id || "",
|
|
598
|
+
label: node.getAttribute("data-label"),
|
|
599
|
+
track: numAttr(node, "data-track-index") || 0,
|
|
600
|
+
start: numAttr(node, "data-start") || 0,
|
|
601
|
+
duration: numAttr(node, "data-duration") || 0,
|
|
602
|
+
transition: node.getAttribute("data-transition"),
|
|
603
|
+
transition_duration: Number.isFinite(duration) ? duration : null,
|
|
604
|
+
transition_out: node.getAttribute("data-transition-out"),
|
|
605
|
+
transition_out_duration: Number.isFinite(outDuration) ? outDuration : null
|
|
606
|
+
};
|
|
607
|
+
}).sort((a, b) => (a.start - b.start) || (a.track - b.track));
|
|
608
|
+
}
|
|
441
609
|
//# sourceMappingURL=composition-edit.js.map
|