@mevdragon/vidfarm-devcli 0.12.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 +29 -7
- package/SKILL.platform.md +4 -4
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +254 -80
- package/dist/src/account-pages-legacy.js +9 -14
- package/dist/src/app.js +1081 -133
- package/dist/src/cli.js +107 -9
- package/dist/src/composition-runtime.js +88 -9
- package/dist/src/devcli/clips.js +5 -0
- package/dist/src/devcli/composition-edit.js +176 -8
- package/dist/src/devcli/transitions.js +205 -0
- package/dist/src/editor-chat.js +6 -5
- package/dist/src/frontend/homepage-view.js +1 -1
- package/dist/src/frontend/template-editor-chat.js +9 -6
- package/dist/src/homepage.js +9 -48
- package/dist/src/hyperframes/composition.js +183 -2
- package/dist/src/primitive-registry.js +237 -47
- package/dist/src/services/hyperframes.js +60 -15
- package/dist/src/services/providers.js +1 -0
- package/dist/src/services/serverless-records.js +19 -2
- package/dist/src/services/storage.js +12 -0
- package/dist/src/template-editor-shell.js +87 -48
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +23 -23
- 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";
|
|
@@ -81,6 +82,9 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
|
|
|
81
82
|
local MP4/MOV/WebM file — as a private (files: presign + PUT via
|
|
82
83
|
template. --title (uploads; defaults to the /discover/templates/upload)
|
|
83
84
|
template id), --tagline, --notes optional.
|
|
85
|
+
--raw (uploads) marks it as your own raw
|
|
86
|
+
project footage — the editor won't auto-
|
|
87
|
+
prompt Decompose on it.
|
|
84
88
|
inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
|
|
85
89
|
inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
|
|
86
90
|
|
|
@@ -145,10 +149,15 @@ Generate AI media and drop it on the timeline (for local coding agents):
|
|
|
145
149
|
--ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
|
|
146
150
|
--transition <preset> Scene transition INTO this clip (the previous
|
|
147
151
|
butt-cut clip is held beneath it automatically):
|
|
148
|
-
crossfade|fade-black|
|
|
149
|
-
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|
|
|
150
155
|
wipe-down|zoom-in|zoom-out|circle-open
|
|
151
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)
|
|
152
161
|
remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
|
|
153
162
|
original vs decomposed (caption-free),
|
|
154
163
|
non-billing (alias: ghostcut)
|
|
@@ -204,6 +213,16 @@ Animated captions (word-by-word TikTok/CapCut styles on a pulled/served composit
|
|
|
204
213
|
captions style <dir> Restyle existing cues (same text/timings)
|
|
205
214
|
captions list <dir> Show the current animated caption cues [--json]
|
|
206
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)
|
|
207
226
|
versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
|
|
208
227
|
snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
|
|
209
228
|
render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
|
|
@@ -248,15 +267,22 @@ Files (multi-step flows the devcli handles for you):
|
|
|
248
267
|
--folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
|
|
249
268
|
--as <name> Name the saved file (required for --content/--stdin)
|
|
250
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
|
|
251
271
|
e.g. vidfarm put-file About.md --folder acme-skincare
|
|
252
272
|
echo "..." | vidfarm put-file --stdin --as About.md --folder acme
|
|
253
273
|
download <url> [dest] Stream any Vidfarm/media URL to disk
|
|
254
274
|
files List My Files assets + folders → GET /api/v1/user/me/attachments
|
|
255
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
|
|
256
278
|
get-file <id> [dest] Resolve a My Files id to its URL and download it
|
|
257
279
|
--print Print text contents (md/txt/csv/json) instead of saving
|
|
258
|
-
|
|
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;
|
|
259
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.
|
|
260
286
|
Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
|
|
261
287
|
them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
|
|
262
288
|
to that box's local disk store, free local render). When you DO need a durable
|
|
@@ -455,12 +481,18 @@ async function main() {
|
|
|
455
481
|
case "save-file":
|
|
456
482
|
await runPutFileCommand(rest);
|
|
457
483
|
return;
|
|
484
|
+
case "annotate-file":
|
|
485
|
+
await runAnnotateFileCommand(rest);
|
|
486
|
+
return;
|
|
458
487
|
case "clips":
|
|
459
488
|
await runClipsCommand(rest);
|
|
460
489
|
return;
|
|
461
490
|
case "captions":
|
|
462
491
|
await runCaptionsCommand(rest);
|
|
463
492
|
return;
|
|
493
|
+
case "transitions":
|
|
494
|
+
await runTransitionsCommand(rest);
|
|
495
|
+
return;
|
|
464
496
|
default:
|
|
465
497
|
if (!command.startsWith("-")) {
|
|
466
498
|
// Positional template id → default to booting the local editor.
|
|
@@ -621,6 +653,11 @@ async function runServeCommand(argv) {
|
|
|
621
653
|
// local-render by definition — cloud rendering is the EXPLICIT
|
|
622
654
|
// render_target="cloud" upstream handoff (the editor's "Render in Cloud").
|
|
623
655
|
process.env.VIDFARM_JOB_STATE_MACHINE_ARN = "";
|
|
656
|
+
// Same trap for clip hunts: a dev-shell .env carrying the staging clip-scan
|
|
657
|
+
// ARN would route this box's /clips/scan to the cloud Step Functions. serve
|
|
658
|
+
// hunts are LOCAL-FIRST (local agent CLI / BYOK keys); the upstream cloud is
|
|
659
|
+
// the explicit backup inside the scan route itself.
|
|
660
|
+
process.env.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN = "";
|
|
624
661
|
// Same trap as the state machine ARN: a dev-shell .env can carry the staging
|
|
625
662
|
// media-lambda URL, which would route probe/thumbnail/trim/etc. to a cloud
|
|
626
663
|
// Lambda that cannot fetch this box's localhost storage URLs ("fetch
|
|
@@ -1265,7 +1302,7 @@ async function runVideosCommand(argv) {
|
|
|
1265
1302
|
emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
|
|
1266
1303
|
}
|
|
1267
1304
|
async function runInspirationAddCommand(argv) {
|
|
1268
|
-
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" } } });
|
|
1305
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" }, raw: { type: "boolean", default: false } } });
|
|
1269
1306
|
const source = parsed.positionals[0];
|
|
1270
1307
|
if (!source)
|
|
1271
1308
|
throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X) or a local video file path.");
|
|
@@ -1285,7 +1322,10 @@ async function runInspirationAddCommand(argv) {
|
|
|
1285
1322
|
fileName: path.basename(localPath),
|
|
1286
1323
|
title: parsed.values.title,
|
|
1287
1324
|
tagline: parsed.values.tagline,
|
|
1288
|
-
notes: parsed.values.notes
|
|
1325
|
+
notes: parsed.values.notes,
|
|
1326
|
+
// --raw: this is the user's own footage starting a new project, not an
|
|
1327
|
+
// inspiration to study — the editor skips the auto Decompose prompt.
|
|
1328
|
+
origin: parsed.values.raw ? "raw" : undefined
|
|
1289
1329
|
});
|
|
1290
1330
|
emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
|
|
1291
1331
|
return;
|
|
@@ -1380,7 +1420,7 @@ async function ingestUploadedVideo(ctx, input) {
|
|
|
1380
1420
|
host: ctx.host,
|
|
1381
1421
|
path: "/discover/templates",
|
|
1382
1422
|
auth: ctx.auth,
|
|
1383
|
-
body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: input.title, tagline: input.tagline, notes: input.notes }
|
|
1423
|
+
body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: input.title, tagline: input.tagline, notes: input.notes, origin: input.origin }
|
|
1384
1424
|
});
|
|
1385
1425
|
assertApiOk(result, "ingest");
|
|
1386
1426
|
return result;
|
|
@@ -1555,7 +1595,10 @@ async function runCreateCommand(argv) {
|
|
|
1555
1595
|
throw new Error(`create: could not fetch the generated video (${dl.status}).`);
|
|
1556
1596
|
const buffer = Buffer.from(await dl.arrayBuffer());
|
|
1557
1597
|
const title = parsed.values.title ?? prompt.slice(0, 80);
|
|
1558
|
-
|
|
1598
|
+
// origin:"raw" — a prompt-generated video is the user's own new project, not
|
|
1599
|
+
// an imported inspiration; the editor must not auto-prompt Decompose (matters
|
|
1600
|
+
// especially with --no-decompose).
|
|
1601
|
+
const add = await ingestUploadedVideo(ctx, { buffer, fileName: "generated.mp4", contentType: "video/mp4", title, origin: "raw" });
|
|
1559
1602
|
const inspirationId = add.json?.inspiration_id;
|
|
1560
1603
|
if (!inspirationId)
|
|
1561
1604
|
throw new Error("create: ingest did not return an inspiration id.");
|
|
@@ -2012,6 +2055,8 @@ function placeMediaOnDisk(input) {
|
|
|
2012
2055
|
kenBurnsIntensity: input.kenBurnsIntensity,
|
|
2013
2056
|
transition: input.transition,
|
|
2014
2057
|
transitionDuration: input.transitionDuration,
|
|
2058
|
+
transitionOut: input.transitionOut,
|
|
2059
|
+
transitionOutDuration: input.transitionOutDuration,
|
|
2015
2060
|
slug: input.slug
|
|
2016
2061
|
};
|
|
2017
2062
|
const result = input.replace
|
|
@@ -2045,6 +2090,8 @@ async function runGenerateCommand(argv) {
|
|
|
2045
2090
|
"ken-burns-intensity": { type: "string" },
|
|
2046
2091
|
transition: { type: "string" },
|
|
2047
2092
|
"transition-duration": { type: "string" },
|
|
2093
|
+
"transition-out": { type: "string" },
|
|
2094
|
+
"transition-out-duration": { type: "string" },
|
|
2048
2095
|
"layer-key": { type: "string" }
|
|
2049
2096
|
}
|
|
2050
2097
|
});
|
|
@@ -2120,6 +2167,8 @@ async function runGenerateCommand(argv) {
|
|
|
2120
2167
|
kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
|
|
2121
2168
|
transition: parsed.values.transition,
|
|
2122
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,
|
|
2123
2172
|
layerKey: parsed.values["layer-key"]
|
|
2124
2173
|
});
|
|
2125
2174
|
if (!ctx.json)
|
|
@@ -2506,6 +2555,8 @@ async function runPlaceCommand(argv) {
|
|
|
2506
2555
|
"ken-burns-intensity": { type: "string" },
|
|
2507
2556
|
transition: { type: "string" },
|
|
2508
2557
|
"transition-duration": { type: "string" },
|
|
2558
|
+
"transition-out": { type: "string" },
|
|
2559
|
+
"transition-out-duration": { type: "string" },
|
|
2509
2560
|
"layer-key": { type: "string" },
|
|
2510
2561
|
slug: { type: "string" },
|
|
2511
2562
|
// Local file support: where a serve box serves /storage from, and which
|
|
@@ -2550,6 +2601,8 @@ async function runPlaceCommand(argv) {
|
|
|
2550
2601
|
kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
|
|
2551
2602
|
transition: parsed.values.transition,
|
|
2552
2603
|
transitionDuration: num(parsed.values["transition-duration"]),
|
|
2604
|
+
transitionOut: parsed.values["transition-out"],
|
|
2605
|
+
transitionOutDuration: num(parsed.values["transition-out-duration"]),
|
|
2553
2606
|
layerKey: parsed.values["layer-key"],
|
|
2554
2607
|
slug: parsed.values.slug
|
|
2555
2608
|
});
|
|
@@ -2875,11 +2928,25 @@ async function runDownloadCommand(argv) {
|
|
|
2875
2928
|
// This is the same filesystem the /editor AI agent browses via the browse_files
|
|
2876
2929
|
// tool, so an agent CLI (Claude Code / Codex) can find assets the same way.
|
|
2877
2930
|
async function runFilesCommand(argv) {
|
|
2878
|
-
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" } } });
|
|
2879
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
|
+
}
|
|
2880
2948
|
const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
|
|
2881
2949
|
assertApiOk(result, "files");
|
|
2882
|
-
const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
|
|
2883
2950
|
if (folderFilter && result.json && Array.isArray(result.json.attachments)) {
|
|
2884
2951
|
result.json = {
|
|
2885
2952
|
...result.json,
|
|
@@ -2888,6 +2955,34 @@ async function runFilesCommand(argv) {
|
|
|
2888
2955
|
}
|
|
2889
2956
|
emitResult(result, ctx.json);
|
|
2890
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
|
+
}
|
|
2891
2986
|
async function runGetFileCommand(argv) {
|
|
2892
2987
|
const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), print: { type: "boolean", default: false } } });
|
|
2893
2988
|
const fileId = parsed.positionals[0];
|
|
@@ -2941,6 +3036,7 @@ async function runPutFileCommand(argv) {
|
|
|
2941
3036
|
folder: { type: "string" },
|
|
2942
3037
|
as: { type: "string" },
|
|
2943
3038
|
content: { type: "string" },
|
|
3039
|
+
notes: { type: "string" },
|
|
2944
3040
|
stdin: { type: "boolean", default: false }
|
|
2945
3041
|
}
|
|
2946
3042
|
});
|
|
@@ -2977,6 +3073,8 @@ async function runPutFileCommand(argv) {
|
|
|
2977
3073
|
form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
|
|
2978
3074
|
if (parsed.values.folder)
|
|
2979
3075
|
form.append("folder_path", String(parsed.values.folder));
|
|
3076
|
+
if (parsed.values.notes)
|
|
3077
|
+
form.append("notes", String(parsed.values.notes));
|
|
2980
3078
|
const res = await fetch(new URL("/api/v1/user/me/attachments/upload", ctx.host), {
|
|
2981
3079
|
method: "POST",
|
|
2982
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; }
|
|
@@ -229,16 +250,65 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
229
250
|
// Burns there is nothing to normalize — the scrub is simply the absolute
|
|
230
251
|
// offset into the layer. Paused-by-stylesheet + scrubbed-here matches the
|
|
231
252
|
// render producer's CSS adapter exactly.
|
|
253
|
+
//
|
|
254
|
+
// Stability: Chrome PERMANENTLY drops a paused fill:none CSS animation the
|
|
255
|
+
// moment its currentTime is pushed past the effect end, and never re-creates
|
|
256
|
+
// it while the computed animation-name is unchanged — so one forward scrub
|
|
257
|
+
// across a word's window would leave that word stuck in its resting state
|
|
258
|
+
// (invisible for word-pop/stack-up) on every later frame. Past the end, a
|
|
259
|
+
// parked fill:none animation looks identical to the resting styles, so park
|
|
260
|
+
// at 0 instead of overshooting. fill:forwards presets (karaoke-fill,
|
|
261
|
+
// cumulative-reveal) must overshoot to hold their final keyframe — finished
|
|
262
|
+
// + forwards animations keep applying their effect and survive. If a word's
|
|
263
|
+
// animation is missing anyway (dropped before this guard, or an innerHTML
|
|
264
|
+
// morph replaced the spans mid-scrub), restart it declaratively by cycling
|
|
265
|
+
// animation-name through none.
|
|
266
|
+
function captionWordAnimationFor(word) {
|
|
267
|
+
let anims = [];
|
|
268
|
+
try { anims = word.getAnimations(); } catch { return null; }
|
|
269
|
+
for (const anim of anims) {
|
|
270
|
+
if (typeof anim.animationName === "string" && anim.animationName.indexOf("vf-cap-") === 0) return anim;
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
232
275
|
function syncCaptionClip(clip, timing) {
|
|
233
276
|
if (typeof clip.getAnimations !== "function") return;
|
|
234
277
|
const offsetMs = Math.max(0, (time - timing.start)) * 1000;
|
|
235
|
-
let
|
|
236
|
-
try {
|
|
237
|
-
for (const
|
|
238
|
-
if (typeof
|
|
278
|
+
let words = [];
|
|
279
|
+
try { words = Array.from(clip.querySelectorAll('[data-cap-word="true"]')); } catch { return; }
|
|
280
|
+
for (const word of words) {
|
|
281
|
+
if (typeof word.getAnimations !== "function") continue;
|
|
282
|
+
let anim = captionWordAnimationFor(word);
|
|
283
|
+
if (!anim) {
|
|
284
|
+
const inlineEndMs =
|
|
285
|
+
((parseFloat(word.style.animationDelay) || 0) + (parseFloat(word.style.animationDuration) || 0)) * 1000;
|
|
286
|
+
let needed = offsetMs <= inlineEndMs;
|
|
287
|
+
if (!needed) {
|
|
288
|
+
try { needed = getComputedStyle(word).animationFillMode.indexOf("forward") !== -1 || getComputedStyle(word).animationFillMode.indexOf("both") !== -1; } catch {}
|
|
289
|
+
}
|
|
290
|
+
if (!needed) continue;
|
|
291
|
+
try {
|
|
292
|
+
word.style.animationName = "none";
|
|
293
|
+
void word.offsetWidth;
|
|
294
|
+
word.style.removeProperty("animation-name");
|
|
295
|
+
} catch { continue; }
|
|
296
|
+
anim = captionWordAnimationFor(word);
|
|
297
|
+
if (!anim) continue;
|
|
298
|
+
}
|
|
299
|
+
let target = offsetMs;
|
|
300
|
+
try {
|
|
301
|
+
const computed = anim.effect && typeof anim.effect.getComputedTiming === "function"
|
|
302
|
+
? anim.effect.getComputedTiming()
|
|
303
|
+
: null;
|
|
304
|
+
if (computed && typeof computed.endTime === "number" && offsetMs >= computed.endTime
|
|
305
|
+
&& computed.fill !== "forwards" && computed.fill !== "both") {
|
|
306
|
+
target = 0;
|
|
307
|
+
}
|
|
308
|
+
} catch {}
|
|
239
309
|
try {
|
|
240
310
|
anim.pause();
|
|
241
|
-
anim.currentTime =
|
|
311
|
+
anim.currentTime = target;
|
|
242
312
|
} catch {}
|
|
243
313
|
}
|
|
244
314
|
}
|
|
@@ -261,7 +331,7 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
261
331
|
}
|
|
262
332
|
if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
|
|
263
333
|
if (clip.hasAttribute("data-caption-animation")) syncCaptionClip(clip, timing);
|
|
264
|
-
if (clip.hasAttribute("data-transition")) syncTransitionClip(clip, timing);
|
|
334
|
+
if (clip.hasAttribute("data-transition") || clip.hasAttribute("data-transition-out")) syncTransitionClip(clip, timing, hold);
|
|
265
335
|
}
|
|
266
336
|
if (clip instanceof HTMLMediaElement) {
|
|
267
337
|
applyMediaState(clip);
|
|
@@ -369,7 +439,16 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
|
|
|
369
439
|
return playbackRate;
|
|
370
440
|
},
|
|
371
441
|
setMuted,
|
|
372
|
-
refreshClips
|
|
442
|
+
refreshClips,
|
|
443
|
+
// Re-scrub caption word animations at the current playhead without a full
|
|
444
|
+
// apply(). The editor calls this after live-patching a caption layer's
|
|
445
|
+
// DOM (style/text edits rebuild the word spans, whose fresh CSS-paused
|
|
446
|
+
// animations sit at currentTime 0 — resting/invisible — until scrubbed).
|
|
447
|
+
syncCaptions() {
|
|
448
|
+
for (const clip of Array.from(document.querySelectorAll("[data-caption-animation]"))) {
|
|
449
|
+
if (clip instanceof HTMLElement) syncCaptionClip(clip, readTiming(clip));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
373
452
|
};
|
|
374
453
|
|
|
375
454
|
refreshClips();
|
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,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
|