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/bin.js +331 -94
- package/dist/browserEntry.js +82 -35
- package/dist/cli.js +305 -75
- package/dist/index.js +79 -8
- package/dist/labels.js +1 -0
- package/dist/renderer-canvas.js +31 -25
- package/dist/trace-cli.js +1 -0
- package/dist/types/assets.d.ts +3 -1
- package/dist/types/audio.d.ts +15 -0
- package/dist/types/dsl.d.ts +4 -1
- package/dist/types/evaluate.d.ts +12 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/ir.d.ts +26 -0
- package/guides/edsl-guide.md +25 -0
- package/package.json +1 -1
- package/preview/src/main.ts +2 -1
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
|
|
5
|
-
import { tmpdir as
|
|
6
|
-
import { basename, dirname as dirname7, join as
|
|
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
|
|
1079
|
+
function collectSrcs(ir, type) {
|
|
1048
1080
|
const srcs = /* @__PURE__ */ new Set();
|
|
1049
|
-
const
|
|
1081
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1050
1082
|
const walkNodes = (nodes) => {
|
|
1051
1083
|
for (const node of nodes) {
|
|
1052
|
-
if (node.type ===
|
|
1053
|
-
|
|
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 (
|
|
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" &&
|
|
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 {
|
|
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/
|
|
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
|
|
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(
|
|
1468
|
+
const work = await mkdtemp(join3(tmpdir2(), "reframe-mux-"));
|
|
1346
1469
|
try {
|
|
1347
|
-
const graphFile =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1398
|
-
import { copyFile, mkdtemp as
|
|
1399
|
-
import { tmpdir as
|
|
1400
|
-
import { dirname as dirname5, join as
|
|
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
|
|
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((
|
|
1424
|
-
const proc =
|
|
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)
|
|
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
|
|
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
|
|
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 ?
|
|
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(
|
|
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
|
|
1470
|
-
import { extname, isAbsolute as
|
|
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 = [
|
|
1625
|
+
const candidates = [isAbsolute3(src) ? src : null, resolve3(sceneDir, src)].filter(
|
|
1487
1626
|
(c) => c !== null
|
|
1488
1627
|
);
|
|
1489
|
-
const found = candidates.find((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) =>
|
|
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:
|
|
1589
|
-
bundleCache = await
|
|
1590
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
1655
|
-
return new Promise((
|
|
1656
|
-
const proc =
|
|
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 ?
|
|
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
|
|
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
|
|
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,
|
|
1681
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
1994
|
+
await copyFile2(combined, opts.out);
|
|
1765
1995
|
}
|
|
1766
1996
|
} else {
|
|
1767
|
-
await
|
|
1997
|
+
await copyFile2(combined, opts.out);
|
|
1768
1998
|
}
|
|
1769
1999
|
return { duration: cc.duration, sceneCount: cc.scenes.length };
|
|
1770
2000
|
} finally {
|
|
1771
|
-
await
|
|
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
|
|
1778
|
-
import { dirname as dirname6, resolve as
|
|
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 ?
|
|
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
|
|
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:
|
|
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 =
|
|
1844
|
-
else if (a === "--overlay") args.overlays.push(
|
|
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
|
|
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
|
|
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
|
|
2142
|
+
await rm5(audioJob.videoOut, { force: true });
|
|
1913
2143
|
}
|
|
1914
2144
|
if (!args.keepFrames && args.framesDir === void 0) {
|
|
1915
|
-
await
|
|
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` : ""})`
|