motionrender 0.2.1 → 0.2.3
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 +2872 -582
- package/dist/index.js +270 -43
- package/dist/player.js +13 -13
- package/dist/react.js +704 -9
- package/dist/types/packages/ai/src/agent.d.ts +57 -0
- package/dist/types/packages/ai/src/brand-scraper.d.ts +35 -0
- package/dist/types/packages/ai/src/captions.d.ts +11 -0
- package/dist/types/packages/ai/src/compose.d.ts +2 -0
- package/dist/types/packages/ai/src/edit.d.ts +69 -0
- package/dist/types/packages/ai/src/index.d.ts +8 -1
- package/dist/types/packages/ai/src/plan.d.ts +42 -0
- package/dist/types/packages/ai/src/prompt.d.ts +18 -0
- package/dist/types/packages/ai/src/providers.d.ts +35 -2
- package/dist/types/packages/ai/src/registry.d.ts +81 -0
- package/dist/types/packages/ai/src/retime.d.ts +7 -0
- package/dist/types/packages/ai/src/shorts.d.ts +75 -0
- package/dist/types/packages/cli/src/agent-cli.d.ts +15 -0
- package/dist/types/packages/cli/src/ai-studio-page.d.ts +1 -1
- package/dist/types/packages/cli/src/ai.d.ts +1 -1
- package/dist/types/packages/cli/src/build.d.ts +2 -0
- package/dist/types/packages/cli/src/templates.d.ts +13 -4
- package/dist/types/packages/contracts/src/index.d.ts +12 -12
- package/dist/types/packages/core/src/index.d.ts +2 -2
- package/dist/types/packages/core/src/review.d.ts +24 -0
- package/dist/types/packages/ffmpeg/src/auto-install.d.ts +12 -0
- package/dist/types/packages/ffmpeg/src/index.d.ts +2 -0
- package/dist/types/packages/jobs/src/queue.d.ts +2 -2
- package/dist/types/packages/jobs/src/redis.d.ts +2 -2
- package/dist/types/packages/react/src/components.d.ts +166 -0
- package/dist/types/packages/react/src/index.d.ts +54 -2
- package/dist/types/packages/react/src/springs.d.ts +25 -0
- package/dist/types/packages/renderer/src/index.d.ts +3 -0
- package/dist/types/packages/schema/src/captions.d.ts +2 -0
- package/dist/types/packages/schema/src/index.d.ts +66 -66
- package/dist/types/scripts/eval-briefs.d.ts +67 -0
- package/package.json +1 -1
- package/skills/ove/SKILL.md +40 -2
- package/skills/ove-agent/SKILL.md +144 -0
- package/skills/ove-code-video/SKILL.md +17 -9
- package/skills/ove-motion-graphics/SKILL.md +113 -0
- package/skills/ove-react-video/SKILL.md +212 -0
- package/skills/ove-remotion/SKILL.md +122 -0
- package/studio/assets/index-DmZj9wTZ.css +1 -0
- package/studio/assets/index-n3DJI_GV.js +10 -0
- package/studio/index.html +2 -2
- package/templates/case-study/composition.json +25 -10
- package/templates/event-invite/composition.json +12 -2
- package/templates/explainer-square/composition.json +1 -1
- package/templates/keynote-wide/composition.json +2 -2
- package/templates/kinetic-reel/composition.json +73 -0
- package/templates/kinetic-reel/composition.schema.json +9295 -0
- package/templates/kinetic-reel/template.json +17 -0
- package/templates/kinetic-reel/video.bundle.js +72 -0
- package/templates/kinetic-reel/video.tsx +283 -0
- package/templates/product-card/composition.json +7 -2
- package/templates/product-wide/composition.json +1 -1
- package/templates/quote-card/composition.json +6 -1
- package/templates/saas-launch/composition.json +70 -0
- package/templates/saas-launch/composition.schema.json +9295 -0
- package/templates/saas-launch/template.json +16 -0
- package/templates/saas-launch/video.bundle.js +72 -0
- package/templates/saas-launch/video.tsx +217 -0
- package/templates/sale-promo/composition.json +16 -1
- package/templates/showcase-tall/composition.json +4 -4
- package/templates/slideshow/composition.json +1 -1
- package/templates/social-reel/composition.json +19 -4
- package/templates/story-tall/composition.json +1 -1
- package/studio/assets/index-CzOvWv9w.css +0 -1
- package/studio/assets/index-J_fvKgng.js +0 -11
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// packages/core/src/index.ts
|
|
2
2
|
import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile5 } from "node:fs/promises";
|
|
3
|
-
import { dirname as dirname3, extname as extname3, join as
|
|
3
|
+
import { dirname as dirname3, extname as extname3, join as join10, resolve as resolve3 } from "node:path";
|
|
4
4
|
|
|
5
5
|
// packages/schema/src/index.ts
|
|
6
6
|
import { z as z3 } from "zod";
|
|
@@ -483,7 +483,35 @@ var captionLines = (cues, maxWords) => cues.flatMap((cue) => cue.words && cue.wo
|
|
|
483
483
|
|
|
484
484
|
// packages/schema/src/index.ts
|
|
485
485
|
var color = z3.string().regex(colorPattern);
|
|
486
|
-
var
|
|
486
|
+
var fontWeightMap = {
|
|
487
|
+
thin: 100,
|
|
488
|
+
hairline: 100,
|
|
489
|
+
extralight: 200,
|
|
490
|
+
ultralight: 200,
|
|
491
|
+
"extra-light": 200,
|
|
492
|
+
"ultra-light": 200,
|
|
493
|
+
light: 300,
|
|
494
|
+
regular: 400,
|
|
495
|
+
medium: 500,
|
|
496
|
+
semibold: 600,
|
|
497
|
+
demibold: 600,
|
|
498
|
+
"semi-bold": 600,
|
|
499
|
+
"demi-bold": 600,
|
|
500
|
+
extrabold: 800,
|
|
501
|
+
ultrabold: 800,
|
|
502
|
+
"extra-bold": 800,
|
|
503
|
+
"ultra-bold": 800,
|
|
504
|
+
black: 900,
|
|
505
|
+
heavy: 900
|
|
506
|
+
};
|
|
507
|
+
var fontWeight = z3.preprocess((val) => {
|
|
508
|
+
if (typeof val === "string") {
|
|
509
|
+
const s = val.trim().toLowerCase();
|
|
510
|
+
if (/^\d+$/.test(s)) return parseInt(s, 10);
|
|
511
|
+
if (s in fontWeightMap) return fontWeightMap[s];
|
|
512
|
+
}
|
|
513
|
+
return val;
|
|
514
|
+
}, z3.union([z3.number().int().min(1).max(1e3), z3.enum(["normal", "bold"])]));
|
|
487
515
|
var fontStyle = z3.enum(["normal", "italic"]);
|
|
488
516
|
var easingSchema = z3.union([z3.enum(["linear", "easeIn", "easeOut", "easeInOut", "easeInCubic", "easeOutCubic", "easeInOutCubic", "easeInQuart", "easeOutQuart", "easeInOutQuart", "easeInExpo", "easeOutExpo", "easeInOutExpo", "easeInBack", "easeOutBack", "spring", "hold", "land", "snap", "glide", "pop", "move", "exit", "drift"]).describe("land, snap, glide and pop for entries, move within the frame, exit for leaving, drift for slow continuous motion; or a standard curve"), z3.tuple([z3.number().min(0).max(1), z3.number(), z3.number().min(0).max(1), z3.number()]).describe("Cubic B\xE9zier control points [x1, y1, x2, y2], as in CSS")]);
|
|
489
517
|
var colorProperties = ["fill", "color", "stroke"];
|
|
@@ -837,7 +865,7 @@ var describeError = (error) => error instanceof z3.ZodError ? z3.prettifyError(e
|
|
|
837
865
|
// packages/core/src/media.ts
|
|
838
866
|
import { spawn as spawn2 } from "node:child_process";
|
|
839
867
|
import { mkdir, readFile as readFile2, stat as stat2, writeFile } from "node:fs/promises";
|
|
840
|
-
import { join as
|
|
868
|
+
import { join as join4 } from "node:path";
|
|
841
869
|
import { createHash } from "node:crypto";
|
|
842
870
|
|
|
843
871
|
// packages/animation/src/index.ts
|
|
@@ -1194,11 +1222,143 @@ async function toDataUrl(source) {
|
|
|
1194
1222
|
// packages/ffmpeg/src/index.ts
|
|
1195
1223
|
import { spawn } from "node:child_process";
|
|
1196
1224
|
import { once } from "node:events";
|
|
1225
|
+
import { join as join3 } from "node:path";
|
|
1226
|
+
|
|
1227
|
+
// packages/ffmpeg/src/auto-install.ts
|
|
1228
|
+
import { homedir } from "node:os";
|
|
1197
1229
|
import { join as join2 } from "node:path";
|
|
1230
|
+
import { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync, renameSync } from "node:fs";
|
|
1231
|
+
import { spawnSync } from "node:child_process";
|
|
1232
|
+
import { pipeline } from "node:stream/promises";
|
|
1233
|
+
import { Readable } from "node:stream";
|
|
1234
|
+
import { createGunzip } from "node:zlib";
|
|
1235
|
+
var isWin = process.platform === "win32";
|
|
1236
|
+
var binDir = join2(homedir(), ".motionrender", "bin");
|
|
1237
|
+
var localFfmpegPath = join2(binDir, isWin ? "ffmpeg.exe" : "ffmpeg");
|
|
1238
|
+
var localFfprobePath = join2(binDir, isWin ? "ffprobe.exe" : "ffprobe");
|
|
1239
|
+
var checkedSystemFfmpeg = false;
|
|
1240
|
+
var systemFfmpegAvailable = false;
|
|
1241
|
+
var checkedSystemFfprobe = false;
|
|
1242
|
+
var systemFfprobeAvailable = false;
|
|
1243
|
+
function hasSystemFfmpeg() {
|
|
1244
|
+
if (checkedSystemFfmpeg) return systemFfmpegAvailable;
|
|
1245
|
+
try {
|
|
1246
|
+
const res = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
|
1247
|
+
systemFfmpegAvailable = res.status === 0;
|
|
1248
|
+
} catch {
|
|
1249
|
+
systemFfmpegAvailable = false;
|
|
1250
|
+
}
|
|
1251
|
+
checkedSystemFfmpeg = true;
|
|
1252
|
+
return systemFfmpegAvailable;
|
|
1253
|
+
}
|
|
1254
|
+
function hasSystemFfprobe() {
|
|
1255
|
+
if (checkedSystemFfprobe) return systemFfprobeAvailable;
|
|
1256
|
+
try {
|
|
1257
|
+
const res = spawnSync("ffprobe", ["-version"], { stdio: "ignore" });
|
|
1258
|
+
systemFfprobeAvailable = res.status === 0;
|
|
1259
|
+
} catch {
|
|
1260
|
+
systemFfprobeAvailable = false;
|
|
1261
|
+
}
|
|
1262
|
+
checkedSystemFfprobe = true;
|
|
1263
|
+
return systemFfprobeAvailable;
|
|
1264
|
+
}
|
|
1265
|
+
var getFfmpegPath = () => {
|
|
1266
|
+
if (process.env.OVE_FFMPEG_PATH) return process.env.OVE_FFMPEG_PATH;
|
|
1267
|
+
if (existsSync(localFfmpegPath)) return localFfmpegPath;
|
|
1268
|
+
if (hasSystemFfmpeg()) return "ffmpeg";
|
|
1269
|
+
return localFfmpegPath;
|
|
1270
|
+
};
|
|
1271
|
+
var getFfprobePath = () => {
|
|
1272
|
+
if (process.env.OVE_FFPROBE_PATH) return process.env.OVE_FFPROBE_PATH;
|
|
1273
|
+
if (existsSync(localFfprobePath)) return localFfprobePath;
|
|
1274
|
+
if (hasSystemFfprobe()) return "ffprobe";
|
|
1275
|
+
return localFfprobePath;
|
|
1276
|
+
};
|
|
1277
|
+
var RELEASE_TAG = "b6.1.1";
|
|
1278
|
+
var BASE_URL = `https://github.com/eugeneware/ffmpeg-static/releases/download/${RELEASE_TAG}`;
|
|
1279
|
+
function getPlatformArch() {
|
|
1280
|
+
const p = process.platform;
|
|
1281
|
+
const a = process.arch;
|
|
1282
|
+
if (p === "win32") return { platform: "win32", arch: "x64" };
|
|
1283
|
+
if (p === "darwin") return { platform: "darwin", arch: a === "arm64" ? "arm64" : "x64" };
|
|
1284
|
+
if (p === "linux") return { platform: "linux", arch: a === "arm64" ? "arm64" : a === "arm" ? "arm" : "x64" };
|
|
1285
|
+
return { platform: p, arch: a };
|
|
1286
|
+
}
|
|
1287
|
+
async function downloadAndExtract(name2, destPath, onProgress) {
|
|
1288
|
+
const { platform, arch } = getPlatformArch();
|
|
1289
|
+
const url = `${BASE_URL}/${name2}-${platform}-${arch}.gz`;
|
|
1290
|
+
if (onProgress) onProgress(`Downloading ${name2} (${platform}-${arch})...`);
|
|
1291
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
1292
|
+
if (!res.ok || !res.body) {
|
|
1293
|
+
throw new Error(`Failed to download ${name2} from ${url} (HTTP ${res.status})`);
|
|
1294
|
+
}
|
|
1295
|
+
const tmpPath = `${destPath}.tmp-${Date.now()}`;
|
|
1296
|
+
try {
|
|
1297
|
+
const fileStream = createWriteStream(tmpPath);
|
|
1298
|
+
const nodeReadable = Readable.fromWeb(res.body);
|
|
1299
|
+
await pipeline(nodeReadable, createGunzip(), fileStream);
|
|
1300
|
+
if (!isWin) {
|
|
1301
|
+
chmodSync(tmpPath, 493);
|
|
1302
|
+
}
|
|
1303
|
+
if (existsSync(destPath)) {
|
|
1304
|
+
try {
|
|
1305
|
+
unlinkSync(destPath);
|
|
1306
|
+
} catch {
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
renameSync(tmpPath, destPath);
|
|
1310
|
+
} catch (err) {
|
|
1311
|
+
try {
|
|
1312
|
+
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
1313
|
+
} catch {
|
|
1314
|
+
}
|
|
1315
|
+
throw err;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
var ensureFfmpegPromise = null;
|
|
1319
|
+
async function ensureFfmpeg(options) {
|
|
1320
|
+
if (ensureFfmpegPromise) return ensureFfmpegPromise;
|
|
1321
|
+
ensureFfmpegPromise = (async () => {
|
|
1322
|
+
const ffmpegFromEnv = process.env.OVE_FFMPEG_PATH;
|
|
1323
|
+
const ffprobeFromEnv = process.env.OVE_FFPROBE_PATH;
|
|
1324
|
+
const hasLocalFfmpeg = existsSync(localFfmpegPath);
|
|
1325
|
+
const hasLocalFfprobe = existsSync(localFfprobePath);
|
|
1326
|
+
const hasSysFfmpeg = !ffmpegFromEnv && !hasLocalFfmpeg ? hasSystemFfmpeg() : false;
|
|
1327
|
+
const hasSysFfprobe = !ffprobeFromEnv && !hasLocalFfprobe ? hasSystemFfprobe() : false;
|
|
1328
|
+
const ffmpegReady = Boolean(ffmpegFromEnv || hasLocalFfmpeg || hasSysFfmpeg);
|
|
1329
|
+
const ffprobeReady = Boolean(ffprobeFromEnv || hasLocalFfprobe || hasSysFfprobe);
|
|
1330
|
+
if (ffmpegReady && ffprobeReady) {
|
|
1331
|
+
return { ffmpeg: getFfmpegPath(), ffprobe: getFfprobePath() };
|
|
1332
|
+
}
|
|
1333
|
+
if (!existsSync(binDir)) {
|
|
1334
|
+
mkdirSync(binDir, { recursive: true });
|
|
1335
|
+
}
|
|
1336
|
+
const log = options?.onProgress ?? ((msg) => console.log(msg));
|
|
1337
|
+
if (!ffmpegReady) {
|
|
1338
|
+
log("FFmpeg not found. Automatically downloading static build (one-time setup)...");
|
|
1339
|
+
await downloadAndExtract("ffmpeg", localFfmpegPath, log);
|
|
1340
|
+
log("\u2713 FFmpeg installed successfully.");
|
|
1341
|
+
}
|
|
1342
|
+
if (!ffprobeReady) {
|
|
1343
|
+
log("ffprobe not found. Automatically downloading static build (one-time setup)...");
|
|
1344
|
+
await downloadAndExtract("ffprobe", localFfprobePath, log);
|
|
1345
|
+
log("\u2713 ffprobe installed successfully.");
|
|
1346
|
+
}
|
|
1347
|
+
return { ffmpeg: getFfmpegPath(), ffprobe: getFfprobePath() };
|
|
1348
|
+
})();
|
|
1349
|
+
try {
|
|
1350
|
+
return await ensureFfmpegPromise;
|
|
1351
|
+
} catch (err) {
|
|
1352
|
+
ensureFfmpegPromise = null;
|
|
1353
|
+
throw err;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// packages/ffmpeg/src/index.ts
|
|
1198
1358
|
var x264Presets = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"];
|
|
1199
1359
|
var defaultPreset = "veryfast";
|
|
1200
|
-
var ffmpegPath = () =>
|
|
1201
|
-
var ffprobePath = () =>
|
|
1360
|
+
var ffmpegPath = () => getFfmpegPath();
|
|
1361
|
+
var ffprobePath = () => getFfprobePath();
|
|
1202
1362
|
var notFound = (tool, env) => new Error(`${tool} was not found. Install FFmpeg (it includes ${tool}) and put it on PATH, or set ${env} to the executable.`);
|
|
1203
1363
|
var num2 = (n) => {
|
|
1204
1364
|
const text = n.toFixed(5).replace(/\.?0+$/, "");
|
|
@@ -1308,12 +1468,14 @@ function concatSegments(segments, listFile, output, audio, options = {}) {
|
|
|
1308
1468
|
const list = segments.map((s) => `file '${s.replace(/\\/g, "/").replace(/'/g, "'\\''")}'`).join("\n");
|
|
1309
1469
|
return import("node:fs/promises").then((fs) => fs.writeFile(listFile, list)).then(() => run(["-y", "-f", "concat", "-safe", "0", "-i", listFile, ...audioCodec ? audioMixArgs(audio, audioCodec) : [], ...audio && audioCodec ? [] : ["-map", "0:v"], "-c:v", "copy", ...options.format === "mp4" || options.format === void 0 ? ["-movflags", "+faststart"] : [], output], "ignore").done);
|
|
1310
1470
|
}
|
|
1311
|
-
function extractFrames(source, outDir, options) {
|
|
1471
|
+
async function extractFrames(source, outDir, options) {
|
|
1472
|
+
await ensureFfmpeg();
|
|
1312
1473
|
const { fps, maxWidth, maxHeight, durationSeconds, startSeconds } = options;
|
|
1313
|
-
return run(["-v", "error", "-y", ...startSeconds ? ["-ss", String(startSeconds)] : [], ...durationSeconds === void 0 ? [] : ["-t", String(durationSeconds)], "-i", source, "-an", "-vf", `fps=${fps},scale=w='min(iw,${maxWidth})':h='min(ih,${maxHeight})':force_original_aspect_ratio=decrease`, "-q:v", "2",
|
|
1474
|
+
return run(["-v", "error", "-y", ...startSeconds ? ["-ss", String(startSeconds)] : [], ...durationSeconds === void 0 ? [] : ["-t", String(durationSeconds)], "-i", source, "-an", "-vf", `fps=${fps},scale=w='min(iw,${maxWidth})':h='min(ih,${maxHeight})':force_original_aspect_ratio=decrease`, "-q:v", "2", join3(outDir, "%06d.jpg")], "ignore").done;
|
|
1314
1475
|
}
|
|
1315
1476
|
var requiredEncoders = { mp4: ["libx264", "aac"], "mp4 (h265)": ["libx265"], webm: ["libvpx-vp9", "libopus"], mov: ["prores_ks", "pcm_s16le"], gif: ["gif"], "video layers": ["mjpeg"] };
|
|
1316
|
-
function checkFfmpeg() {
|
|
1477
|
+
async function checkFfmpeg() {
|
|
1478
|
+
await ensureFfmpeg();
|
|
1317
1479
|
return new Promise((resolve4, reject) => {
|
|
1318
1480
|
const child = spawn(ffmpegPath(), ["-hide_banner", "-encoders"]);
|
|
1319
1481
|
let out = "";
|
|
@@ -1330,7 +1492,8 @@ function checkFfmpeg() {
|
|
|
1330
1492
|
});
|
|
1331
1493
|
});
|
|
1332
1494
|
}
|
|
1333
|
-
function hasAudioStream(path) {
|
|
1495
|
+
async function hasAudioStream(path) {
|
|
1496
|
+
await ensureFfmpeg();
|
|
1334
1497
|
return new Promise((resolve4, reject) => {
|
|
1335
1498
|
const child = spawn(ffprobePath(), ["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", path]);
|
|
1336
1499
|
let out = "";
|
|
@@ -1339,7 +1502,8 @@ function hasAudioStream(path) {
|
|
|
1339
1502
|
child.on("close", (c) => c === 0 ? resolve4(out.trim().length > 0) : reject(new Error(`ffprobe could not read ${path}`)));
|
|
1340
1503
|
});
|
|
1341
1504
|
}
|
|
1342
|
-
function probe(path) {
|
|
1505
|
+
async function probe(path) {
|
|
1506
|
+
await ensureFfmpeg();
|
|
1343
1507
|
return new Promise((resolve4, reject) => {
|
|
1344
1508
|
const child = spawn(ffprobePath(), ["-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path]);
|
|
1345
1509
|
let out = "";
|
|
@@ -1363,7 +1527,7 @@ async function measureSource(source) {
|
|
|
1363
1527
|
const version = isUrl(source) ? "" : await stat2(source).then((s) => `${s.size}:${s.mtimeMs}`, () => {
|
|
1364
1528
|
throw new Error(`Media not found: ${source}`);
|
|
1365
1529
|
});
|
|
1366
|
-
const key = createHash("sha256").update(`${source}|${version}|v1`).digest("hex").slice(0, 32), file =
|
|
1530
|
+
const key = createHash("sha256").update(`${source}|${version}|v1`).digest("hex").slice(0, 32), file = join4(mediaCache(), `${key}.json`);
|
|
1367
1531
|
const cached2 = await readFile2(file, "utf8").then(JSON.parse, () => void 0);
|
|
1368
1532
|
if (cached2) {
|
|
1369
1533
|
void touchCacheEntry(file);
|
|
@@ -1438,7 +1602,7 @@ async function prepareComposition(raw, values = {}, baseDir = process.cwd()) {
|
|
|
1438
1602
|
|
|
1439
1603
|
// packages/renderer/src/index.ts
|
|
1440
1604
|
import { chromium } from "playwright";
|
|
1441
|
-
import { existsSync as
|
|
1605
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
1442
1606
|
import { readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
1443
1607
|
import { availableParallelism } from "node:os";
|
|
1444
1608
|
|
|
@@ -1530,18 +1694,18 @@ var prepareOperations = (operations, fps = 30) => {
|
|
|
1530
1694
|
|
|
1531
1695
|
// packages/renderer/src/video.ts
|
|
1532
1696
|
import { mkdir as mkdir2, readdir as readdir2, rename, rm as rm2, stat as stat3, writeFile as writeFile2 } from "node:fs/promises";
|
|
1533
|
-
import { join as
|
|
1697
|
+
import { join as join5 } from "node:path";
|
|
1534
1698
|
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
1535
|
-
var videoFrameFile = (video, index) =>
|
|
1699
|
+
var videoFrameFile = (video, index) => join5(video.dir, `${String(Math.max(0, index - video.first) + 1).padStart(6, "0")}.jpg`);
|
|
1536
1700
|
var videoCacheRoot = () => cacheDir("video");
|
|
1537
1701
|
var complete = ".complete";
|
|
1538
1702
|
async function decode(source, key, first = 0) {
|
|
1539
1703
|
const version = isUrl(source) ? "" : await stat3(source).then((s) => `${s.size}:${s.mtimeMs}`, () => {
|
|
1540
1704
|
throw new Error(`Video not found: ${source}`);
|
|
1541
1705
|
});
|
|
1542
|
-
const dir =
|
|
1706
|
+
const dir = join5(videoCacheRoot(), createHash2("sha256").update(JSON.stringify({ source, version, ...key })).digest("hex").slice(0, 32));
|
|
1543
1707
|
const count = async () => (await readdir2(dir)).filter((f) => f.endsWith(".jpg")).length;
|
|
1544
|
-
if (await stat3(
|
|
1708
|
+
if (await stat3(join5(dir, complete)).then(() => true, () => false)) {
|
|
1545
1709
|
void touchCacheEntry(dir);
|
|
1546
1710
|
return { dir, count: first + await count(), first };
|
|
1547
1711
|
}
|
|
@@ -1549,9 +1713,9 @@ async function decode(source, key, first = 0) {
|
|
|
1549
1713
|
await mkdir2(scratch, { recursive: true });
|
|
1550
1714
|
try {
|
|
1551
1715
|
await extractFrames(source, scratch, key);
|
|
1552
|
-
await writeFile2(
|
|
1716
|
+
await writeFile2(join5(scratch, complete), "");
|
|
1553
1717
|
await rename(scratch, dir).catch(async (error) => {
|
|
1554
|
-
if (!await stat3(
|
|
1718
|
+
if (!await stat3(join5(dir, complete)).then(() => true, () => false)) throw error;
|
|
1555
1719
|
});
|
|
1556
1720
|
} finally {
|
|
1557
1721
|
await rm2(scratch, { recursive: true, force: true });
|
|
@@ -1642,34 +1806,34 @@ async function createPagePool(open, discard, size, timeoutMs = defaultFrameTimeo
|
|
|
1642
1806
|
}
|
|
1643
1807
|
|
|
1644
1808
|
// packages/graphics/src/fonts.ts
|
|
1645
|
-
import { existsSync } from "node:fs";
|
|
1809
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1646
1810
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
1647
1811
|
import { createRequire } from "node:module";
|
|
1648
|
-
import { dirname, join as
|
|
1812
|
+
import { dirname, join as join6 } from "node:path";
|
|
1649
1813
|
import { fileURLToPath } from "node:url";
|
|
1650
1814
|
function builtinFontDir() {
|
|
1651
1815
|
const bundled = fileURLToPath(new URL("./fonts/", import.meta.url));
|
|
1652
|
-
if (
|
|
1653
|
-
return
|
|
1816
|
+
if (existsSync2(join6(bundled, builtinFontFaces[0].file))) return bundled;
|
|
1817
|
+
return join6(dirname(createRequire(import.meta.url).resolve("@fontsource-variable/inter/package.json")), "files");
|
|
1654
1818
|
}
|
|
1655
1819
|
var cached;
|
|
1656
|
-
var builtinFonts = () => cached ??= Promise.all(builtinFontFaces.map(async (face) => ({ ...face, src: `builtin:${face.file}`, url: `data:font/woff2;base64,${(await readFile3(
|
|
1820
|
+
var builtinFonts = () => cached ??= Promise.all(builtinFontFaces.map(async (face) => ({ ...face, src: `builtin:${face.file}`, url: `data:font/woff2;base64,${(await readFile3(join6(builtinFontDir(), face.file))).toString("base64")}` })));
|
|
1657
1821
|
|
|
1658
1822
|
// packages/graphics/src/runtime.ts
|
|
1659
|
-
import { existsSync as
|
|
1823
|
+
import { existsSync as existsSync3, readFileSync } from "node:fs";
|
|
1660
1824
|
import { createRequire as createRequire2 } from "node:module";
|
|
1661
|
-
import { join as
|
|
1825
|
+
import { join as join7 } from "node:path";
|
|
1662
1826
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1663
1827
|
var runtimeDir = () => {
|
|
1664
1828
|
const bundled = fileURLToPath2(new URL("./runtime/", import.meta.url));
|
|
1665
|
-
return
|
|
1829
|
+
return existsSync3(join7(bundled, "canvas-runtime.js")) ? bundled : fileURLToPath2(new URL("../runtime/", import.meta.url));
|
|
1666
1830
|
};
|
|
1667
|
-
var read = (name2) => readFileSync(
|
|
1831
|
+
var read = (name2) => readFileSync(join7(runtimeDir(), name2), "utf8");
|
|
1668
1832
|
var textLayoutSource = read("text-layout.js");
|
|
1669
1833
|
var lottieFile = "lottie_light_canvas.min.js";
|
|
1670
1834
|
var lottiePath = () => {
|
|
1671
|
-
const bundled =
|
|
1672
|
-
return
|
|
1835
|
+
const bundled = join7(runtimeDir(), lottieFile);
|
|
1836
|
+
return existsSync3(bundled) ? bundled : createRequire2(import.meta.url).resolve(`lottie-web/build/player/${lottieFile}`);
|
|
1673
1837
|
};
|
|
1674
1838
|
var lottieSource = readFileSync(lottiePath(), "utf8");
|
|
1675
1839
|
var canvasRuntimeSource = `${textLayoutSource}
|
|
@@ -1688,25 +1852,68 @@ for(let i=0;i<images.length;i++){const img=new Image();img.src=images[i];await i
|
|
|
1688
1852
|
var allLayers = (composition) => composition.scenes.flatMap((scene) => scene.layers);
|
|
1689
1853
|
var browserArgs = ["--use-angle=swiftshader", "--enable-unsafe-swiftshader", ...(process.env.OVE_CHROMIUM_ARGS ?? "").split(/\s+/).filter(Boolean)];
|
|
1690
1854
|
var browserInstallHint = `Install one with "ove browser install" (Playwright's Chromium, about 150 MB), install Google Chrome, or set OVE_CHROMIUM_EXECUTABLE to a Chromium-based browser.`;
|
|
1855
|
+
var ensureChromiumPromise = null;
|
|
1856
|
+
async function ensureChromium(options) {
|
|
1857
|
+
if (existsSync4(chromium.executablePath())) return;
|
|
1858
|
+
for (const channel of ["chrome", "msedge"]) {
|
|
1859
|
+
try {
|
|
1860
|
+
const b = await chromium.launch({ headless: true, channel, args: browserArgs });
|
|
1861
|
+
await b.close();
|
|
1862
|
+
return;
|
|
1863
|
+
} catch {
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
if (ensureChromiumPromise) return ensureChromiumPromise;
|
|
1867
|
+
ensureChromiumPromise = (async () => {
|
|
1868
|
+
const log = options?.onProgress ?? ((msg) => console.log(msg));
|
|
1869
|
+
log("No Chrome, Edge, or Chromium browser detected.");
|
|
1870
|
+
log("Downloading Playwright Chromium (one-time setup)...");
|
|
1871
|
+
const { createRequire: createRequire3 } = await import("node:module");
|
|
1872
|
+
const { dirname: dirname4, join: join11 } = await import("node:path");
|
|
1873
|
+
const { spawn: spawn4 } = await import("node:child_process");
|
|
1874
|
+
const req = createRequire3(import.meta.url);
|
|
1875
|
+
const cli = join11(dirname4(req.resolve("playwright/package.json")), "cli.js");
|
|
1876
|
+
await new Promise((resolve4, reject) => {
|
|
1877
|
+
const child = spawn4(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
|
|
1878
|
+
child.on("close", (code) => {
|
|
1879
|
+
if (code === 0) resolve4();
|
|
1880
|
+
else reject(new Error(`Chromium installation failed (exit code ${code})`));
|
|
1881
|
+
});
|
|
1882
|
+
child.on("error", reject);
|
|
1883
|
+
});
|
|
1884
|
+
log("\u2713 Playwright Chromium installed successfully.");
|
|
1885
|
+
})();
|
|
1886
|
+
try {
|
|
1887
|
+
await ensureChromiumPromise;
|
|
1888
|
+
} catch (err) {
|
|
1889
|
+
ensureChromiumPromise = null;
|
|
1890
|
+
throw err;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1691
1893
|
async function launchBrowser() {
|
|
1692
1894
|
const explicit = process.env.OVE_CHROMIUM_EXECUTABLE;
|
|
1693
1895
|
if (explicit) {
|
|
1694
|
-
if (!
|
|
1896
|
+
if (!existsSync4(explicit)) throw new Error(`OVE_CHROMIUM_EXECUTABLE points to ${explicit}, which does not exist.`);
|
|
1695
1897
|
return chromium.launch({ headless: true, executablePath: explicit, args: browserArgs });
|
|
1696
1898
|
}
|
|
1697
|
-
if (
|
|
1899
|
+
if (existsSync4(chromium.executablePath())) return chromium.launch({ headless: true, args: browserArgs });
|
|
1698
1900
|
for (const channel of ["chrome", "msedge"]) {
|
|
1699
1901
|
try {
|
|
1700
1902
|
return await chromium.launch({ headless: true, channel, args: browserArgs });
|
|
1701
1903
|
} catch {
|
|
1702
1904
|
}
|
|
1703
1905
|
}
|
|
1704
|
-
|
|
1906
|
+
try {
|
|
1907
|
+
await ensureChromium();
|
|
1908
|
+
return await chromium.launch({ headless: true, args: browserArgs });
|
|
1909
|
+
} catch (error) {
|
|
1910
|
+
throw new Error(`No Chromium-based browser was found and auto-install failed: ${error.message}. ${browserInstallHint}`);
|
|
1911
|
+
}
|
|
1705
1912
|
}
|
|
1706
1913
|
async function describeBrowser() {
|
|
1707
1914
|
const browser = await launchBrowser();
|
|
1708
1915
|
try {
|
|
1709
|
-
return `${browser.browserType().name()} ${browser.version()}${process.env.OVE_CHROMIUM_EXECUTABLE ? ` (OVE_CHROMIUM_EXECUTABLE=${process.env.OVE_CHROMIUM_EXECUTABLE})` :
|
|
1916
|
+
return `${browser.browserType().name()} ${browser.version()}${process.env.OVE_CHROMIUM_EXECUTABLE ? ` (OVE_CHROMIUM_EXECUTABLE=${process.env.OVE_CHROMIUM_EXECUTABLE})` : existsSync4(chromium.executablePath()) ? " (Playwright Chromium)" : " (installed Chrome/Edge)"}`;
|
|
1710
1917
|
} finally {
|
|
1711
1918
|
await browser.close();
|
|
1712
1919
|
}
|
|
@@ -1836,10 +2043,10 @@ async function* renderFrames(renderer, from, to, options = {}) {
|
|
|
1836
2043
|
import { stat as stat4 } from "node:fs/promises";
|
|
1837
2044
|
import { spawn as spawn3 } from "node:child_process";
|
|
1838
2045
|
import { mkdtemp, rm as rm3, mkdir as mkdir3, rename as rename2 } from "node:fs/promises";
|
|
1839
|
-
import { existsSync as
|
|
2046
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
1840
2047
|
import { createHash as createHash3 } from "node:crypto";
|
|
1841
2048
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
1842
|
-
import { join as
|
|
2049
|
+
import { join as join8 } from "node:path";
|
|
1843
2050
|
var runFfmpeg = (args) => new Promise((resolve4, reject) => {
|
|
1844
2051
|
const child = spawn3(ffmpegPath(), args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
1845
2052
|
let err = "";
|
|
@@ -1850,8 +2057,8 @@ var runFfmpeg = (args) => new Promise((resolve4, reject) => {
|
|
|
1850
2057
|
var sfxDir = () => cacheDir("sfx");
|
|
1851
2058
|
var making = /* @__PURE__ */ new Map();
|
|
1852
2059
|
function sfxFile(name2) {
|
|
1853
|
-
const file =
|
|
1854
|
-
if (
|
|
2060
|
+
const file = join8(sfxDir(), `${name2}-${createHash3("sha256").update(JSON.stringify(sfxArgs(name2, ""))).digest("hex").slice(0, 12)}.wav`);
|
|
2061
|
+
if (existsSync5(file)) {
|
|
1855
2062
|
void touchCacheEntry(file);
|
|
1856
2063
|
return Promise.resolve(file);
|
|
1857
2064
|
}
|
|
@@ -1905,7 +2112,7 @@ async function planAudio(composition, baseDir, probe2 = hasAudioStream) {
|
|
|
1905
2112
|
}
|
|
1906
2113
|
async function measureMix(mix) {
|
|
1907
2114
|
if (!mix.clips.length) return void 0;
|
|
1908
|
-
const dir = await mkdtemp(
|
|
2115
|
+
const dir = await mkdtemp(join8(tmpdir2(), "ove-loudness-")), wav = join8(dir, "mix.wav");
|
|
1909
2116
|
try {
|
|
1910
2117
|
const args = audioMixArgs(mix, ["-c:a", "pcm_s16le"]), video = args.indexOf("0:v");
|
|
1911
2118
|
if (video > 0) args.splice(video - 1, 2);
|
|
@@ -1932,6 +2139,16 @@ async function measureLoudness(composition, baseDir) {
|
|
|
1932
2139
|
var area = (a) => Math.max(0, a.x1 - a.x0) * Math.max(0, a.y1 - a.y0);
|
|
1933
2140
|
var overlap = (a, b) => Math.max(0, Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0)) * Math.max(0, Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0));
|
|
1934
2141
|
var seconds2 = (frame, fps) => `${(frame / fps).toFixed(1)} s`;
|
|
2142
|
+
function safeZoneBands(width, height, override) {
|
|
2143
|
+
const screen = width * 16 / 9, letterbox = height >= width ? Math.max(0, (screen - height) / 2) : 0;
|
|
2144
|
+
const inset = { ...height >= width ? { top: Math.max(0, 0.08 * screen - letterbox) / height, bottom: Math.max(0, 0.18 * screen - letterbox) / height, left: 0.02, right: 0.11 } : {}, ...override };
|
|
2145
|
+
const bands = [];
|
|
2146
|
+
if (inset.bottom) bands.push({ name: "bottom UI band", hint: `Reels and TikTok draw their controls over the bottom ${Math.round(inset.bottom * height)} px: keep text above y ${Math.round(height - inset.bottom * height)}`, x0: 0, y0: height - inset.bottom * height, x1: width, y1: height });
|
|
2147
|
+
if (inset.right) bands.push({ name: "right UI band", hint: `the action buttons cover the right ${Math.round(inset.right * width)} px: keep text left of x ${Math.round(width - inset.right * width)}`, x0: width - inset.right * width, y0: 0, x1: width, y1: height });
|
|
2148
|
+
if (inset.top) bands.push({ name: "top UI band", hint: `search and the camera cover the top ${Math.round(inset.top * height)} px: keep text below y ${Math.round(inset.top * height)}`, x0: 0, y0: 0, x1: width, y1: inset.top * height });
|
|
2149
|
+
if (inset.left) bands.push({ name: "left UI band", hint: `keep text right of x ${Math.round(inset.left * width)}`, x0: 0, y0: 0, x1: inset.left * width, y1: height });
|
|
2150
|
+
return bands;
|
|
2151
|
+
}
|
|
1935
2152
|
async function reviewComposition(composition, options = {}) {
|
|
1936
2153
|
const { fps, width, height, durationFrames } = composition, baseDir = options.baseDir ?? process.cwd();
|
|
1937
2154
|
const every = Math.max(1, Math.round(options.every ?? fps / 2)), frames = [];
|
|
@@ -1980,6 +2197,12 @@ async function reviewComposition(composition, options = {}) {
|
|
|
1980
2197
|
const other = report.texts.find((o) => o !== t && o.id !== t.id && visible(o) && overlap(t, o) > 0.15 * Math.min(area(t), area(o)) && (t.kind === "captions" || o.kind === "captions" || t.id < o.id));
|
|
1981
2198
|
return other ? t.kind === "captions" || other.kind === "captions" ? `"${(t.kind === "captions" ? other : t).text}" sits under the captions: keep content out of the captions band` : `"${t.text}" overlaps "${other.text}"` : void 0;
|
|
1982
2199
|
}, "error");
|
|
2200
|
+
const bands = safeZoneBands(width, height, options.safeZone);
|
|
2201
|
+
if (bands.length) persistent("safe-zone", hold, (t) => {
|
|
2202
|
+
if (!visible(t)) return;
|
|
2203
|
+
const band = bands.find((b) => overlap(t, b) > 0.25 * area(t));
|
|
2204
|
+
return band ? `"${t.text}" sits in the ${band.name}: ${band.hint}` : void 0;
|
|
2205
|
+
}, "warning");
|
|
1983
2206
|
const crowded = reports.map((r) => r.texts.filter((t) => visible(t) && t.kind === "text").reduce((n, t) => n + t.words, 0) > 40), busy = crowded.findIndex((_, i) => i + hold <= crowded.length && crowded.slice(i, i + hold).every(Boolean));
|
|
1984
2207
|
if (busy >= 0) issues.push({ severity: "warning", code: "busy", message: `More than 40 words on screen at ${seconds2(frames[busy], fps)}: split it across beats or cut words`, frame: frames[busy] });
|
|
1985
2208
|
const texts = reports.flatMap((r) => r.texts).filter((t) => visible(t) && t.kind === "text"), sizes = [...new Set(texts.map((t) => Math.round(t.baseSize)))].sort((a, b) => b - a), colours = [...new Set(texts.map((t) => t.color.toLowerCase()).filter((c) => c !== "gradient"))];
|
|
@@ -2078,7 +2301,7 @@ async function reviewComposition(composition, options = {}) {
|
|
|
2078
2301
|
// packages/core/src/distributed.ts
|
|
2079
2302
|
import { mkdtemp as mkdtemp2, rm as rm4, writeFile as writeFile4, mkdir as mkdir4 } from "node:fs/promises";
|
|
2080
2303
|
import { tmpdir as tmpdir3 } from "node:os";
|
|
2081
|
-
import { dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as
|
|
2304
|
+
import { dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join9, resolve as resolve2 } from "node:path";
|
|
2082
2305
|
import { timingSafeEqual } from "node:crypto";
|
|
2083
2306
|
function planChunks(first, last, chunkSize) {
|
|
2084
2307
|
if (!Number.isInteger(chunkSize) || chunkSize < 1) throw new Error("Chunk size must be a positive whole number of frames");
|
|
@@ -2102,7 +2325,7 @@ async function renderDistributed(composition, options) {
|
|
|
2102
2325
|
if (!Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last < first || last >= composition.durationFrames) throw new Error(`Frame range must be within 0\u2013${composition.durationFrames - 1}`);
|
|
2103
2326
|
const chunkSettings = { format, codec: settings.codec, transparent: settings.transparent, preset: options.preset ?? defaultPreset, crf: settings.crf, bitrate: settings.bitrate, scale: settings.scale };
|
|
2104
2327
|
const jobs = planChunks(first, last, options.chunkSize ?? Math.max(30, Math.ceil((last - first + 1) / (options.workers.length * 2)))), total = last - first + 1, retries = options.retries ?? 2;
|
|
2105
|
-
const work = await mkdtemp2(
|
|
2328
|
+
const work = await mkdtemp2(join9(tmpdir3(), "ove-chunks-")), segments = new Array(jobs.length), attempts = /* @__PURE__ */ new Map();
|
|
2106
2329
|
const abort = new AbortController();
|
|
2107
2330
|
const onOuterAbort = () => abort.abort(options.signal?.reason);
|
|
2108
2331
|
options.signal?.addEventListener("abort", onOuterAbort);
|
|
@@ -2116,7 +2339,7 @@ async function renderDistributed(composition, options) {
|
|
|
2116
2339
|
const job = queue.shift();
|
|
2117
2340
|
try {
|
|
2118
2341
|
const bytes = await worker.renderChunk(target, job, chunkSettings, abort.signal);
|
|
2119
|
-
const file =
|
|
2342
|
+
const file = join9(work, `chunk-${String(job.index).padStart(5, "0")}.${format}`);
|
|
2120
2343
|
await writeFile4(file, bytes);
|
|
2121
2344
|
segments[job.index] = file;
|
|
2122
2345
|
failures = 0;
|
|
@@ -2149,7 +2372,7 @@ async function renderDistributed(composition, options) {
|
|
|
2149
2372
|
if (segments.some((s) => !s)) throw new Error("Some chunks were not rendered");
|
|
2150
2373
|
await mkdir4(dirname2(output), { recursive: true });
|
|
2151
2374
|
const audio = await masterAudio(target, baseDir).then((mix) => mix && { ...mix, durationSeconds: total / composition.fps, offsetSeconds: first / composition.fps });
|
|
2152
|
-
await concatSegments(segments,
|
|
2375
|
+
await concatSegments(segments, join9(work, "segments.txt"), output, audio, { format, audioBitrate: settings.audioBitrate });
|
|
2153
2376
|
return { output, format, frames: total, durationSeconds: Number(await probe(output)) || total / composition.fps };
|
|
2154
2377
|
} finally {
|
|
2155
2378
|
options.signal?.removeEventListener("abort", onOuterAbort);
|
|
@@ -2182,6 +2405,7 @@ function checkRange(composition, range) {
|
|
|
2182
2405
|
return [first, last];
|
|
2183
2406
|
}
|
|
2184
2407
|
async function renderMedia(composition, options) {
|
|
2408
|
+
await ensureFfmpeg();
|
|
2185
2409
|
const baseDir = options.baseDir ?? process.cwd(), output = resolve3(options.output);
|
|
2186
2410
|
const format = options.format ?? formatForOutput(output, composition.export.format);
|
|
2187
2411
|
const settings = exportSchema.parse({ ...composition.export, ...Object.fromEntries(Object.entries({ crf: options.crf, bitrate: options.bitrate, audioBitrate: options.audioBitrate, scale: options.scale, loudness: options.loudness }).filter(([, v]) => v !== void 0)), format, codec: options.codec ?? (format === "mp4" ? composition.export.codec : "h264"), transparent: options.transparent ?? composition.export.transparent });
|
|
@@ -2200,7 +2424,7 @@ async function renderMedia(composition, options) {
|
|
|
2200
2424
|
for await (const png of renderFrames(renderer, first, last + 1)) {
|
|
2201
2425
|
throwIfAborted();
|
|
2202
2426
|
if (encoder) await encoder.write(png);
|
|
2203
|
-
else await writeFile5(
|
|
2427
|
+
else await writeFile5(join10(output, `frame-${String(frame).padStart(6, "0")}.png`), png);
|
|
2204
2428
|
frame++;
|
|
2205
2429
|
options.onProgress?.({ frame: frame - 1, rendered: frame - first, total });
|
|
2206
2430
|
}
|
|
@@ -2266,12 +2490,15 @@ export {
|
|
|
2266
2490
|
dropFillerWords,
|
|
2267
2491
|
duckSchema,
|
|
2268
2492
|
easingSchema,
|
|
2493
|
+
ensureChromium,
|
|
2494
|
+
ensureFfmpeg,
|
|
2269
2495
|
evaluateFrame,
|
|
2270
2496
|
expandMattes,
|
|
2271
2497
|
expandSceneGrades,
|
|
2272
2498
|
exportSchema,
|
|
2273
2499
|
ffmpegPath,
|
|
2274
2500
|
ffprobePath,
|
|
2501
|
+
fillers,
|
|
2275
2502
|
formatCaptions,
|
|
2276
2503
|
formatForOutput,
|
|
2277
2504
|
frameToSeconds,
|