reframe-video 0.6.6 → 0.6.8
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 +219 -88
- package/dist/browserEntry.js +26 -11
- package/dist/cli.js +202 -70
- package/dist/index.js +43 -15
- package/dist/labels.js +1 -1
- package/dist/trace-cli.js +1 -1
- package/dist/types/audio.d.ts +15 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/ir.d.ts +5 -0
- package/dist/types/montage.d.ts +19 -6
- package/guides/edsl-guide.md +25 -15
- package/package.json +1 -1
package/dist/browserEntry.js
CHANGED
|
@@ -342,7 +342,7 @@
|
|
|
342
342
|
line: ["x1", "y1", "x2", "y2", "stroke", "strokeWidth", "opacity", "progress", ...FX_PROPS],
|
|
343
343
|
text: [...COMMON_PROPS, "content", "contentDecimals", "contentThousands", "fontFamily", "fontSize", "fontWeight", "fill", "letterSpacing"],
|
|
344
344
|
image: [...COMMON_PROPS, "src", "width", "height", "fit"],
|
|
345
|
-
video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart"],
|
|
345
|
+
video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
|
|
346
346
|
path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
|
|
347
347
|
group: COMMON_PROPS
|
|
348
348
|
};
|
|
@@ -1104,11 +1104,26 @@
|
|
|
1104
1104
|
var canvas = null;
|
|
1105
1105
|
var images = /* @__PURE__ */ new Map();
|
|
1106
1106
|
var videoFrames = /* @__PURE__ */ new Map();
|
|
1107
|
-
|
|
1107
|
+
async function decode(dataUrl, label = "") {
|
|
1108
1108
|
const img = new Image();
|
|
1109
1109
|
img.src = dataUrl;
|
|
1110
|
-
|
|
1111
|
-
|
|
1110
|
+
try {
|
|
1111
|
+
await img.decode();
|
|
1112
|
+
return img;
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
throw new Error(`decode failed for ${label} (len=${dataUrl.length}): ${String(e)}`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
async function decodeAll(urls, label) {
|
|
1118
|
+
const out = new Array(urls.length);
|
|
1119
|
+
const LIMIT = 8;
|
|
1120
|
+
for (let base = 0; base < urls.length; base += LIMIT) {
|
|
1121
|
+
const batch = urls.slice(base, base + LIMIT);
|
|
1122
|
+
const decoded = await Promise.all(batch.map((u, j) => decode(u, `${label}#${base + j}`)));
|
|
1123
|
+
for (let j = 0; j < decoded.length; j++) out[base + j] = decoded[j];
|
|
1124
|
+
}
|
|
1125
|
+
return out;
|
|
1126
|
+
}
|
|
1112
1127
|
var videos = {
|
|
1113
1128
|
frame(src, index) {
|
|
1114
1129
|
const frames = videoFrames.get(src);
|
|
@@ -1126,14 +1141,14 @@
|
|
|
1126
1141
|
document.body.appendChild(canvas);
|
|
1127
1142
|
ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
1128
1143
|
if (!ctx) throw new Error("could not create 2d context");
|
|
1129
|
-
await Promise.all(
|
|
1130
|
-
|
|
1131
|
-
images.set(src, await decode(dataUrl));
|
|
1132
|
-
}),
|
|
1133
|
-
...Object.entries(videoAssets).map(async ([src, frames]) => {
|
|
1134
|
-
videoFrames.set(src, await Promise.all(frames.map(decode)));
|
|
1144
|
+
await Promise.all(
|
|
1145
|
+
Object.entries(assets).map(async ([src, dataUrl]) => {
|
|
1146
|
+
images.set(src, await decode(dataUrl, `image ${src}`));
|
|
1135
1147
|
})
|
|
1136
|
-
|
|
1148
|
+
);
|
|
1149
|
+
for (const [src, frames] of Object.entries(videoAssets)) {
|
|
1150
|
+
videoFrames.set(src, await decodeAll(frames, `video ${src}`));
|
|
1151
|
+
}
|
|
1137
1152
|
return { duration: compiled.duration, fps: ir.fps ?? 30 };
|
|
1138
1153
|
},
|
|
1139
1154
|
renderFrame(t) {
|
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,7 +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"],
|
|
350
|
+
video: [...COMMON_PROPS, "src", "width", "height", "fit", "start", "rate", "clipStart", "volume"],
|
|
351
351
|
path: [...COMMON_PROPS, "d", "fill", "stroke", "strokeWidth", "progress", "originX", "originY"],
|
|
352
352
|
group: COMMON_PROPS
|
|
353
353
|
};
|
|
@@ -885,11 +885,34 @@ var SFX_DURATION = {
|
|
|
885
885
|
thud: 0.25
|
|
886
886
|
};
|
|
887
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
|
+
}
|
|
888
908
|
function resolveAudioPlan(compiled) {
|
|
889
909
|
const audio = compiled.ir.audio;
|
|
890
|
-
if (!audio || !audio.bgm && (audio.cues ?? []).length === 0) return null;
|
|
891
910
|
const warnings = [];
|
|
892
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
|
+
}
|
|
893
916
|
const cues = [];
|
|
894
917
|
for (const [index, cue] of (audio.cues ?? []).entries()) {
|
|
895
918
|
let anchor;
|
|
@@ -925,6 +948,7 @@ function resolveAudioPlan(compiled) {
|
|
|
925
948
|
bgm: resolveBgm(audio.bgm),
|
|
926
949
|
cues,
|
|
927
950
|
duckWindows: mergeDuckWindows(cues, duration),
|
|
951
|
+
clipAudio,
|
|
928
952
|
warnings
|
|
929
953
|
};
|
|
930
954
|
}
|
|
@@ -958,6 +982,7 @@ function resolveCompositionAudioPlan(comp) {
|
|
|
958
982
|
const duration = comp.duration;
|
|
959
983
|
const warnings = [];
|
|
960
984
|
const cues = [];
|
|
985
|
+
const clipAudio = [];
|
|
961
986
|
for (const placement of comp.scenes) {
|
|
962
987
|
const plan = resolveAudioPlan(placement.compiled);
|
|
963
988
|
if (!plan) continue;
|
|
@@ -970,6 +995,11 @@ function resolveCompositionAudioPlan(comp) {
|
|
|
970
995
|
if (t >= duration) continue;
|
|
971
996
|
cues.push({ ...cue, t });
|
|
972
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
|
+
}
|
|
973
1003
|
}
|
|
974
1004
|
for (const [index, cue] of (audio?.cues ?? []).entries()) {
|
|
975
1005
|
if (typeof cue.at !== "number") {
|
|
@@ -989,13 +1019,14 @@ function resolveCompositionAudioPlan(comp) {
|
|
|
989
1019
|
source: cue.sfx ? { kind: "sfx", name: cue.sfx, params: cue.params ?? {} } : { kind: "file", path: cue.file }
|
|
990
1020
|
});
|
|
991
1021
|
}
|
|
992
|
-
if (!audio?.bgm && cues.length === 0) return null;
|
|
1022
|
+
if (!audio?.bgm && cues.length === 0 && clipAudio.length === 0) return null;
|
|
993
1023
|
cues.sort((a, b) => a.t - b.t);
|
|
994
1024
|
return {
|
|
995
1025
|
duration,
|
|
996
1026
|
bgm: resolveBgm(audio?.bgm),
|
|
997
1027
|
cues,
|
|
998
1028
|
duckWindows: mergeDuckWindows(cues, duration),
|
|
1029
|
+
clipAudio,
|
|
999
1030
|
warnings
|
|
1000
1031
|
};
|
|
1001
1032
|
}
|
|
@@ -1083,7 +1114,9 @@ function collectVideoSrcs(ir) {
|
|
|
1083
1114
|
}
|
|
1084
1115
|
|
|
1085
1116
|
// ../render-cli/src/audio/index.ts
|
|
1086
|
-
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";
|
|
1087
1120
|
|
|
1088
1121
|
// ../render-cli/src/audio/sfx.ts
|
|
1089
1122
|
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
@@ -1305,12 +1338,85 @@ async function resolveBgmFile(source, duration, sceneDir) {
|
|
|
1305
1338
|
return writeCached(`ambient-pad-${duration.toFixed(2)}`, () => synthAmbientPad(duration));
|
|
1306
1339
|
}
|
|
1307
1340
|
|
|
1308
|
-
// ../render-cli/src/audio/
|
|
1341
|
+
// ../render-cli/src/audio/clip.ts
|
|
1309
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";
|
|
1310
1401
|
import { mkdtemp, rm, writeFile as writeFile2 } from "node:fs/promises";
|
|
1311
1402
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
1312
|
-
import { join as
|
|
1403
|
+
import { join as join3 } from "node:path";
|
|
1313
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
|
+
}
|
|
1314
1420
|
function buildFilterGraph(plan, inputs) {
|
|
1315
1421
|
const lines = [];
|
|
1316
1422
|
const mixIn = ["[anchor]"];
|
|
@@ -1343,15 +1449,25 @@ function buildFilterGraph(plan, inputs) {
|
|
|
1343
1449
|
mixIn.push(`[c${i}]`);
|
|
1344
1450
|
inputIndex++;
|
|
1345
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
|
+
});
|
|
1346
1462
|
lines.push(
|
|
1347
1463
|
`${mixIn.join("")}amix=inputs=${mixIn.length}:duration=first:normalize=0,alimiter=limit=0.891,aresample=async=1:first_pts=0[aout]`
|
|
1348
1464
|
);
|
|
1349
1465
|
return lines.join(";\n");
|
|
1350
1466
|
}
|
|
1351
1467
|
async function muxAudio(videoIn, plan, inputs, outFile) {
|
|
1352
|
-
const work = await mkdtemp(
|
|
1468
|
+
const work = await mkdtemp(join3(tmpdir2(), "reframe-mux-"));
|
|
1353
1469
|
try {
|
|
1354
|
-
const graphFile =
|
|
1470
|
+
const graphFile = join3(work, "graph.txt");
|
|
1355
1471
|
await writeFile2(graphFile, buildFilterGraph(plan, inputs));
|
|
1356
1472
|
const args = [
|
|
1357
1473
|
"-y",
|
|
@@ -1359,6 +1475,7 @@ async function muxAudio(videoIn, plan, inputs, outFile) {
|
|
|
1359
1475
|
videoIn,
|
|
1360
1476
|
...plan.bgm && inputs.bgmFile ? ["-i", inputs.bgmFile] : [],
|
|
1361
1477
|
...inputs.cueFiles.flatMap((f) => ["-i", f]),
|
|
1478
|
+
...(inputs.clipFiles ?? []).flatMap((c) => ["-i", c.file]),
|
|
1362
1479
|
"-filter_complex_script",
|
|
1363
1480
|
graphFile,
|
|
1364
1481
|
"-map",
|
|
@@ -1377,7 +1494,7 @@ async function muxAudio(videoIn, plan, inputs, outFile) {
|
|
|
1377
1494
|
outFile
|
|
1378
1495
|
];
|
|
1379
1496
|
await new Promise((resolvePromise, reject) => {
|
|
1380
|
-
const proc =
|
|
1497
|
+
const proc = spawn2("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
1381
1498
|
let stderr = "";
|
|
1382
1499
|
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
1383
1500
|
proc.on("close", (code) => {
|
|
@@ -1397,17 +1514,32 @@ async function buildAudioTrack(plan, scenePath, videoIn, outFile) {
|
|
|
1397
1514
|
const sceneDir = dirname2(scenePath);
|
|
1398
1515
|
const cueFiles = await Promise.all(plan.cues.map((cue) => resolveCueFile(cue, sceneDir)));
|
|
1399
1516
|
const bgmFile = plan.bgm ? await resolveBgmFile(plan.bgm.source, plan.duration, sceneDir) : null;
|
|
1400
|
-
|
|
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
|
+
}
|
|
1401
1533
|
}
|
|
1402
1534
|
|
|
1403
1535
|
// ../render-cli/src/composition.ts
|
|
1404
|
-
import { spawn as
|
|
1405
|
-
import { copyFile, mkdtemp as
|
|
1406
|
-
import { tmpdir as
|
|
1407
|
-
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";
|
|
1408
1540
|
|
|
1409
1541
|
// ../render-cli/src/encode.ts
|
|
1410
|
-
import { spawn as
|
|
1542
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
1411
1543
|
async function encodeMp4(framesDir, fps, outFile) {
|
|
1412
1544
|
const args = [
|
|
1413
1545
|
"-y",
|
|
@@ -1427,12 +1559,12 @@ async function encodeMp4(framesDir, fps, outFile) {
|
|
|
1427
1559
|
"+faststart",
|
|
1428
1560
|
outFile
|
|
1429
1561
|
];
|
|
1430
|
-
await new Promise((
|
|
1431
|
-
const proc =
|
|
1562
|
+
await new Promise((resolve7, reject) => {
|
|
1563
|
+
const proc = spawn3("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
1432
1564
|
let stderr = "";
|
|
1433
1565
|
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
1434
1566
|
proc.on("close", (code) => {
|
|
1435
|
-
if (code === 0)
|
|
1567
|
+
if (code === 0) resolve7();
|
|
1436
1568
|
else reject(new Error(`ffmpeg exited with ${code}:
|
|
1437
1569
|
${stderr.slice(-2e3)}`));
|
|
1438
1570
|
});
|
|
@@ -1442,23 +1574,23 @@ ${stderr.slice(-2e3)}`));
|
|
|
1442
1574
|
|
|
1443
1575
|
// ../render-cli/src/frameLoop.ts
|
|
1444
1576
|
import { mkdir as mkdir2, writeFile as writeFile3 } from "node:fs/promises";
|
|
1445
|
-
import { join as
|
|
1577
|
+
import { join as join7, dirname as dirname4 } from "node:path";
|
|
1446
1578
|
import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
|
|
1447
1579
|
import { build } from "esbuild";
|
|
1448
1580
|
import { chromium } from "playwright";
|
|
1449
1581
|
|
|
1450
1582
|
// ../render-cli/src/fonts.ts
|
|
1451
1583
|
import { readFile } from "node:fs/promises";
|
|
1452
|
-
import { dirname as dirname3, join as
|
|
1584
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
1453
1585
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1454
|
-
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");
|
|
1455
1587
|
var WEIGHTS = [400, 700, 800];
|
|
1456
1588
|
var cssCache = null;
|
|
1457
1589
|
async function fontFaceCss() {
|
|
1458
1590
|
if (cssCache) return cssCache;
|
|
1459
1591
|
const rules = await Promise.all(
|
|
1460
1592
|
WEIGHTS.map(async (weight) => {
|
|
1461
|
-
const data = await readFile(
|
|
1593
|
+
const data = await readFile(join5(FONTS_DIR, `inter-${weight}.woff2`));
|
|
1462
1594
|
return `@font-face {
|
|
1463
1595
|
font-family: "Inter";
|
|
1464
1596
|
font-style: normal;
|
|
@@ -1473,8 +1605,8 @@ async function fontFaceCss() {
|
|
|
1473
1605
|
|
|
1474
1606
|
// ../render-cli/src/images.ts
|
|
1475
1607
|
import { readFile as readFile2 } from "node:fs/promises";
|
|
1476
|
-
import { existsSync as
|
|
1477
|
-
import { extname, isAbsolute as
|
|
1608
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
1609
|
+
import { extname, isAbsolute as isAbsolute3, resolve as resolve3 } from "node:path";
|
|
1478
1610
|
var MIME = {
|
|
1479
1611
|
".png": "image/png",
|
|
1480
1612
|
".jpg": "image/jpeg",
|
|
@@ -1490,10 +1622,10 @@ async function buildImageAssets(ir, sceneDir) {
|
|
|
1490
1622
|
`image "${src}": unsupported format "${extname(src)}" \u2014 supported: ${Object.keys(MIME).join(" ")}`
|
|
1491
1623
|
);
|
|
1492
1624
|
}
|
|
1493
|
-
const candidates = [
|
|
1625
|
+
const candidates = [isAbsolute3(src) ? src : null, resolve3(sceneDir, src)].filter(
|
|
1494
1626
|
(c) => c !== null
|
|
1495
1627
|
);
|
|
1496
|
-
const found = candidates.find((c) =>
|
|
1628
|
+
const found = candidates.find((c) => existsSync3(c));
|
|
1497
1629
|
if (!found) {
|
|
1498
1630
|
throw new Error(`image "${src}" not found (tried: ${candidates.join(", ")})`);
|
|
1499
1631
|
}
|
|
@@ -1504,20 +1636,20 @@ async function buildImageAssets(ir, sceneDir) {
|
|
|
1504
1636
|
}
|
|
1505
1637
|
|
|
1506
1638
|
// ../render-cli/src/videos.ts
|
|
1507
|
-
import { spawn as
|
|
1508
|
-
import { mkdtemp as
|
|
1509
|
-
import { existsSync as
|
|
1510
|
-
import { tmpdir as
|
|
1511
|
-
import { extname as extname2, isAbsolute as
|
|
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";
|
|
1512
1644
|
var VIDEO_EXT = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".m4v", ".mkv"]);
|
|
1513
1645
|
function runFfmpeg(args) {
|
|
1514
|
-
return new Promise((
|
|
1515
|
-
const proc =
|
|
1646
|
+
return new Promise((resolve7, reject) => {
|
|
1647
|
+
const proc = spawn4("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
1516
1648
|
let stderr = "";
|
|
1517
1649
|
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
1518
1650
|
proc.on(
|
|
1519
1651
|
"close",
|
|
1520
|
-
(code) => code === 0 ?
|
|
1652
|
+
(code) => code === 0 ? resolve7() : reject(new Error(`ffmpeg exited ${code}:
|
|
1521
1653
|
${stderr.slice(-2e3)}`))
|
|
1522
1654
|
);
|
|
1523
1655
|
proc.on("error", reject);
|
|
@@ -1556,12 +1688,12 @@ async function buildVideoFrameAssets(ir, sceneDir, fps, duration) {
|
|
|
1556
1688
|
`video "${src}": unsupported format "${extname2(src)}" \u2014 supported: ${[...VIDEO_EXT].join(" ")}`
|
|
1557
1689
|
);
|
|
1558
1690
|
}
|
|
1559
|
-
const candidates = [
|
|
1691
|
+
const candidates = [isAbsolute4(src) ? src : null, resolve4(sceneDir, src)].filter(
|
|
1560
1692
|
(c) => c !== null
|
|
1561
1693
|
);
|
|
1562
|
-
const found = candidates.find((c) =>
|
|
1694
|
+
const found = candidates.find((c) => existsSync4(c));
|
|
1563
1695
|
if (!found) throw new Error(`video "${src}" not found (tried: ${candidates.join(", ")})`);
|
|
1564
|
-
const dir = await
|
|
1696
|
+
const dir = await mkdtemp3(join6(tmpdir4(), "reframe-vframes-"));
|
|
1565
1697
|
try {
|
|
1566
1698
|
const seconds = Math.max(1 / fps, reachBySrc.get(src) ?? duration);
|
|
1567
1699
|
await runFfmpeg([
|
|
@@ -1574,15 +1706,15 @@ async function buildVideoFrameAssets(ir, sceneDir, fps, duration) {
|
|
|
1574
1706
|
`fps=${fps},scale='min(iw,1280)':-2`,
|
|
1575
1707
|
"-q:v",
|
|
1576
1708
|
"4",
|
|
1577
|
-
|
|
1709
|
+
join6(dir, "%05d.jpg")
|
|
1578
1710
|
]);
|
|
1579
1711
|
const files = (await readdir(dir)).filter((f) => f.endsWith(".jpg")).sort();
|
|
1580
1712
|
assets[src] = await Promise.all(
|
|
1581
|
-
files.map(async (f) => `data:image/jpeg;base64,${(await readFile3(
|
|
1713
|
+
files.map(async (f) => `data:image/jpeg;base64,${(await readFile3(join6(dir, f))).toString("base64")}`)
|
|
1582
1714
|
);
|
|
1583
1715
|
if (assets[src].length === 0) throw new Error(`video "${src}": ffmpeg extracted no frames`);
|
|
1584
1716
|
} finally {
|
|
1585
|
-
await
|
|
1717
|
+
await rm3(dir, { recursive: true, force: true });
|
|
1586
1718
|
}
|
|
1587
1719
|
}
|
|
1588
1720
|
return assets;
|
|
@@ -1666,7 +1798,7 @@ async function injectFonts(page) {
|
|
|
1666
1798
|
await document.fonts.ready;
|
|
1667
1799
|
});
|
|
1668
1800
|
}
|
|
1669
|
-
var framePath = (dir, i) =>
|
|
1801
|
+
var framePath = (dir, i) => join7(dir, `${String(i).padStart(5, "0")}.png`);
|
|
1670
1802
|
async function withPage(size, fn) {
|
|
1671
1803
|
const browser = await chromium.launch({
|
|
1672
1804
|
args: ["--force-color-profile=srgb", "--font-render-hinting=none"]
|
|
@@ -1684,12 +1816,12 @@ async function browserBundle() {
|
|
|
1684
1816
|
if (true) {
|
|
1685
1817
|
const { readFile: readFile6 } = await import("node:fs/promises");
|
|
1686
1818
|
bundleCache = await readFile6(
|
|
1687
|
-
|
|
1819
|
+
join7(dirname4(fileURLToPath3(import.meta.url)), "browserEntry.js"),
|
|
1688
1820
|
"utf8"
|
|
1689
1821
|
);
|
|
1690
1822
|
return bundleCache;
|
|
1691
1823
|
}
|
|
1692
|
-
const entry =
|
|
1824
|
+
const entry = join7(dirname4(fileURLToPath3(import.meta.url)), "browserEntry.ts");
|
|
1693
1825
|
const result = await build({
|
|
1694
1826
|
entryPoints: [entry],
|
|
1695
1827
|
bundle: true,
|
|
@@ -1750,24 +1882,24 @@ async function captureHtml(htmlPath, opts) {
|
|
|
1750
1882
|
|
|
1751
1883
|
// ../render-cli/src/composition.ts
|
|
1752
1884
|
function runFfmpeg2(args) {
|
|
1753
|
-
return new Promise((
|
|
1754
|
-
const proc =
|
|
1885
|
+
return new Promise((resolve7, reject) => {
|
|
1886
|
+
const proc = spawn5("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
1755
1887
|
let stderr = "";
|
|
1756
1888
|
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
1757
|
-
proc.on("close", (code) => code === 0 ?
|
|
1889
|
+
proc.on("close", (code) => code === 0 ? resolve7() : reject(new Error(`ffmpeg exited ${code}:
|
|
1758
1890
|
${stderr.slice(-2e3)}`)));
|
|
1759
1891
|
proc.on("error", reject);
|
|
1760
1892
|
});
|
|
1761
1893
|
}
|
|
1762
1894
|
var sanitize = (id) => id.replace(/[^a-z0-9_-]/gi, "_");
|
|
1763
1895
|
async function renderSceneVideo(scene, sceneDir, fps, out) {
|
|
1764
|
-
const framesDir = await
|
|
1896
|
+
const framesDir = await mkdtemp4(join8(tmpdir5(), "reframe-frames-"));
|
|
1765
1897
|
try {
|
|
1766
1898
|
const result = await captureIr(scene, { framesDir, sceneDir, ...fps !== void 0 && { fps } });
|
|
1767
1899
|
await encodeMp4(result.framesDir, result.fps, out);
|
|
1768
1900
|
return { fps: result.fps, frameCount: result.frameCount };
|
|
1769
1901
|
} finally {
|
|
1770
|
-
await
|
|
1902
|
+
await rm4(framesDir, { recursive: true, force: true });
|
|
1771
1903
|
}
|
|
1772
1904
|
}
|
|
1773
1905
|
async function renderStandaloneScene(scene, sceneDir, fps, noAudio, out) {
|
|
@@ -1775,15 +1907,15 @@ async function renderStandaloneScene(scene, sceneDir, fps, noAudio, out) {
|
|
|
1775
1907
|
if (plan) {
|
|
1776
1908
|
const videoOut = `${out}.video.mp4`;
|
|
1777
1909
|
await renderSceneVideo(scene, sceneDir, fps, videoOut);
|
|
1778
|
-
await buildAudioTrack(plan,
|
|
1779
|
-
await
|
|
1910
|
+
await buildAudioTrack(plan, join8(sceneDir, "scene"), videoOut, out);
|
|
1911
|
+
await rm4(videoOut, { force: true });
|
|
1780
1912
|
} else {
|
|
1781
1913
|
await renderSceneVideo(scene, sceneDir, fps, out);
|
|
1782
1914
|
}
|
|
1783
1915
|
}
|
|
1784
1916
|
async function concatVideos(files, out) {
|
|
1785
1917
|
if (files.length === 1) {
|
|
1786
|
-
await
|
|
1918
|
+
await copyFile2(files[0], out);
|
|
1787
1919
|
return;
|
|
1788
1920
|
}
|
|
1789
1921
|
const list = `${out}.concat.txt`;
|
|
@@ -1819,7 +1951,7 @@ async function combineWithTransitions(videos, out, tmp) {
|
|
|
1819
1951
|
let accDur = videos[0].placement.duration;
|
|
1820
1952
|
for (let i = 1; i < videos.length; i++) {
|
|
1821
1953
|
const { overlap, duration } = videos[i].placement;
|
|
1822
|
-
const step =
|
|
1954
|
+
const step = join8(tmp, `step${i}.mp4`);
|
|
1823
1955
|
if (overlap <= 0) {
|
|
1824
1956
|
await concatVideos([acc, videos[i].file], step);
|
|
1825
1957
|
accDur += duration;
|
|
@@ -1830,7 +1962,7 @@ async function combineWithTransitions(videos, out, tmp) {
|
|
|
1830
1962
|
}
|
|
1831
1963
|
acc = step;
|
|
1832
1964
|
}
|
|
1833
|
-
await
|
|
1965
|
+
await copyFile2(acc, out);
|
|
1834
1966
|
}
|
|
1835
1967
|
async function renderComposition(comp, opts) {
|
|
1836
1968
|
const cc = compileComposition(comp);
|
|
@@ -1841,15 +1973,15 @@ async function renderComposition(comp, opts) {
|
|
|
1841
1973
|
await renderStandaloneScene(p.scene, sceneDir, opts.fps, opts.noAudio, opts.out);
|
|
1842
1974
|
return { duration: p.duration, sceneCount: 1 };
|
|
1843
1975
|
}
|
|
1844
|
-
const tmp = await
|
|
1976
|
+
const tmp = await mkdtemp4(join8(tmpdir5(), "reframe-comp-"));
|
|
1845
1977
|
try {
|
|
1846
1978
|
const videos = [];
|
|
1847
1979
|
for (const p of cc.scenes) {
|
|
1848
|
-
const file =
|
|
1980
|
+
const file = join8(tmp, `${sanitize(p.id)}.mp4`);
|
|
1849
1981
|
const { fps } = await renderSceneVideo(p.scene, sceneDir, opts.fps, file);
|
|
1850
1982
|
videos.push({ id: p.id, file, placement: p, fps });
|
|
1851
1983
|
}
|
|
1852
|
-
const combined =
|
|
1984
|
+
const combined = join8(tmp, "combined.mp4");
|
|
1853
1985
|
const allCut = cc.scenes.every((s) => s.overlap === 0);
|
|
1854
1986
|
if (allCut) await concatVideos(videos.map((v) => v.file), combined);
|
|
1855
1987
|
else await combineWithTransitions(videos, combined, tmp);
|
|
@@ -1859,24 +1991,24 @@ async function renderComposition(comp, opts) {
|
|
|
1859
1991
|
for (const w of plan.warnings) console.error(`audio: ${w}`);
|
|
1860
1992
|
await buildAudioTrack(plan, opts.compositionPath, combined, opts.out);
|
|
1861
1993
|
} else {
|
|
1862
|
-
await
|
|
1994
|
+
await copyFile2(combined, opts.out);
|
|
1863
1995
|
}
|
|
1864
1996
|
} else {
|
|
1865
|
-
await
|
|
1997
|
+
await copyFile2(combined, opts.out);
|
|
1866
1998
|
}
|
|
1867
1999
|
return { duration: cc.duration, sceneCount: cc.scenes.length };
|
|
1868
2000
|
} finally {
|
|
1869
|
-
await
|
|
2001
|
+
await rm4(tmp, { recursive: true, force: true });
|
|
1870
2002
|
}
|
|
1871
2003
|
}
|
|
1872
2004
|
|
|
1873
2005
|
// ../render-cli/src/loadScene.ts
|
|
1874
2006
|
import { build as build2 } from "esbuild";
|
|
1875
2007
|
import { readFile as readFile4 } from "node:fs/promises";
|
|
1876
|
-
import { dirname as dirname6, resolve as
|
|
2008
|
+
import { dirname as dirname6, resolve as resolve5 } from "node:path";
|
|
1877
2009
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
1878
2010
|
var HERE = dirname6(fileURLToPath4(import.meta.url));
|
|
1879
|
-
var CORE_ENTRY = true ?
|
|
2011
|
+
var CORE_ENTRY = true ? resolve5(HERE, "index.js") : resolve5(HERE, "..", "..", "core", "src", "index.ts");
|
|
1880
2012
|
async function loadDefault(path2) {
|
|
1881
2013
|
if (path2.endsWith(".json")) return JSON.parse(await readFile4(path2, "utf8"));
|
|
1882
2014
|
let code;
|
|
@@ -1926,7 +2058,7 @@ function parseArgs(argv) {
|
|
|
1926
2058
|
}
|
|
1927
2059
|
const args = {
|
|
1928
2060
|
mode,
|
|
1929
|
-
input:
|
|
2061
|
+
input: resolve6(input),
|
|
1930
2062
|
out: `${basename(input).replace(/\.[^.]+$/, "")}.mp4`,
|
|
1931
2063
|
keepFrames: false,
|
|
1932
2064
|
overlays: [],
|
|
@@ -1938,8 +2070,8 @@ function parseArgs(argv) {
|
|
|
1938
2070
|
else if (a === "--fps") args.fps = Number(rest[++i]);
|
|
1939
2071
|
else if (a === "--duration") args.duration = Number(rest[++i]);
|
|
1940
2072
|
else if (a === "--keep-frames") args.keepFrames = true;
|
|
1941
|
-
else if (a === "--frames-dir") args.framesDir =
|
|
1942
|
-
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]));
|
|
1943
2075
|
else if (a === "--no-audio") args.noAudio = true;
|
|
1944
2076
|
else if (a === "--scene") args.scene = rest[++i];
|
|
1945
2077
|
else {
|
|
@@ -1968,7 +2100,7 @@ async function main() {
|
|
|
1968
2100
|
);
|
|
1969
2101
|
return;
|
|
1970
2102
|
}
|
|
1971
|
-
const framesDir = args.framesDir ?? await
|
|
2103
|
+
const framesDir = args.framesDir ?? await mkdtemp5(join9(tmpdir6(), "reframe-frames-"));
|
|
1972
2104
|
let result;
|
|
1973
2105
|
let audioJob = null;
|
|
1974
2106
|
if (args.mode === "ir") {
|
|
@@ -2007,10 +2139,10 @@ async function main() {
|
|
|
2007
2139
|
await encodeMp4(result.framesDir, result.fps, audioJob ? audioJob.videoOut : args.out);
|
|
2008
2140
|
if (audioJob) {
|
|
2009
2141
|
await buildAudioTrack(audioJob.plan, args.input, audioJob.videoOut, args.out);
|
|
2010
|
-
await
|
|
2142
|
+
await rm5(audioJob.videoOut, { force: true });
|
|
2011
2143
|
}
|
|
2012
2144
|
if (!args.keepFrames && args.framesDir === void 0) {
|
|
2013
|
-
await
|
|
2145
|
+
await rm5(framesDir, { recursive: true, force: true });
|
|
2014
2146
|
}
|
|
2015
2147
|
console.log(
|
|
2016
2148
|
`${args.out} (${result.frameCount} frames @ ${result.fps}fps${audioJob ? `, ${audioJob.plan.cues.length} audio cues` : ""})`
|