reframe-video 0.6.5 → 0.6.7

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/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
3
  // ../render-cli/src/cli.ts
4
- import { mkdtemp as mkdtemp3, readFile as readFile4, rm as rm3 } from "node:fs/promises";
5
- import { tmpdir as tmpdir4 } from "node:os";
6
- import { basename, dirname as dirname7, join as join6, resolve as resolve4 } from "node:path";
4
+ import { mkdtemp as mkdtemp5, readFile as readFile5, rm as rm5 } from "node:fs/promises";
5
+ import { tmpdir as tmpdir6 } from "node:os";
6
+ import { basename, dirname as dirname7, join as join9, resolve as resolve6 } from "node:path";
7
7
 
8
8
  // ../core/src/ir.ts
9
9
  var DEFAULT_CROSSFADE = 0.5;
@@ -347,6 +347,7 @@ var PROPS_BY_TYPE = {
347
347
  line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
348
348
  text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
349
349
  image: [...COMMON_PROPS, "src", "width", "height", "fit"],
350
+ video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
350
351
  path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
351
352
  group: COMMON_PROPS
352
353
  };
@@ -884,11 +885,34 @@ var SFX_DURATION = {
884
885
  thud: 0.25
885
886
  };
886
887
  var FILE_CUE_DURATION = 0.4;
888
+ function collectClipAudio(ir, duration, warnings) {
889
+ const out = [];
890
+ const walk = (nodes) => {
891
+ for (const node of nodes) {
892
+ if (node.type === "video") {
893
+ const gain = node.props.volume ?? 1;
894
+ const start = node.props.start ?? 0;
895
+ if (gain <= 0) continue;
896
+ if (start >= duration) {
897
+ warnings.push(`video "${node.id}": start ${start.toFixed(2)}s past the scene end \u2014 audio dropped`);
898
+ continue;
899
+ }
900
+ out.push({ nodeId: node.id, src: node.props.src, start, rate: node.props.rate ?? 1, clipStart: node.props.clipStart ?? 0, gain });
901
+ }
902
+ if (node.type === "group") walk(node.children);
903
+ }
904
+ };
905
+ walk(ir.nodes);
906
+ return out;
907
+ }
887
908
  function resolveAudioPlan(compiled) {
888
909
  const audio = compiled.ir.audio;
889
- if (!audio || !audio.bgm && (audio.cues ?? []).length === 0) return null;
890
910
  const warnings = [];
891
911
  const duration = compiled.duration;
912
+ const clipAudio = collectClipAudio(compiled.ir, duration, warnings);
913
+ if (!audio || !audio.bgm && (audio.cues ?? []).length === 0) {
914
+ return clipAudio.length === 0 ? null : { duration, bgm: null, cues: [], duckWindows: [], clipAudio, warnings };
915
+ }
892
916
  const cues = [];
893
917
  for (const [index, cue] of (audio.cues ?? []).entries()) {
894
918
  let anchor;
@@ -924,6 +948,7 @@ function resolveAudioPlan(compiled) {
924
948
  bgm: resolveBgm(audio.bgm),
925
949
  cues,
926
950
  duckWindows: mergeDuckWindows(cues, duration),
951
+ clipAudio,
927
952
  warnings
928
953
  };
929
954
  }
@@ -957,6 +982,7 @@ function resolveCompositionAudioPlan(comp) {
957
982
  const duration = comp.duration;
958
983
  const warnings = [];
959
984
  const cues = [];
985
+ const clipAudio = [];
960
986
  for (const placement of comp.scenes) {
961
987
  const plan = resolveAudioPlan(placement.compiled);
962
988
  if (!plan) continue;
@@ -969,6 +995,11 @@ function resolveCompositionAudioPlan(comp) {
969
995
  if (t >= duration) continue;
970
996
  cues.push({ ...cue, t });
971
997
  }
998
+ for (const clip of plan.clipAudio) {
999
+ const start = clip.start + placement.start;
1000
+ if (start >= duration) continue;
1001
+ clipAudio.push({ ...clip, start });
1002
+ }
972
1003
  }
973
1004
  for (const [index, cue] of (audio?.cues ?? []).entries()) {
974
1005
  if (typeof cue.at !== "number") {
@@ -988,13 +1019,14 @@ function resolveCompositionAudioPlan(comp) {
988
1019
  source: cue.sfx ? { kind: "sfx", name: cue.sfx, params: cue.params ?? {} } : { kind: "file", path: cue.file }
989
1020
  });
990
1021
  }
991
- if (!audio?.bgm && cues.length === 0) return null;
1022
+ if (!audio?.bgm && cues.length === 0 && clipAudio.length === 0) return null;
992
1023
  cues.sort((a, b) => a.t - b.t);
993
1024
  return {
994
1025
  duration,
995
1026
  bgm: resolveBgm(audio?.bgm),
996
1027
  cues,
997
1028
  duckWindows: mergeDuckWindows(cues, duration),
1029
+ clipAudio,
998
1030
  warnings
999
1031
  };
1000
1032
  }
@@ -1044,13 +1076,13 @@ var EASE_TABLE = {
1044
1076
  var EASE_NAMES = Object.keys(EASE_TABLE);
1045
1077
 
1046
1078
  // ../core/src/assets.ts
1047
- function collectImageSrcs(ir) {
1079
+ function collectSrcs(ir, type) {
1048
1080
  const srcs = /* @__PURE__ */ new Set();
1049
- const imageIds = /* @__PURE__ */ new Set();
1081
+ const ids = /* @__PURE__ */ new Set();
1050
1082
  const walkNodes = (nodes) => {
1051
1083
  for (const node of nodes) {
1052
- if (node.type === "image") {
1053
- imageIds.add(node.id);
1084
+ if (node.type === type) {
1085
+ ids.add(node.id);
1054
1086
  srcs.add(node.props.src);
1055
1087
  }
1056
1088
  if (node.type === "group") walkNodes(node.children);
@@ -1059,14 +1091,14 @@ function collectImageSrcs(ir) {
1059
1091
  walkNodes(ir.nodes);
1060
1092
  for (const overrides of Object.values(ir.states ?? {})) {
1061
1093
  for (const [nodeId, props] of Object.entries(overrides)) {
1062
- if (imageIds.has(nodeId) && typeof props.src === "string") srcs.add(props.src);
1094
+ if (ids.has(nodeId) && typeof props.src === "string") srcs.add(props.src);
1063
1095
  }
1064
1096
  }
1065
1097
  const walkTimeline = (step) => {
1066
1098
  if (!step) return;
1067
1099
  if (step.kind === "seq" || step.kind === "par" || step.kind === "stagger") {
1068
1100
  for (const child of step.children) walkTimeline(child);
1069
- } else if (step.kind === "tween" && imageIds.has(step.target)) {
1101
+ } else if (step.kind === "tween" && ids.has(step.target)) {
1070
1102
  const src = step.props.src;
1071
1103
  if (typeof src === "string") srcs.add(src);
1072
1104
  }
@@ -1074,9 +1106,17 @@ function collectImageSrcs(ir) {
1074
1106
  walkTimeline(ir.timeline);
1075
1107
  return [...srcs];
1076
1108
  }
1109
+ function collectImageSrcs(ir) {
1110
+ return collectSrcs(ir, "image");
1111
+ }
1112
+ function collectVideoSrcs(ir) {
1113
+ return collectSrcs(ir, "video");
1114
+ }
1077
1115
 
1078
1116
  // ../render-cli/src/audio/index.ts
1079
- import { dirname as dirname2 } from "node:path";
1117
+ import { copyFile, mkdtemp as mkdtemp2, rm as rm2 } from "node:fs/promises";
1118
+ import { tmpdir as tmpdir3 } from "node:os";
1119
+ import { dirname as dirname2, join as join4 } from "node:path";
1080
1120
 
1081
1121
  // ../render-cli/src/audio/sfx.ts
1082
1122
  import { mkdir, rename, writeFile } from "node:fs/promises";
@@ -1298,12 +1338,85 @@ async function resolveBgmFile(source, duration, sceneDir) {
1298
1338
  return writeCached(`ambient-pad-${duration.toFixed(2)}`, () => synthAmbientPad(duration));
1299
1339
  }
1300
1340
 
1301
- // ../render-cli/src/audio/mux.ts
1341
+ // ../render-cli/src/audio/clip.ts
1302
1342
  import { spawn } from "node:child_process";
1343
+ import { existsSync as existsSync2 } from "node:fs";
1344
+ import { isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "node:path";
1345
+ function run(cmd, args) {
1346
+ return new Promise((res, reject) => {
1347
+ const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
1348
+ let stdout = "", stderr = "";
1349
+ proc.stdout.on("data", (d) => stdout += d.toString());
1350
+ proc.stderr.on("data", (d) => stderr += d.toString());
1351
+ proc.on("close", (code) => res({ code: code ?? 1, stdout, stderr }));
1352
+ proc.on("error", reject);
1353
+ });
1354
+ }
1355
+ async function hasAudioStream(file) {
1356
+ const { stdout } = await run("ffprobe", [
1357
+ "-v",
1358
+ "error",
1359
+ "-select_streams",
1360
+ "a",
1361
+ "-show_entries",
1362
+ "stream=index",
1363
+ "-of",
1364
+ "csv=p=0",
1365
+ file
1366
+ ]);
1367
+ return stdout.trim().length > 0;
1368
+ }
1369
+ function resolveSrc(src, sceneDir) {
1370
+ const candidates = [isAbsolute2(src) ? src : null, resolve2(sceneDir, src)].filter(
1371
+ (c) => c !== null
1372
+ );
1373
+ const found = candidates.find((c) => existsSync2(c));
1374
+ if (!found) throw new Error(`video "${src}" not found (tried: ${candidates.join(", ")})`);
1375
+ return found;
1376
+ }
1377
+ async function resolveClipAudio(entry, sceneDir, workDir) {
1378
+ const src = resolveSrc(entry.src, sceneDir);
1379
+ if (!await hasAudioStream(src)) return null;
1380
+ const out = join2(workDir, `clip-${entry.nodeId}.wav`);
1381
+ const { code, stderr } = await run("ffmpeg", [
1382
+ "-y",
1383
+ "-i",
1384
+ src,
1385
+ "-vn",
1386
+ "-ac",
1387
+ "2",
1388
+ "-ar",
1389
+ "44100",
1390
+ "-c:a",
1391
+ "pcm_s16le",
1392
+ out
1393
+ ]);
1394
+ if (code !== 0) throw new Error(`clip audio extract failed for "${entry.src}":
1395
+ ${stderr.slice(-1500)}`);
1396
+ return out;
1397
+ }
1398
+
1399
+ // ../render-cli/src/audio/mux.ts
1400
+ import { spawn as spawn2 } from "node:child_process";
1303
1401
  import { mkdtemp, rm, writeFile as writeFile2 } from "node:fs/promises";
1304
1402
  import { tmpdir as tmpdir2 } from "node:os";
1305
- import { join as join2 } from "node:path";
1403
+ import { join as join3 } from "node:path";
1306
1404
  var FORMAT = "aformat=sample_rates=44100:channel_layouts=stereo";
1405
+ function atempoChain(rate) {
1406
+ if (!(rate > 0) || rate === 1) return [];
1407
+ const out = [];
1408
+ let r = rate;
1409
+ while (r > 2) {
1410
+ out.push("atempo=2.0");
1411
+ r /= 2;
1412
+ }
1413
+ while (r < 0.5) {
1414
+ out.push("atempo=0.5");
1415
+ r /= 0.5;
1416
+ }
1417
+ out.push(`atempo=${r.toFixed(4)}`);
1418
+ return out;
1419
+ }
1307
1420
  function buildFilterGraph(plan, inputs) {
1308
1421
  const lines = [];
1309
1422
  const mixIn = ["[anchor]"];
@@ -1336,15 +1449,25 @@ function buildFilterGraph(plan, inputs) {
1336
1449
  mixIn.push(`[c${i}]`);
1337
1450
  inputIndex++;
1338
1451
  });
1452
+ (inputs.clipFiles ?? []).forEach(({ audio }, i) => {
1453
+ const chain = [];
1454
+ if (audio.clipStart > 0) chain.push(`atrim=start=${audio.clipStart.toFixed(3)}`, "asetpts=PTS-STARTPTS");
1455
+ chain.push(...atempoChain(audio.rate), FORMAT, `volume=${audio.gain}`);
1456
+ const delayMs = Math.round(audio.start * 1e3);
1457
+ if (delayMs > 0) chain.push(`adelay=${delayMs}:all=1`);
1458
+ lines.push(`[${inputIndex}:a]${chain.join(",")}[k${i}]`);
1459
+ mixIn.push(`[k${i}]`);
1460
+ inputIndex++;
1461
+ });
1339
1462
  lines.push(
1340
1463
  `${mixIn.join("")}amix=inputs=${mixIn.length}:duration=first:normalize=0,alimiter=limit=0.891,aresample=async=1:first_pts=0[aout]`
1341
1464
  );
1342
1465
  return lines.join(";\n");
1343
1466
  }
1344
1467
  async function muxAudio(videoIn, plan, inputs, outFile) {
1345
- const work = await mkdtemp(join2(tmpdir2(), "reframe-mux-"));
1468
+ const work = await mkdtemp(join3(tmpdir2(), "reframe-mux-"));
1346
1469
  try {
1347
- const graphFile = join2(work, "graph.txt");
1470
+ const graphFile = join3(work, "graph.txt");
1348
1471
  await writeFile2(graphFile, buildFilterGraph(plan, inputs));
1349
1472
  const args = [
1350
1473
  "-y",
@@ -1352,6 +1475,7 @@ async function muxAudio(videoIn, plan, inputs, outFile) {
1352
1475
  videoIn,
1353
1476
  ...plan.bgm && inputs.bgmFile ? ["-i", inputs.bgmFile] : [],
1354
1477
  ...inputs.cueFiles.flatMap((f) => ["-i", f]),
1478
+ ...(inputs.clipFiles ?? []).flatMap((c) => ["-i", c.file]),
1355
1479
  "-filter_complex_script",
1356
1480
  graphFile,
1357
1481
  "-map",
@@ -1370,7 +1494,7 @@ async function muxAudio(videoIn, plan, inputs, outFile) {
1370
1494
  outFile
1371
1495
  ];
1372
1496
  await new Promise((resolvePromise, reject) => {
1373
- const proc = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
1497
+ const proc = spawn2("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
1374
1498
  let stderr = "";
1375
1499
  proc.stderr.on("data", (d) => stderr += d.toString());
1376
1500
  proc.on("close", (code) => {
@@ -1390,17 +1514,32 @@ async function buildAudioTrack(plan, scenePath, videoIn, outFile) {
1390
1514
  const sceneDir = dirname2(scenePath);
1391
1515
  const cueFiles = await Promise.all(plan.cues.map((cue) => resolveCueFile(cue, sceneDir)));
1392
1516
  const bgmFile = plan.bgm ? await resolveBgmFile(plan.bgm.source, plan.duration, sceneDir) : null;
1393
- await muxAudio(videoIn, plan, { cueFiles, bgmFile }, outFile);
1517
+ const work = await mkdtemp2(join4(tmpdir3(), "reframe-clipaudio-"));
1518
+ try {
1519
+ const clipFiles = [];
1520
+ for (const entry of plan.clipAudio) {
1521
+ const file = await resolveClipAudio(entry, sceneDir, work);
1522
+ if (file) clipFiles.push({ audio: entry, file });
1523
+ else console.error(`audio: video "${entry.nodeId}" has no audio track \u2014 skipped`);
1524
+ }
1525
+ if (!plan.bgm && plan.cues.length === 0 && clipFiles.length === 0) {
1526
+ await copyFile(videoIn, outFile);
1527
+ return;
1528
+ }
1529
+ await muxAudio(videoIn, plan, { cueFiles, bgmFile, clipFiles }, outFile);
1530
+ } finally {
1531
+ await rm2(work, { recursive: true, force: true });
1532
+ }
1394
1533
  }
1395
1534
 
1396
1535
  // ../render-cli/src/composition.ts
1397
- import { spawn as spawn3 } from "node:child_process";
1398
- import { copyFile, mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
1399
- import { tmpdir as tmpdir3 } from "node:os";
1400
- import { dirname as dirname5, join as join5 } from "node:path";
1536
+ import { spawn as spawn5 } from "node:child_process";
1537
+ import { copyFile as copyFile2, mkdtemp as mkdtemp4, rm as rm4, writeFile as writeFile4 } from "node:fs/promises";
1538
+ import { tmpdir as tmpdir5 } from "node:os";
1539
+ import { dirname as dirname5, join as join8 } from "node:path";
1401
1540
 
1402
1541
  // ../render-cli/src/encode.ts
1403
- import { spawn as spawn2 } from "node:child_process";
1542
+ import { spawn as spawn3 } from "node:child_process";
1404
1543
  async function encodeMp4(framesDir, fps, outFile) {
1405
1544
  const args = [
1406
1545
  "-y",
@@ -1420,12 +1559,12 @@ async function encodeMp4(framesDir, fps, outFile) {
1420
1559
  "+faststart",
1421
1560
  outFile
1422
1561
  ];
1423
- await new Promise((resolve5, reject) => {
1424
- const proc = spawn2("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
1562
+ await new Promise((resolve7, reject) => {
1563
+ const proc = spawn3("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
1425
1564
  let stderr = "";
1426
1565
  proc.stderr.on("data", (d) => stderr += d.toString());
1427
1566
  proc.on("close", (code) => {
1428
- if (code === 0) resolve5();
1567
+ if (code === 0) resolve7();
1429
1568
  else reject(new Error(`ffmpeg exited with ${code}:
1430
1569
  ${stderr.slice(-2e3)}`));
1431
1570
  });
@@ -1435,23 +1574,23 @@ ${stderr.slice(-2e3)}`));
1435
1574
 
1436
1575
  // ../render-cli/src/frameLoop.ts
1437
1576
  import { mkdir as mkdir2, writeFile as writeFile3 } from "node:fs/promises";
1438
- import { join as join4, dirname as dirname4 } from "node:path";
1577
+ import { join as join7, dirname as dirname4 } from "node:path";
1439
1578
  import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
1440
1579
  import { build } from "esbuild";
1441
1580
  import { chromium } from "playwright";
1442
1581
 
1443
1582
  // ../render-cli/src/fonts.ts
1444
1583
  import { readFile } from "node:fs/promises";
1445
- import { dirname as dirname3, join as join3 } from "node:path";
1584
+ import { dirname as dirname3, join as join5 } from "node:path";
1446
1585
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1447
- var FONTS_DIR = true ? join3(dirname3(fileURLToPath2(import.meta.url)), "..", "assets", "fonts") : join3(dirname3(fileURLToPath2(import.meta.url)), "..", "..", "..", "assets", "fonts");
1586
+ var FONTS_DIR = true ? join5(dirname3(fileURLToPath2(import.meta.url)), "..", "assets", "fonts") : join5(dirname3(fileURLToPath2(import.meta.url)), "..", "..", "..", "assets", "fonts");
1448
1587
  var WEIGHTS = [400, 700, 800];
1449
1588
  var cssCache = null;
1450
1589
  async function fontFaceCss() {
1451
1590
  if (cssCache) return cssCache;
1452
1591
  const rules = await Promise.all(
1453
1592
  WEIGHTS.map(async (weight) => {
1454
- const data = await readFile(join3(FONTS_DIR, `inter-${weight}.woff2`));
1593
+ const data = await readFile(join5(FONTS_DIR, `inter-${weight}.woff2`));
1455
1594
  return `@font-face {
1456
1595
  font-family: "Inter";
1457
1596
  font-style: normal;
@@ -1466,8 +1605,8 @@ async function fontFaceCss() {
1466
1605
 
1467
1606
  // ../render-cli/src/images.ts
1468
1607
  import { readFile as readFile2 } from "node:fs/promises";
1469
- import { existsSync as existsSync2 } from "node:fs";
1470
- import { extname, isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
1608
+ import { existsSync as existsSync3 } from "node:fs";
1609
+ import { extname, isAbsolute as isAbsolute3, resolve as resolve3 } from "node:path";
1471
1610
  var MIME = {
1472
1611
  ".png": "image/png",
1473
1612
  ".jpg": "image/jpeg",
@@ -1483,10 +1622,10 @@ async function buildImageAssets(ir, sceneDir) {
1483
1622
  `image "${src}": unsupported format "${extname(src)}" \u2014 supported: ${Object.keys(MIME).join(" ")}`
1484
1623
  );
1485
1624
  }
1486
- const candidates = [isAbsolute2(src) ? src : null, resolve2(sceneDir, src)].filter(
1625
+ const candidates = [isAbsolute3(src) ? src : null, resolve3(sceneDir, src)].filter(
1487
1626
  (c) => c !== null
1488
1627
  );
1489
- const found = candidates.find((c) => existsSync2(c));
1628
+ const found = candidates.find((c) => existsSync3(c));
1490
1629
  if (!found) {
1491
1630
  throw new Error(`image "${src}" not found (tried: ${candidates.join(", ")})`);
1492
1631
  }
@@ -1496,6 +1635,96 @@ async function buildImageAssets(ir, sceneDir) {
1496
1635
  return assets;
1497
1636
  }
1498
1637
 
1638
+ // ../render-cli/src/videos.ts
1639
+ import { spawn as spawn4 } from "node:child_process";
1640
+ import { mkdtemp as mkdtemp3, readFile as readFile3, readdir, rm as rm3 } from "node:fs/promises";
1641
+ import { existsSync as existsSync4 } from "node:fs";
1642
+ import { tmpdir as tmpdir4 } from "node:os";
1643
+ import { extname as extname2, isAbsolute as isAbsolute4, join as join6, resolve as resolve4 } from "node:path";
1644
+ var VIDEO_EXT = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".m4v", ".mkv"]);
1645
+ function runFfmpeg(args) {
1646
+ return new Promise((resolve7, reject) => {
1647
+ const proc = spawn4("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
1648
+ let stderr = "";
1649
+ proc.stderr.on("data", (d) => stderr += d.toString());
1650
+ proc.on(
1651
+ "close",
1652
+ (code) => code === 0 ? resolve7() : reject(new Error(`ffmpeg exited ${code}:
1653
+ ${stderr.slice(-2e3)}`))
1654
+ );
1655
+ proc.on("error", reject);
1656
+ });
1657
+ }
1658
+ function neededSeconds(node, duration) {
1659
+ const start = node.props.start ?? 0;
1660
+ const rate = node.props.rate ?? 1;
1661
+ const clipStart = node.props.clipStart ?? 0;
1662
+ return clipStart + Math.max(0, duration - start) * Math.max(0, rate) + 1 / 30;
1663
+ }
1664
+ function videoNodes(ir) {
1665
+ const out = [];
1666
+ const walk = (nodes) => {
1667
+ for (const n of nodes) {
1668
+ if (n.type === "video") out.push(n);
1669
+ if (n.type === "group") walk(n.children);
1670
+ }
1671
+ };
1672
+ walk(ir.nodes);
1673
+ return out;
1674
+ }
1675
+ async function buildVideoFrameAssets(ir, sceneDir, fps, duration) {
1676
+ const srcs = collectVideoSrcs(ir);
1677
+ if (srcs.length === 0) return {};
1678
+ const nodes = videoNodes(ir);
1679
+ const reachBySrc = /* @__PURE__ */ new Map();
1680
+ for (const n of nodes) {
1681
+ const reach = neededSeconds(n, duration);
1682
+ reachBySrc.set(n.props.src, Math.max(reachBySrc.get(n.props.src) ?? 0, reach));
1683
+ }
1684
+ const assets = {};
1685
+ for (const src of srcs) {
1686
+ if (!VIDEO_EXT.has(extname2(src).toLowerCase())) {
1687
+ throw new Error(
1688
+ `video "${src}": unsupported format "${extname2(src)}" \u2014 supported: ${[...VIDEO_EXT].join(" ")}`
1689
+ );
1690
+ }
1691
+ const candidates = [isAbsolute4(src) ? src : null, resolve4(sceneDir, src)].filter(
1692
+ (c) => c !== null
1693
+ );
1694
+ const found = candidates.find((c) => existsSync4(c));
1695
+ if (!found) throw new Error(`video "${src}" not found (tried: ${candidates.join(", ")})`);
1696
+ const dir = await mkdtemp3(join6(tmpdir4(), "reframe-vframes-"));
1697
+ try {
1698
+ const seconds = Math.max(1 / fps, reachBySrc.get(src) ?? duration);
1699
+ await runFfmpeg([
1700
+ "-y",
1701
+ "-i",
1702
+ found,
1703
+ "-t",
1704
+ seconds.toFixed(3),
1705
+ "-vf",
1706
+ `fps=${fps},scale='min(iw,1280)':-2`,
1707
+ "-q:v",
1708
+ "4",
1709
+ join6(dir, "%05d.jpg")
1710
+ ]);
1711
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".jpg")).sort();
1712
+ assets[src] = await Promise.all(
1713
+ files.map(async (f) => `data:image/jpeg;base64,${(await readFile3(join6(dir, f))).toString("base64")}`)
1714
+ );
1715
+ if (assets[src].length === 0) throw new Error(`video "${src}": ffmpeg extracted no frames`);
1716
+ } finally {
1717
+ await rm3(dir, { recursive: true, force: true });
1718
+ }
1719
+ }
1720
+ return assets;
1721
+ }
1722
+ function resolveTiming(ir, opts) {
1723
+ const fps = opts.fps ?? ir.fps ?? 30;
1724
+ const duration = opts.duration ?? compileScene(ir).duration;
1725
+ return { fps, duration };
1726
+ }
1727
+
1499
1728
  // ../render-cli/src/vclock.ts
1500
1729
  var VCLOCK_SOURCE = String.raw`
1501
1730
  (() => {
@@ -1569,7 +1798,7 @@ async function injectFonts(page) {
1569
1798
  await document.fonts.ready;
1570
1799
  });
1571
1800
  }
1572
- var framePath = (dir, i) => join4(dir, `${String(i).padStart(5, "0")}.png`);
1801
+ var framePath = (dir, i) => join7(dir, `${String(i).padStart(5, "0")}.png`);
1573
1802
  async function withPage(size, fn) {
1574
1803
  const browser = await chromium.launch({
1575
1804
  args: ["--force-color-profile=srgb", "--font-render-hinting=none"]
@@ -1585,14 +1814,14 @@ var bundleCache = null;
1585
1814
  async function browserBundle() {
1586
1815
  if (bundleCache) return bundleCache;
1587
1816
  if (true) {
1588
- const { readFile: readFile5 } = await import("node:fs/promises");
1589
- bundleCache = await readFile5(
1590
- join4(dirname4(fileURLToPath3(import.meta.url)), "browserEntry.js"),
1817
+ const { readFile: readFile6 } = await import("node:fs/promises");
1818
+ bundleCache = await readFile6(
1819
+ join7(dirname4(fileURLToPath3(import.meta.url)), "browserEntry.js"),
1591
1820
  "utf8"
1592
1821
  );
1593
1822
  return bundleCache;
1594
1823
  }
1595
- const entry = join4(dirname4(fileURLToPath3(import.meta.url)), "browserEntry.ts");
1824
+ const entry = join7(dirname4(fileURLToPath3(import.meta.url)), "browserEntry.ts");
1596
1825
  const result = await build({
1597
1826
  entryPoints: [entry],
1598
1827
  bundle: true,
@@ -1605,7 +1834,10 @@ async function browserBundle() {
1605
1834
  }
1606
1835
  async function captureIr(ir, opts) {
1607
1836
  await mkdir2(opts.framesDir, { recursive: true });
1608
- const assets = await buildImageAssets(ir, opts.sceneDir ?? process.cwd());
1837
+ const sceneDir = opts.sceneDir ?? process.cwd();
1838
+ const assets = await buildImageAssets(ir, sceneDir);
1839
+ const { fps, duration } = resolveTiming(ir, opts);
1840
+ const videoAssets = await buildVideoFrameAssets(ir, sceneDir, fps, duration);
1609
1841
  const bundle = await browserBundle();
1610
1842
  return withPage(ir.size, async (page) => {
1611
1843
  await page.setContent(
@@ -1613,12 +1845,10 @@ async function captureIr(ir, opts) {
1613
1845
  );
1614
1846
  await injectFonts(page);
1615
1847
  await page.addScriptTag({ content: bundle });
1616
- const info = await page.evaluate(
1617
- ([sceneIr, imageAssets]) => window.__reframe.init(sceneIr, imageAssets),
1618
- [ir, assets]
1848
+ await page.evaluate(
1849
+ ([sceneIr, imageAssets, vAssets]) => window.__reframe.init(sceneIr, imageAssets, vAssets),
1850
+ [ir, assets, videoAssets]
1619
1851
  );
1620
- const fps = opts.fps ?? info.fps;
1621
- const duration = opts.duration ?? info.duration;
1622
1852
  const frameCount = Math.max(1, Math.round(duration * fps));
1623
1853
  for (let f = 0; f < frameCount; f++) {
1624
1854
  const dataUrl = await page.evaluate((t) => window.__reframe.renderFrame(t), f / fps);
@@ -1651,25 +1881,25 @@ async function captureHtml(htmlPath, opts) {
1651
1881
  }
1652
1882
 
1653
1883
  // ../render-cli/src/composition.ts
1654
- function runFfmpeg(args) {
1655
- return new Promise((resolve5, reject) => {
1656
- const proc = spawn3("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
1884
+ function runFfmpeg2(args) {
1885
+ return new Promise((resolve7, reject) => {
1886
+ const proc = spawn5("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
1657
1887
  let stderr = "";
1658
1888
  proc.stderr.on("data", (d) => stderr += d.toString());
1659
- proc.on("close", (code) => code === 0 ? resolve5() : reject(new Error(`ffmpeg exited ${code}:
1889
+ proc.on("close", (code) => code === 0 ? resolve7() : reject(new Error(`ffmpeg exited ${code}:
1660
1890
  ${stderr.slice(-2e3)}`)));
1661
1891
  proc.on("error", reject);
1662
1892
  });
1663
1893
  }
1664
1894
  var sanitize = (id) => id.replace(/[^a-z0-9_-]/gi, "_");
1665
1895
  async function renderSceneVideo(scene, sceneDir, fps, out) {
1666
- const framesDir = await mkdtemp2(join5(tmpdir3(), "reframe-frames-"));
1896
+ const framesDir = await mkdtemp4(join8(tmpdir5(), "reframe-frames-"));
1667
1897
  try {
1668
1898
  const result = await captureIr(scene, { framesDir, sceneDir, ...fps !== void 0 && { fps } });
1669
1899
  await encodeMp4(result.framesDir, result.fps, out);
1670
1900
  return { fps: result.fps, frameCount: result.frameCount };
1671
1901
  } finally {
1672
- await rm2(framesDir, { recursive: true, force: true });
1902
+ await rm4(framesDir, { recursive: true, force: true });
1673
1903
  }
1674
1904
  }
1675
1905
  async function renderStandaloneScene(scene, sceneDir, fps, noAudio, out) {
@@ -1677,23 +1907,23 @@ async function renderStandaloneScene(scene, sceneDir, fps, noAudio, out) {
1677
1907
  if (plan) {
1678
1908
  const videoOut = `${out}.video.mp4`;
1679
1909
  await renderSceneVideo(scene, sceneDir, fps, videoOut);
1680
- await buildAudioTrack(plan, join5(sceneDir, "scene"), videoOut, out);
1681
- await rm2(videoOut, { force: true });
1910
+ await buildAudioTrack(plan, join8(sceneDir, "scene"), videoOut, out);
1911
+ await rm4(videoOut, { force: true });
1682
1912
  } else {
1683
1913
  await renderSceneVideo(scene, sceneDir, fps, out);
1684
1914
  }
1685
1915
  }
1686
1916
  async function concatVideos(files, out) {
1687
1917
  if (files.length === 1) {
1688
- await copyFile(files[0], out);
1918
+ await copyFile2(files[0], out);
1689
1919
  return;
1690
1920
  }
1691
1921
  const list = `${out}.concat.txt`;
1692
1922
  await writeFile4(list, files.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n"));
1693
- await runFfmpeg(["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", "-movflags", "+faststart", out]);
1923
+ await runFfmpeg2(["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", "-movflags", "+faststart", out]);
1694
1924
  }
1695
1925
  async function xfade2(a, b, overlap, offset, out) {
1696
- await runFfmpeg([
1926
+ await runFfmpeg2([
1697
1927
  "-y",
1698
1928
  "-i",
1699
1929
  a,
@@ -1721,7 +1951,7 @@ async function combineWithTransitions(videos, out, tmp) {
1721
1951
  let accDur = videos[0].placement.duration;
1722
1952
  for (let i = 1; i < videos.length; i++) {
1723
1953
  const { overlap, duration } = videos[i].placement;
1724
- const step = join5(tmp, `step${i}.mp4`);
1954
+ const step = join8(tmp, `step${i}.mp4`);
1725
1955
  if (overlap <= 0) {
1726
1956
  await concatVideos([acc, videos[i].file], step);
1727
1957
  accDur += duration;
@@ -1732,7 +1962,7 @@ async function combineWithTransitions(videos, out, tmp) {
1732
1962
  }
1733
1963
  acc = step;
1734
1964
  }
1735
- await copyFile(acc, out);
1965
+ await copyFile2(acc, out);
1736
1966
  }
1737
1967
  async function renderComposition(comp, opts) {
1738
1968
  const cc = compileComposition(comp);
@@ -1743,15 +1973,15 @@ async function renderComposition(comp, opts) {
1743
1973
  await renderStandaloneScene(p.scene, sceneDir, opts.fps, opts.noAudio, opts.out);
1744
1974
  return { duration: p.duration, sceneCount: 1 };
1745
1975
  }
1746
- const tmp = await mkdtemp2(join5(tmpdir3(), "reframe-comp-"));
1976
+ const tmp = await mkdtemp4(join8(tmpdir5(), "reframe-comp-"));
1747
1977
  try {
1748
1978
  const videos = [];
1749
1979
  for (const p of cc.scenes) {
1750
- const file = join5(tmp, `${sanitize(p.id)}.mp4`);
1980
+ const file = join8(tmp, `${sanitize(p.id)}.mp4`);
1751
1981
  const { fps } = await renderSceneVideo(p.scene, sceneDir, opts.fps, file);
1752
1982
  videos.push({ id: p.id, file, placement: p, fps });
1753
1983
  }
1754
- const combined = join5(tmp, "combined.mp4");
1984
+ const combined = join8(tmp, "combined.mp4");
1755
1985
  const allCut = cc.scenes.every((s) => s.overlap === 0);
1756
1986
  if (allCut) await concatVideos(videos.map((v) => v.file), combined);
1757
1987
  else await combineWithTransitions(videos, combined, tmp);
@@ -1761,26 +1991,26 @@ async function renderComposition(comp, opts) {
1761
1991
  for (const w of plan.warnings) console.error(`audio: ${w}`);
1762
1992
  await buildAudioTrack(plan, opts.compositionPath, combined, opts.out);
1763
1993
  } else {
1764
- await copyFile(combined, opts.out);
1994
+ await copyFile2(combined, opts.out);
1765
1995
  }
1766
1996
  } else {
1767
- await copyFile(combined, opts.out);
1997
+ await copyFile2(combined, opts.out);
1768
1998
  }
1769
1999
  return { duration: cc.duration, sceneCount: cc.scenes.length };
1770
2000
  } finally {
1771
- await rm2(tmp, { recursive: true, force: true });
2001
+ await rm4(tmp, { recursive: true, force: true });
1772
2002
  }
1773
2003
  }
1774
2004
 
1775
2005
  // ../render-cli/src/loadScene.ts
1776
2006
  import { build as build2 } from "esbuild";
1777
- import { readFile as readFile3 } from "node:fs/promises";
1778
- import { dirname as dirname6, resolve as resolve3 } from "node:path";
2007
+ import { readFile as readFile4 } from "node:fs/promises";
2008
+ import { dirname as dirname6, resolve as resolve5 } from "node:path";
1779
2009
  import { fileURLToPath as fileURLToPath4 } from "node:url";
1780
2010
  var HERE = dirname6(fileURLToPath4(import.meta.url));
1781
- var CORE_ENTRY = true ? resolve3(HERE, "index.js") : resolve3(HERE, "..", "..", "core", "src", "index.ts");
2011
+ var CORE_ENTRY = true ? resolve5(HERE, "index.js") : resolve5(HERE, "..", "..", "core", "src", "index.ts");
1782
2012
  async function loadDefault(path2) {
1783
- if (path2.endsWith(".json")) return JSON.parse(await readFile3(path2, "utf8"));
2013
+ if (path2.endsWith(".json")) return JSON.parse(await readFile4(path2, "utf8"));
1784
2014
  let code;
1785
2015
  try {
1786
2016
  const out = await build2({
@@ -1828,7 +2058,7 @@ function parseArgs(argv) {
1828
2058
  }
1829
2059
  const args = {
1830
2060
  mode,
1831
- input: resolve4(input),
2061
+ input: resolve6(input),
1832
2062
  out: `${basename(input).replace(/\.[^.]+$/, "")}.mp4`,
1833
2063
  keepFrames: false,
1834
2064
  overlays: [],
@@ -1840,8 +2070,8 @@ function parseArgs(argv) {
1840
2070
  else if (a === "--fps") args.fps = Number(rest[++i]);
1841
2071
  else if (a === "--duration") args.duration = Number(rest[++i]);
1842
2072
  else if (a === "--keep-frames") args.keepFrames = true;
1843
- else if (a === "--frames-dir") args.framesDir = resolve4(rest[++i]);
1844
- else if (a === "--overlay") args.overlays.push(resolve4(rest[++i]));
2073
+ else if (a === "--frames-dir") args.framesDir = resolve6(rest[++i]);
2074
+ else if (a === "--overlay") args.overlays.push(resolve6(rest[++i]));
1845
2075
  else if (a === "--no-audio") args.noAudio = true;
1846
2076
  else if (a === "--scene") args.scene = rest[++i];
1847
2077
  else {
@@ -1870,14 +2100,14 @@ async function main() {
1870
2100
  );
1871
2101
  return;
1872
2102
  }
1873
- const framesDir = args.framesDir ?? await mkdtemp3(join6(tmpdir4(), "reframe-frames-"));
2103
+ const framesDir = args.framesDir ?? await mkdtemp5(join9(tmpdir6(), "reframe-frames-"));
1874
2104
  let result;
1875
2105
  let audioJob = null;
1876
2106
  if (args.mode === "ir") {
1877
2107
  let ir = loaded.ir;
1878
2108
  if (args.overlays.length > 0) {
1879
2109
  const docs = await Promise.all(
1880
- args.overlays.map(async (p) => JSON.parse(await readFile4(p, "utf8")))
2110
+ args.overlays.map(async (p) => JSON.parse(await readFile5(p, "utf8")))
1881
2111
  );
1882
2112
  const composed = composeScene(ir, ...docs);
1883
2113
  console.error(formatComposeReport(composed.report));
@@ -1909,10 +2139,10 @@ async function main() {
1909
2139
  await encodeMp4(result.framesDir, result.fps, audioJob ? audioJob.videoOut : args.out);
1910
2140
  if (audioJob) {
1911
2141
  await buildAudioTrack(audioJob.plan, args.input, audioJob.videoOut, args.out);
1912
- await rm3(audioJob.videoOut, { force: true });
2142
+ await rm5(audioJob.videoOut, { force: true });
1913
2143
  }
1914
2144
  if (!args.keepFrames && args.framesDir === void 0) {
1915
- await rm3(framesDir, { recursive: true, force: true });
2145
+ await rm5(framesDir, { recursive: true, force: true });
1916
2146
  }
1917
2147
  console.log(
1918
2148
  `${args.out} (${result.frameCount} frames @ ${result.fps}fps${audioJob ? `, ${audioJob.plan.cues.length} audio cues` : ""})`