@redwilly/anima 0.1.22 → 0.1.23
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/index.js +12 -4
- package/dist/index.d.ts +121 -23
- package/dist/index.js +392 -64
- package/package.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -65,6 +65,7 @@ async function listScenes(file) {
|
|
|
65
65
|
|
|
66
66
|
// src/cli/commands/render.ts
|
|
67
67
|
import { Renderer, Resolution } from "@redwilly/anima";
|
|
68
|
+
import { join, dirname } from "path";
|
|
68
69
|
async function render(file, options) {
|
|
69
70
|
const loader = new SceneLoader();
|
|
70
71
|
const { scenes, error } = await loader.load(file);
|
|
@@ -73,6 +74,7 @@ async function render(file, options) {
|
|
|
73
74
|
process.exit(1);
|
|
74
75
|
}
|
|
75
76
|
let scene;
|
|
77
|
+
let sceneName;
|
|
76
78
|
if (options.scene) {
|
|
77
79
|
const found = scenes.get(options.scene);
|
|
78
80
|
if (!found) {
|
|
@@ -84,9 +86,12 @@ async function render(file, options) {
|
|
|
84
86
|
process.exit(1);
|
|
85
87
|
}
|
|
86
88
|
scene = found;
|
|
89
|
+
sceneName = options.scene;
|
|
87
90
|
} else {
|
|
88
91
|
if (scenes.size === 1) {
|
|
89
|
-
|
|
92
|
+
const entry = scenes.entries().next().value;
|
|
93
|
+
sceneName = entry[0];
|
|
94
|
+
scene = entry[1];
|
|
90
95
|
} else {
|
|
91
96
|
console.error("Error: Multiple scenes found in file. Please specify one with --scene.");
|
|
92
97
|
for (const name of scenes.keys()) {
|
|
@@ -99,8 +104,10 @@ async function render(file, options) {
|
|
|
99
104
|
console.error("Error: No scene found to render.");
|
|
100
105
|
process.exit(1);
|
|
101
106
|
}
|
|
107
|
+
const format = options.format ?? "mp4";
|
|
108
|
+
const outputPath = options.output ?? join("media", `${sceneName}.${format}`);
|
|
109
|
+
const cacheDir = join(dirname(outputPath), ".anima-cache");
|
|
102
110
|
const renderer = new Renderer();
|
|
103
|
-
const outputPath = options.output ?? `output.${options.format ?? "mp4"}`;
|
|
104
111
|
let width = scene.getWidth();
|
|
105
112
|
let height = scene.getHeight();
|
|
106
113
|
if (options.resolution) {
|
|
@@ -113,13 +120,14 @@ async function render(file, options) {
|
|
|
113
120
|
console.warn(`Warning: Resolution preset '${options.resolution}' not found. Using scene defaults.`);
|
|
114
121
|
}
|
|
115
122
|
}
|
|
116
|
-
console.log(`Rendering scene to '${outputPath}'...`);
|
|
123
|
+
console.log(`Rendering scene '${sceneName}' to '${outputPath}'...`);
|
|
117
124
|
await renderer.render(scene, outputPath, {
|
|
118
125
|
width,
|
|
119
126
|
height,
|
|
120
127
|
frameRate: options.fps ? parseInt(options.fps, 10) : void 0,
|
|
121
|
-
format
|
|
128
|
+
format,
|
|
122
129
|
quality: options.quality,
|
|
130
|
+
cacheDir,
|
|
123
131
|
onProgress: (progress) => {
|
|
124
132
|
const percent = progress.percentage.toFixed(1);
|
|
125
133
|
const eta = (progress.estimatedRemainingMs / 1e3).toFixed(1);
|
package/dist/index.d.ts
CHANGED
|
@@ -358,6 +358,11 @@ declare class Mobject {
|
|
|
358
358
|
* ).fadeOut(1);
|
|
359
359
|
*/
|
|
360
360
|
parallel(...items: (Animation<Mobject> | Mobject)[]): this;
|
|
361
|
+
/**
|
|
362
|
+
* Computes a CRC32 hash of this mobject's full state.
|
|
363
|
+
* Used by the segment cache to detect changes.
|
|
364
|
+
*/
|
|
365
|
+
computeHash(): number;
|
|
361
366
|
}
|
|
362
367
|
|
|
363
368
|
/**
|
|
@@ -416,6 +421,12 @@ declare abstract class Animation<T extends Mobject = Mobject> {
|
|
|
416
421
|
*/
|
|
417
422
|
reset(): void;
|
|
418
423
|
update(progress: number): void;
|
|
424
|
+
/**
|
|
425
|
+
* Hashes the animation type, config, and target state.
|
|
426
|
+
* Subclass-specific behavior is captured through the target's hash,
|
|
427
|
+
* since animations mutate the target.
|
|
428
|
+
*/
|
|
429
|
+
computeHash(): number;
|
|
419
430
|
}
|
|
420
431
|
|
|
421
432
|
/**
|
|
@@ -916,6 +927,10 @@ declare class Camera {
|
|
|
916
927
|
*/
|
|
917
928
|
isInView(pos: Vector2): boolean;
|
|
918
929
|
reset(): this;
|
|
930
|
+
/**
|
|
931
|
+
* Hashes camera config and the CameraFrame's full transform state.
|
|
932
|
+
*/
|
|
933
|
+
computeHash(): number;
|
|
919
934
|
}
|
|
920
935
|
|
|
921
936
|
/**
|
|
@@ -932,6 +947,26 @@ interface SceneConfig {
|
|
|
932
947
|
readonly frameRate?: number;
|
|
933
948
|
}
|
|
934
949
|
|
|
950
|
+
/**
|
|
951
|
+
* A Segment represents one independent rendering unit,
|
|
952
|
+
* corresponding to a single play() or wait() call.
|
|
953
|
+
*
|
|
954
|
+
* Its hash is a holistic CRC32 composition of the camera state,
|
|
955
|
+
* all current mobjects, and the animations for this segment.
|
|
956
|
+
*/
|
|
957
|
+
interface Segment {
|
|
958
|
+
/** Zero-based index in the scene's segment list. */
|
|
959
|
+
readonly index: number;
|
|
960
|
+
/** Start time in seconds. */
|
|
961
|
+
readonly startTime: number;
|
|
962
|
+
/** End time in seconds. */
|
|
963
|
+
readonly endTime: number;
|
|
964
|
+
/** Animations scheduled in this segment (empty for wait segments). */
|
|
965
|
+
readonly animations: readonly Animation[];
|
|
966
|
+
/** CRC32 hash of camera + mobjects + animations at this point. */
|
|
967
|
+
readonly hash: number;
|
|
968
|
+
}
|
|
969
|
+
|
|
935
970
|
/**
|
|
936
971
|
* Scene is the core container that manages Mobjects and coordinates animations.
|
|
937
972
|
* It provides both a simple API for playing animations and access to the
|
|
@@ -942,6 +977,7 @@ declare class Scene {
|
|
|
942
977
|
private readonly mobjects;
|
|
943
978
|
private readonly timeline;
|
|
944
979
|
private readonly _camera;
|
|
980
|
+
private readonly segmentList;
|
|
945
981
|
private playheadTime;
|
|
946
982
|
constructor(config?: SceneConfig);
|
|
947
983
|
get camera(): Camera;
|
|
@@ -1019,6 +1055,11 @@ declare class Scene {
|
|
|
1019
1055
|
* Camera calculates Manim-compatible frame dimensions from pixel resolution.
|
|
1020
1056
|
*/
|
|
1021
1057
|
getCamera(): Camera;
|
|
1058
|
+
/**
|
|
1059
|
+
* Get the list of segments emitted by play() and wait() calls.
|
|
1060
|
+
* Used by the Renderer for cache-aware segmented rendering.
|
|
1061
|
+
*/
|
|
1062
|
+
getSegments(): readonly Segment[];
|
|
1022
1063
|
/**
|
|
1023
1064
|
* Validates and registers animation targets based on lifecycle.
|
|
1024
1065
|
* Handles composition animations (Sequence, Parallel) by processing children.
|
|
@@ -1039,6 +1080,11 @@ declare class Scene {
|
|
|
1039
1080
|
* Returns empty array for non-composition animations.
|
|
1040
1081
|
*/
|
|
1041
1082
|
private getAnimationChildren;
|
|
1083
|
+
/**
|
|
1084
|
+
* Computes a holistic CRC32 hash for a segment.
|
|
1085
|
+
* Includes camera state, all current mobjects, animations, and timing.
|
|
1086
|
+
*/
|
|
1087
|
+
private computeSegmentHash;
|
|
1042
1088
|
}
|
|
1043
1089
|
|
|
1044
1090
|
type PathCommandType = 'Move' | 'Line' | 'Quadratic' | 'Cubic' | 'Close';
|
|
@@ -1186,6 +1232,11 @@ declare class VMobject extends Mobject {
|
|
|
1186
1232
|
draw(durationSeconds?: number): this & {
|
|
1187
1233
|
toAnimation(): Animation<Mobject>;
|
|
1188
1234
|
};
|
|
1235
|
+
/**
|
|
1236
|
+
* Extends parent hash with VMobject-specific state:
|
|
1237
|
+
* stroke/fill colors, widths, opacity, and path geometry.
|
|
1238
|
+
*/
|
|
1239
|
+
computeHash(): number;
|
|
1189
1240
|
}
|
|
1190
1241
|
|
|
1191
1242
|
type CornerPosition = 'TOP_LEFT' | 'TOP_RIGHT' | 'BOTTOM_LEFT' | 'BOTTOM_RIGHT';
|
|
@@ -1239,6 +1290,11 @@ declare class VGroup extends VMobject {
|
|
|
1239
1290
|
toCorner(corner: CornerPosition, buff?: number): this;
|
|
1240
1291
|
arrange(direction?: Direction, buff?: number, shouldCenter?: boolean): this;
|
|
1241
1292
|
alignTo(target: VMobject, edge: Edge): this;
|
|
1293
|
+
/**
|
|
1294
|
+
* Recursively hashes this VGroup and all children.
|
|
1295
|
+
* Any child state change invalidates segments containing this group.
|
|
1296
|
+
*/
|
|
1297
|
+
computeHash(): number;
|
|
1242
1298
|
}
|
|
1243
1299
|
|
|
1244
1300
|
declare class Arc extends VMobject {
|
|
@@ -1260,13 +1316,9 @@ declare class Line extends VMobject {
|
|
|
1260
1316
|
private generatePath;
|
|
1261
1317
|
}
|
|
1262
1318
|
|
|
1263
|
-
declare class Point extends Circle {
|
|
1264
|
-
constructor(location?: Vector2);
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
1319
|
declare class Polygon extends VMobject {
|
|
1268
1320
|
readonly vertices: Vector2[];
|
|
1269
|
-
constructor(
|
|
1321
|
+
constructor(...points: Array<[number, number]>);
|
|
1270
1322
|
private generatePath;
|
|
1271
1323
|
}
|
|
1272
1324
|
|
|
@@ -1293,32 +1345,26 @@ declare class Glyph extends VMobject {
|
|
|
1293
1345
|
constructor(fontkitGlyph: Glyph$1, character: string, scale: number, offsetX: number, offsetY: number);
|
|
1294
1346
|
}
|
|
1295
1347
|
|
|
1296
|
-
/**
|
|
1297
|
-
* Options for configuring Text appearance.
|
|
1298
|
-
*/
|
|
1299
|
-
interface TextStyle {
|
|
1300
|
-
fontSize: number;
|
|
1301
|
-
color: Color;
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
1348
|
/**
|
|
1305
1349
|
* A VGroup of vectorized glyphs created from a text string using fontkit.
|
|
1306
1350
|
* Each character becomes a Glyph VMobject that can be individually animated.
|
|
1351
|
+
*
|
|
1352
|
+
* Uses VMobject's fill/stroke as the source of truth (same as geometry).
|
|
1353
|
+
* Default: white fill, white stroke width 2.
|
|
1307
1354
|
*/
|
|
1308
1355
|
declare class Text extends VGroup {
|
|
1309
1356
|
readonly text: string;
|
|
1310
|
-
private
|
|
1357
|
+
private fontSize;
|
|
1311
1358
|
private fontPath;
|
|
1312
|
-
constructor(text: string, fontPath?: string, options?:
|
|
1359
|
+
constructor(text: string, fontPath?: string, options?: {
|
|
1360
|
+
fontSize?: number;
|
|
1361
|
+
});
|
|
1313
1362
|
private buildGlyphs;
|
|
1314
|
-
|
|
1315
|
-
private
|
|
1316
|
-
setStyle(options: Partial<TextStyle>): this;
|
|
1317
|
-
getStyle(): TextStyle;
|
|
1318
|
-
/** Override stroke to propagate to all Glyph children. */
|
|
1363
|
+
/** Propagates this Text's fill/stroke to all Glyph children. */
|
|
1364
|
+
private propagate;
|
|
1319
1365
|
stroke(color: Color, width?: number): this;
|
|
1320
|
-
/** Override fill to propagate to all Glyph children. */
|
|
1321
1366
|
fill(color: Color, opacity?: number): this;
|
|
1367
|
+
getFontSize(): number;
|
|
1322
1368
|
getGlyph(index: number): Glyph | undefined;
|
|
1323
1369
|
}
|
|
1324
1370
|
|
|
@@ -1968,6 +2014,10 @@ interface RenderConfig {
|
|
|
1968
2014
|
quality?: RenderQuality;
|
|
1969
2015
|
/** Progress callback for render updates. */
|
|
1970
2016
|
onProgress?: ProgressCallback;
|
|
2017
|
+
/** Enable segment caching for incremental rendering. Default: true for video formats. */
|
|
2018
|
+
cache?: boolean;
|
|
2019
|
+
/** Custom cache directory path. Default: '.anima-cache' relative to output. */
|
|
2020
|
+
cacheDir?: string;
|
|
1971
2021
|
}
|
|
1972
2022
|
/**
|
|
1973
2023
|
* Progress information during rendering.
|
|
@@ -1991,12 +2041,17 @@ type ProgressCallback = (progress: RenderProgress) => void;
|
|
|
1991
2041
|
|
|
1992
2042
|
/**
|
|
1993
2043
|
* Main renderer for producing output from Scenes.
|
|
1994
|
-
* Supports multiple output formats
|
|
2044
|
+
* Supports multiple output formats, progress callbacks, and
|
|
2045
|
+
* segment-level caching for incremental re-renders.
|
|
1995
2046
|
*/
|
|
1996
2047
|
declare class Renderer {
|
|
1997
2048
|
/**
|
|
1998
2049
|
* Renders a scene to the specified output.
|
|
1999
2050
|
*
|
|
2051
|
+
* For video formats (mp4/webp/gif) with caching enabled, renders
|
|
2052
|
+
* each segment independently and concatenates the results.
|
|
2053
|
+
* Segments whose hash matches a cached file are skipped entirely.
|
|
2054
|
+
*
|
|
2000
2055
|
* @param scene The scene to render
|
|
2001
2056
|
* @param outputPath Output file or directory path (depends on format)
|
|
2002
2057
|
* @param config Optional render configuration
|
|
@@ -2022,6 +2077,20 @@ declare class Renderer {
|
|
|
2022
2077
|
* Renders a single frame (the last frame of the animation).
|
|
2023
2078
|
*/
|
|
2024
2079
|
private renderSingleFrame;
|
|
2080
|
+
/**
|
|
2081
|
+
* Cache-aware segmented rendering.
|
|
2082
|
+
*
|
|
2083
|
+
* For each segment:
|
|
2084
|
+
* 1. Check if a cached partial file exists (hash match)
|
|
2085
|
+
* 2. If miss, render that segment's frame range to a partial .mp4
|
|
2086
|
+
* 3. After all segments, concat partial files into final output
|
|
2087
|
+
* 4. Prune orphaned cache entries
|
|
2088
|
+
*/
|
|
2089
|
+
private renderSegmented;
|
|
2090
|
+
/**
|
|
2091
|
+
* Renders a single segment's frame range to a video file.
|
|
2092
|
+
*/
|
|
2093
|
+
private renderSegmentToFile;
|
|
2025
2094
|
}
|
|
2026
2095
|
|
|
2027
2096
|
/**
|
|
@@ -2079,4 +2148,33 @@ declare class ProgressReporter {
|
|
|
2079
2148
|
complete(): void;
|
|
2080
2149
|
}
|
|
2081
2150
|
|
|
2082
|
-
|
|
2151
|
+
/** Protocol for objects that contribute to segment hashing. */
|
|
2152
|
+
interface Hashable {
|
|
2153
|
+
computeHash(): number;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/**
|
|
2157
|
+
* Manages a disk-based cache of rendered segment partial files.
|
|
2158
|
+
*
|
|
2159
|
+
* Each segment is stored as a video file keyed by its CRC32 hash.
|
|
2160
|
+
* On re-render, segments whose hashes match an existing file are skipped.
|
|
2161
|
+
*/
|
|
2162
|
+
declare class SegmentCache {
|
|
2163
|
+
private readonly cacheDir;
|
|
2164
|
+
constructor(cacheDir: string);
|
|
2165
|
+
/** Ensure the cache directory exists. */
|
|
2166
|
+
init(): Promise<void>;
|
|
2167
|
+
/** Check if a rendered segment file exists for the given hash. */
|
|
2168
|
+
has(hash: number): boolean;
|
|
2169
|
+
/** Get the absolute file path for a segment hash. */
|
|
2170
|
+
getPath(hash: number): string;
|
|
2171
|
+
/** Get the cache directory path. */
|
|
2172
|
+
getDir(): string;
|
|
2173
|
+
/**
|
|
2174
|
+
* Remove cached files that are not in the active set.
|
|
2175
|
+
* Call after a full render to clean up stale segments.
|
|
2176
|
+
*/
|
|
2177
|
+
prune(activeHashes: Set<number>): Promise<void>;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
export { Animation, type AnimationConfig, Arc, Arrow, Camera, type CameraConfig, CameraFrame, Circle, Color, Draw, type EasingFunction, type EdgeConfig, FadeIn, FadeOut, Follow, FrameRenderer, Glyph, Graph, GraphEdge, GraphNode, type GraphNodeId, type Hashable, type Keyframe, KeyframeAnimation, KeyframeTrack, type LayoutConfig, type LayoutType, Line, Mobject, MorphTo, MoveTo, type NodeConfig, Parallel, Polygon, type ProgressCallback, ProgressReporter, Rectangle, type RenderConfig, type RenderFormat, type RenderProgress, type RenderQuality, Renderer, Resolution, type ResolvedCameraConfig, Rotate, Scale, Scene, type SceneConfig, type ScheduledAnimation, type Segment, SegmentCache, Sequence, Shake, Text, Timeline, type TimelineConfig, Unwrite, VGroup, VMobject, Vector2, Write, clearRegistry, smooth as defaultEasing, doubleSmooth, easeInBack, easeInBounce, easeInCirc, easeInCubic, easeInElastic, easeInExpo, easeInOutBack, easeInOutBounce, easeInOutCirc, easeInOutCubic, easeInOutElastic, easeInOutExpo, easeInOutQuad, easeInOutQuart, easeInOutQuint, easeInOutSine, easeInQuad, easeInQuart, easeInQuint, easeInSine, easeOutBack, easeOutBounce, easeOutCirc, easeOutCubic, easeOutElastic, easeOutExpo, easeOutQuad, easeOutQuart, easeOutQuint, easeOutSine, exponentialDecay, getEasing, hasEasing, linear, lingering, notQuiteThere, registerEasing, runningStart, rushFrom, rushInto, slowInto, smooth, thereAndBack, thereAndBackWithPause, unregisterEasing, wiggle };
|
package/dist/index.js
CHANGED
|
@@ -464,6 +464,44 @@ var Matrix3x3 = class _Matrix3x3 {
|
|
|
464
464
|
static IDENTITY = new _Matrix3x3(createIdentity());
|
|
465
465
|
};
|
|
466
466
|
|
|
467
|
+
// src/core/cache/Hashable.ts
|
|
468
|
+
var CRC32_TABLE = new Uint32Array(256);
|
|
469
|
+
for (let i = 0; i < 256; i++) {
|
|
470
|
+
let crc = i;
|
|
471
|
+
for (let j = 0; j < 8; j++) {
|
|
472
|
+
crc = crc & 1 ? crc >>> 1 ^ 3988292384 : crc >>> 1;
|
|
473
|
+
}
|
|
474
|
+
CRC32_TABLE[i] = crc;
|
|
475
|
+
}
|
|
476
|
+
function crc32(data) {
|
|
477
|
+
let crc = 4294967295;
|
|
478
|
+
for (let i = 0; i < data.length; i++) {
|
|
479
|
+
crc = crc >>> 8 ^ CRC32_TABLE[(crc ^ data[i]) & 255];
|
|
480
|
+
}
|
|
481
|
+
return (crc ^ 4294967295) >>> 0;
|
|
482
|
+
}
|
|
483
|
+
var FLOAT64_VIEW = new Float64Array(1);
|
|
484
|
+
var FLOAT64_BYTES = new Uint8Array(FLOAT64_VIEW.buffer);
|
|
485
|
+
function hashNumber(n) {
|
|
486
|
+
FLOAT64_VIEW[0] = n;
|
|
487
|
+
return crc32(FLOAT64_BYTES);
|
|
488
|
+
}
|
|
489
|
+
function hashString(s) {
|
|
490
|
+
const encoder = new TextEncoder();
|
|
491
|
+
return crc32(encoder.encode(s));
|
|
492
|
+
}
|
|
493
|
+
function hashFloat32Array(arr) {
|
|
494
|
+
return crc32(new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength));
|
|
495
|
+
}
|
|
496
|
+
function hashCompose(...hashes) {
|
|
497
|
+
const buf = new Uint8Array(hashes.length * 4);
|
|
498
|
+
const view = new DataView(buf.buffer);
|
|
499
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
500
|
+
view.setUint32(i * 4, hashes[i], false);
|
|
501
|
+
}
|
|
502
|
+
return crc32(buf);
|
|
503
|
+
}
|
|
504
|
+
|
|
467
505
|
// src/core/animations/easing/standard.ts
|
|
468
506
|
var linear = (t) => t;
|
|
469
507
|
var easeInQuad = (t) => t * t;
|
|
@@ -673,6 +711,20 @@ var Animation = class {
|
|
|
673
711
|
const easedProgress = this.easingFn(clampedProgress);
|
|
674
712
|
this.interpolate(easedProgress);
|
|
675
713
|
}
|
|
714
|
+
/**
|
|
715
|
+
* Hashes the animation type, config, and target state.
|
|
716
|
+
* Subclass-specific behavior is captured through the target's hash,
|
|
717
|
+
* since animations mutate the target.
|
|
718
|
+
*/
|
|
719
|
+
computeHash() {
|
|
720
|
+
return hashCompose(
|
|
721
|
+
hashString(this.constructor.name),
|
|
722
|
+
hashNumber(this.durationSeconds),
|
|
723
|
+
hashNumber(this.delaySeconds),
|
|
724
|
+
hashString(this.easingFn.name || "anonymous"),
|
|
725
|
+
this.target.computeHash()
|
|
726
|
+
);
|
|
727
|
+
}
|
|
676
728
|
};
|
|
677
729
|
|
|
678
730
|
// src/core/animations/categories/IntroductoryAnimation.ts
|
|
@@ -1280,6 +1332,16 @@ var Mobject = class {
|
|
|
1280
1332
|
this.getQueue().enqueueAnimation(new Parallel(animations));
|
|
1281
1333
|
return this;
|
|
1282
1334
|
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Computes a CRC32 hash of this mobject's full state.
|
|
1337
|
+
* Used by the segment cache to detect changes.
|
|
1338
|
+
*/
|
|
1339
|
+
computeHash() {
|
|
1340
|
+
return hashCompose(
|
|
1341
|
+
hashFloat32Array(this.localMatrix.values),
|
|
1342
|
+
hashNumber(this.opacityValue)
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1283
1345
|
};
|
|
1284
1346
|
|
|
1285
1347
|
// src/core/camera/types.ts
|
|
@@ -1734,6 +1796,16 @@ var Camera = class {
|
|
|
1734
1796
|
this.frame.setRotation(0);
|
|
1735
1797
|
return this;
|
|
1736
1798
|
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Hashes camera config and the CameraFrame's full transform state.
|
|
1801
|
+
*/
|
|
1802
|
+
computeHash() {
|
|
1803
|
+
return hashCompose(
|
|
1804
|
+
hashNumber(this.config.pixelWidth),
|
|
1805
|
+
hashNumber(this.config.pixelHeight),
|
|
1806
|
+
hashFloat32Array(this.frame.matrix.values)
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1737
1809
|
};
|
|
1738
1810
|
|
|
1739
1811
|
// src/core/errors/AnimationErrors.ts
|
|
@@ -1777,6 +1849,7 @@ var Scene = class {
|
|
|
1777
1849
|
mobjects = /* @__PURE__ */ new Set();
|
|
1778
1850
|
timeline;
|
|
1779
1851
|
_camera;
|
|
1852
|
+
segmentList = [];
|
|
1780
1853
|
playheadTime = 0;
|
|
1781
1854
|
constructor(config = {}) {
|
|
1782
1855
|
this.config = {
|
|
@@ -1894,7 +1967,15 @@ var Scene = class {
|
|
|
1894
1967
|
maxDuration = totalTime;
|
|
1895
1968
|
}
|
|
1896
1969
|
}
|
|
1897
|
-
this.playheadTime
|
|
1970
|
+
const endTime = this.playheadTime + maxDuration;
|
|
1971
|
+
this.segmentList.push({
|
|
1972
|
+
index: this.segmentList.length,
|
|
1973
|
+
startTime: this.playheadTime,
|
|
1974
|
+
endTime,
|
|
1975
|
+
animations,
|
|
1976
|
+
hash: this.computeSegmentHash(animations, this.playheadTime, endTime)
|
|
1977
|
+
});
|
|
1978
|
+
this.playheadTime = endTime;
|
|
1898
1979
|
return this;
|
|
1899
1980
|
}
|
|
1900
1981
|
/**
|
|
@@ -1905,7 +1986,15 @@ var Scene = class {
|
|
|
1905
1986
|
if (seconds < 0) {
|
|
1906
1987
|
throw new Error("Wait duration must be non-negative");
|
|
1907
1988
|
}
|
|
1908
|
-
this.playheadTime
|
|
1989
|
+
const endTime = this.playheadTime + seconds;
|
|
1990
|
+
this.segmentList.push({
|
|
1991
|
+
index: this.segmentList.length,
|
|
1992
|
+
startTime: this.playheadTime,
|
|
1993
|
+
endTime,
|
|
1994
|
+
animations: [],
|
|
1995
|
+
hash: this.computeSegmentHash([], this.playheadTime, endTime)
|
|
1996
|
+
});
|
|
1997
|
+
this.playheadTime = endTime;
|
|
1909
1998
|
return this;
|
|
1910
1999
|
}
|
|
1911
2000
|
/**
|
|
@@ -1935,6 +2024,13 @@ var Scene = class {
|
|
|
1935
2024
|
getCamera() {
|
|
1936
2025
|
return this._camera;
|
|
1937
2026
|
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Get the list of segments emitted by play() and wait() calls.
|
|
2029
|
+
* Used by the Renderer for cache-aware segmented rendering.
|
|
2030
|
+
*/
|
|
2031
|
+
getSegments() {
|
|
2032
|
+
return this.segmentList;
|
|
2033
|
+
}
|
|
1938
2034
|
// ========== Private Helpers ==========
|
|
1939
2035
|
/**
|
|
1940
2036
|
* Validates and registers animation targets based on lifecycle.
|
|
@@ -1998,6 +2094,22 @@ var Scene = class {
|
|
|
1998
2094
|
}
|
|
1999
2095
|
return [];
|
|
2000
2096
|
}
|
|
2097
|
+
/**
|
|
2098
|
+
* Computes a holistic CRC32 hash for a segment.
|
|
2099
|
+
* Includes camera state, all current mobjects, animations, and timing.
|
|
2100
|
+
*/
|
|
2101
|
+
computeSegmentHash(animations, startTime, endTime) {
|
|
2102
|
+
const cameraHash = this._camera.computeHash();
|
|
2103
|
+
const mobjectHashes = [...this.mobjects].map((m) => m.computeHash());
|
|
2104
|
+
const animHashes = animations.map((a) => a.computeHash());
|
|
2105
|
+
return hashCompose(
|
|
2106
|
+
cameraHash,
|
|
2107
|
+
...mobjectHashes,
|
|
2108
|
+
...animHashes,
|
|
2109
|
+
hashNumber(startTime),
|
|
2110
|
+
hashNumber(endTime)
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2001
2113
|
};
|
|
2002
2114
|
|
|
2003
2115
|
// src/core/math/bezier/evaluators.ts
|
|
@@ -3159,6 +3271,34 @@ var VMobject = class extends Mobject {
|
|
|
3159
3271
|
this.getQueue().enqueueAnimation(animation);
|
|
3160
3272
|
return this;
|
|
3161
3273
|
}
|
|
3274
|
+
/**
|
|
3275
|
+
* Extends parent hash with VMobject-specific state:
|
|
3276
|
+
* stroke/fill colors, widths, opacity, and path geometry.
|
|
3277
|
+
*/
|
|
3278
|
+
computeHash() {
|
|
3279
|
+
const parentHash = super.computeHash();
|
|
3280
|
+
const pathHash = hashString(
|
|
3281
|
+
this.pathList.map(
|
|
3282
|
+
(p) => p.getCommands().map(
|
|
3283
|
+
(c) => `${c.type}:${c.end.x},${c.end.y}` + (c.control1 ? `:${c.control1.x},${c.control1.y}` : "") + (c.control2 ? `:${c.control2.x},${c.control2.y}` : "")
|
|
3284
|
+
).join("|")
|
|
3285
|
+
).join("||")
|
|
3286
|
+
);
|
|
3287
|
+
return hashCompose(
|
|
3288
|
+
parentHash,
|
|
3289
|
+
hashNumber(this.strokeColor.r),
|
|
3290
|
+
hashNumber(this.strokeColor.g),
|
|
3291
|
+
hashNumber(this.strokeColor.b),
|
|
3292
|
+
hashNumber(this.strokeColor.a),
|
|
3293
|
+
hashNumber(this.strokeWidth),
|
|
3294
|
+
hashNumber(this.fillColor.r),
|
|
3295
|
+
hashNumber(this.fillColor.g),
|
|
3296
|
+
hashNumber(this.fillColor.b),
|
|
3297
|
+
hashNumber(this.fillColor.a),
|
|
3298
|
+
hashNumber(this.fillOpacity),
|
|
3299
|
+
pathHash
|
|
3300
|
+
);
|
|
3301
|
+
}
|
|
3162
3302
|
};
|
|
3163
3303
|
|
|
3164
3304
|
// src/mobjects/VGroup/layout.ts
|
|
@@ -3412,6 +3552,14 @@ var VGroup = class extends VMobject {
|
|
|
3412
3552
|
alignToTarget(this, target, edge);
|
|
3413
3553
|
return this;
|
|
3414
3554
|
}
|
|
3555
|
+
/**
|
|
3556
|
+
* Recursively hashes this VGroup and all children.
|
|
3557
|
+
* Any child state change invalidates segments containing this group.
|
|
3558
|
+
*/
|
|
3559
|
+
computeHash() {
|
|
3560
|
+
const childHashes = this.children.map((c) => c.computeHash());
|
|
3561
|
+
return hashCompose(super.computeHash(), ...childHashes);
|
|
3562
|
+
}
|
|
3415
3563
|
};
|
|
3416
3564
|
|
|
3417
3565
|
// src/mobjects/geometry/Arc.ts
|
|
@@ -3486,21 +3634,12 @@ var Line = class extends VMobject {
|
|
|
3486
3634
|
}
|
|
3487
3635
|
};
|
|
3488
3636
|
|
|
3489
|
-
// src/mobjects/geometry/Point.ts
|
|
3490
|
-
var Point = class extends Circle {
|
|
3491
|
-
constructor(location = Vector2.ZERO) {
|
|
3492
|
-
super(0.05);
|
|
3493
|
-
this.fill(Color.WHITE, 1);
|
|
3494
|
-
this.stroke(Color.WHITE, 0);
|
|
3495
|
-
this.pos(location.x, location.y);
|
|
3496
|
-
}
|
|
3497
|
-
};
|
|
3498
|
-
|
|
3499
3637
|
// src/mobjects/geometry/Polygon.ts
|
|
3500
3638
|
var Polygon = class extends VMobject {
|
|
3501
|
-
|
|
3639
|
+
vertices;
|
|
3640
|
+
constructor(...points) {
|
|
3502
3641
|
super();
|
|
3503
|
-
this.vertices =
|
|
3642
|
+
this.vertices = points.map(([x, y]) => new Vector2(x, y));
|
|
3504
3643
|
this.generatePath();
|
|
3505
3644
|
}
|
|
3506
3645
|
generatePath() {
|
|
@@ -3520,16 +3659,16 @@ var Rectangle = class extends Polygon {
|
|
|
3520
3659
|
constructor(width = 2, height = 1) {
|
|
3521
3660
|
const halfW = width / 2;
|
|
3522
3661
|
const halfH = height / 2;
|
|
3523
|
-
super(
|
|
3524
|
-
|
|
3662
|
+
super(
|
|
3663
|
+
[-halfW, -halfH],
|
|
3525
3664
|
// Top-Left
|
|
3526
|
-
|
|
3665
|
+
[halfW, -halfH],
|
|
3527
3666
|
// Top-Right
|
|
3528
|
-
|
|
3667
|
+
[halfW, halfH],
|
|
3529
3668
|
// Bottom-Right
|
|
3530
|
-
|
|
3669
|
+
[-halfW, halfH]
|
|
3531
3670
|
// Bottom-Left
|
|
3532
|
-
|
|
3671
|
+
);
|
|
3533
3672
|
this.width = width;
|
|
3534
3673
|
this.height = height;
|
|
3535
3674
|
}
|
|
@@ -3558,10 +3697,13 @@ var Arrow = class extends Line {
|
|
|
3558
3697
|
tipPath.lineTo(p2);
|
|
3559
3698
|
tipPath.closePath();
|
|
3560
3699
|
this.addPath(tipPath);
|
|
3561
|
-
this.fillOpacity = 1;
|
|
3562
3700
|
}
|
|
3563
3701
|
};
|
|
3564
3702
|
|
|
3703
|
+
// src/mobjects/text/Text.ts
|
|
3704
|
+
import * as fontkit from "fontkit";
|
|
3705
|
+
import { join, resolve } from "path";
|
|
3706
|
+
|
|
3565
3707
|
// src/mobjects/text/Glyph.ts
|
|
3566
3708
|
function convertFontkitPath(fontkitPath, scale, offsetX, offsetY) {
|
|
3567
3709
|
const path = new BezierPath();
|
|
@@ -3619,30 +3761,20 @@ var Glyph = class extends VMobject {
|
|
|
3619
3761
|
}
|
|
3620
3762
|
};
|
|
3621
3763
|
|
|
3622
|
-
// src/mobjects/text/Text.ts
|
|
3623
|
-
import * as fontkit from "fontkit";
|
|
3624
|
-
import { join, resolve } from "path";
|
|
3625
|
-
|
|
3626
|
-
// src/mobjects/text/types.ts
|
|
3627
|
-
var DEFAULT_TEXT_STYLE = {
|
|
3628
|
-
fontSize: 1,
|
|
3629
|
-
color: Color.WHITE
|
|
3630
|
-
};
|
|
3631
|
-
|
|
3632
3764
|
// src/mobjects/text/Text.ts
|
|
3633
3765
|
var DEFAULT_FONT_PATH = resolve(join(__dirname, "..", "..", "fonts", "ComicSansMS3.ttf"));
|
|
3634
3766
|
var Text = class extends VGroup {
|
|
3635
3767
|
text;
|
|
3636
|
-
|
|
3768
|
+
fontSize;
|
|
3637
3769
|
fontPath;
|
|
3638
3770
|
constructor(text, fontPath, options = {}) {
|
|
3639
3771
|
super();
|
|
3640
3772
|
this.text = text;
|
|
3641
3773
|
this.fontPath = fontPath ?? DEFAULT_FONT_PATH;
|
|
3642
|
-
this.
|
|
3774
|
+
this.fontSize = options.fontSize ?? 1;
|
|
3643
3775
|
this.buildGlyphs();
|
|
3644
3776
|
centerGroup(this);
|
|
3645
|
-
this.
|
|
3777
|
+
this.propagate();
|
|
3646
3778
|
}
|
|
3647
3779
|
buildGlyphs() {
|
|
3648
3780
|
const fontOrCollection = fontkit.openSync(this.fontPath);
|
|
@@ -3651,26 +3783,23 @@ var Text = class extends VGroup {
|
|
|
3651
3783
|
throw new Error(`Could not load font from ${this.fontPath}`);
|
|
3652
3784
|
}
|
|
3653
3785
|
const run = font.layout(this.text);
|
|
3654
|
-
const scale = this.
|
|
3786
|
+
const scale = this.fontSize / font.unitsPerEm;
|
|
3655
3787
|
let penX = 0;
|
|
3656
3788
|
let penY = 0;
|
|
3657
3789
|
for (let i = 0; i < run.glyphs.length; i++) {
|
|
3658
3790
|
const glyph = run.glyphs[i];
|
|
3659
3791
|
const pos = run.positions[i];
|
|
3660
3792
|
if (glyph === void 0 || pos === void 0) continue;
|
|
3661
|
-
const char = this.
|
|
3793
|
+
const char = this.text.charAt(i) || "";
|
|
3662
3794
|
const glyphX = penX + pos.xOffset * scale;
|
|
3663
3795
|
const glyphY = penY + pos.yOffset * scale;
|
|
3664
|
-
|
|
3665
|
-
this.add(glyphMobject);
|
|
3796
|
+
this.add(new Glyph(glyph, char, scale, glyphX, glyphY));
|
|
3666
3797
|
penX += pos.xAdvance * scale;
|
|
3667
3798
|
penY += pos.yAdvance * scale;
|
|
3668
3799
|
}
|
|
3669
3800
|
}
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
}
|
|
3673
|
-
applyStyle() {
|
|
3801
|
+
/** Propagates this Text's fill/stroke to all Glyph children. */
|
|
3802
|
+
propagate() {
|
|
3674
3803
|
for (const child of this.getChildren()) {
|
|
3675
3804
|
if (child instanceof Glyph) {
|
|
3676
3805
|
child.stroke(this.getStrokeColor(), this.getStrokeWidth());
|
|
@@ -3678,26 +3807,19 @@ var Text = class extends VGroup {
|
|
|
3678
3807
|
}
|
|
3679
3808
|
}
|
|
3680
3809
|
}
|
|
3681
|
-
setStyle(options) {
|
|
3682
|
-
this.style = { ...this.style, ...options };
|
|
3683
|
-
this.applyStyle();
|
|
3684
|
-
return this;
|
|
3685
|
-
}
|
|
3686
|
-
getStyle() {
|
|
3687
|
-
return { ...this.style };
|
|
3688
|
-
}
|
|
3689
|
-
/** Override stroke to propagate to all Glyph children. */
|
|
3690
3810
|
stroke(color, width = 2) {
|
|
3691
3811
|
super.stroke(color, width);
|
|
3692
|
-
this.
|
|
3812
|
+
this.propagate();
|
|
3693
3813
|
return this;
|
|
3694
3814
|
}
|
|
3695
|
-
/** Override fill to propagate to all Glyph children. */
|
|
3696
3815
|
fill(color, opacity) {
|
|
3697
3816
|
super.fill(color, opacity);
|
|
3698
|
-
this.
|
|
3817
|
+
this.propagate();
|
|
3699
3818
|
return this;
|
|
3700
3819
|
}
|
|
3820
|
+
getFontSize() {
|
|
3821
|
+
return this.fontSize;
|
|
3822
|
+
}
|
|
3701
3823
|
getGlyph(index) {
|
|
3702
3824
|
const child = this.get(index);
|
|
3703
3825
|
return child instanceof Glyph ? child : void 0;
|
|
@@ -4671,13 +4793,104 @@ async function renderVideo(frameRenderer, outputPath, format, frameRate, totalDu
|
|
|
4671
4793
|
progressReporter.complete();
|
|
4672
4794
|
}
|
|
4673
4795
|
|
|
4796
|
+
// src/core/renderer/formats/concat.ts
|
|
4797
|
+
import { dirname, join as join3, resolve as resolve2 } from "path";
|
|
4798
|
+
async function concatSegments(segmentPaths, outputPath) {
|
|
4799
|
+
if (segmentPaths.length === 0) {
|
|
4800
|
+
throw new Error("No segments to concatenate");
|
|
4801
|
+
}
|
|
4802
|
+
if (segmentPaths.length === 1) {
|
|
4803
|
+
const src = Bun.file(segmentPaths[0]);
|
|
4804
|
+
await Bun.write(outputPath, src);
|
|
4805
|
+
return;
|
|
4806
|
+
}
|
|
4807
|
+
const listContent = segmentPaths.map((p) => `file '${resolve2(p).replace(/\\/g, "/")}'`).join("\n");
|
|
4808
|
+
const listPath = join3(dirname(outputPath), ".concat_list.txt");
|
|
4809
|
+
await Bun.write(listPath, listContent);
|
|
4810
|
+
try {
|
|
4811
|
+
const process = Bun.spawn([
|
|
4812
|
+
"ffmpeg",
|
|
4813
|
+
"-y",
|
|
4814
|
+
"-f",
|
|
4815
|
+
"concat",
|
|
4816
|
+
"-safe",
|
|
4817
|
+
"0",
|
|
4818
|
+
"-i",
|
|
4819
|
+
listPath,
|
|
4820
|
+
"-c",
|
|
4821
|
+
"copy",
|
|
4822
|
+
outputPath
|
|
4823
|
+
]);
|
|
4824
|
+
const status = await process.exited;
|
|
4825
|
+
if (status !== 0) {
|
|
4826
|
+
throw new Error(`FFmpeg concat exited with code ${status}`);
|
|
4827
|
+
}
|
|
4828
|
+
} finally {
|
|
4829
|
+
try {
|
|
4830
|
+
const { unlink: unlink2 } = await import("fs/promises");
|
|
4831
|
+
await unlink2(listPath);
|
|
4832
|
+
} catch {
|
|
4833
|
+
}
|
|
4834
|
+
}
|
|
4835
|
+
}
|
|
4836
|
+
|
|
4837
|
+
// src/core/cache/SegmentCache.ts
|
|
4838
|
+
import { existsSync } from "fs";
|
|
4839
|
+
import { mkdir, readdir, unlink } from "fs/promises";
|
|
4840
|
+
import { join as join4 } from "path";
|
|
4841
|
+
var SegmentCache = class {
|
|
4842
|
+
cacheDir;
|
|
4843
|
+
constructor(cacheDir) {
|
|
4844
|
+
this.cacheDir = cacheDir;
|
|
4845
|
+
}
|
|
4846
|
+
/** Ensure the cache directory exists. */
|
|
4847
|
+
async init() {
|
|
4848
|
+
await mkdir(this.cacheDir, { recursive: true });
|
|
4849
|
+
}
|
|
4850
|
+
/** Check if a rendered segment file exists for the given hash. */
|
|
4851
|
+
has(hash) {
|
|
4852
|
+
return existsSync(this.getPath(hash));
|
|
4853
|
+
}
|
|
4854
|
+
/** Get the absolute file path for a segment hash. */
|
|
4855
|
+
getPath(hash) {
|
|
4856
|
+
const name = `segment_${hash.toString(16).padStart(8, "0")}.mp4`;
|
|
4857
|
+
return join4(this.cacheDir, name);
|
|
4858
|
+
}
|
|
4859
|
+
/** Get the cache directory path. */
|
|
4860
|
+
getDir() {
|
|
4861
|
+
return this.cacheDir;
|
|
4862
|
+
}
|
|
4863
|
+
/**
|
|
4864
|
+
* Remove cached files that are not in the active set.
|
|
4865
|
+
* Call after a full render to clean up stale segments.
|
|
4866
|
+
*/
|
|
4867
|
+
async prune(activeHashes) {
|
|
4868
|
+
if (!existsSync(this.cacheDir)) return;
|
|
4869
|
+
const entries = await readdir(this.cacheDir);
|
|
4870
|
+
const removals = [];
|
|
4871
|
+
for (const entry of entries) {
|
|
4872
|
+
if (!entry.startsWith("segment_") || !entry.endsWith(".mp4")) continue;
|
|
4873
|
+
const hexStr = entry.slice(8, 16);
|
|
4874
|
+
const hash = parseInt(hexStr, 16);
|
|
4875
|
+
if (!activeHashes.has(hash)) {
|
|
4876
|
+
removals.push(unlink(join4(this.cacheDir, entry)));
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4879
|
+
await Promise.all(removals);
|
|
4880
|
+
}
|
|
4881
|
+
};
|
|
4882
|
+
|
|
4674
4883
|
// src/core/renderer/Renderer.ts
|
|
4675
|
-
import { mkdir } from "fs/promises";
|
|
4676
|
-
import { dirname } from "path";
|
|
4884
|
+
import { mkdir as mkdir2 } from "fs/promises";
|
|
4885
|
+
import { dirname as dirname2, join as join5 } from "path";
|
|
4677
4886
|
var Renderer = class {
|
|
4678
4887
|
/**
|
|
4679
4888
|
* Renders a scene to the specified output.
|
|
4680
4889
|
*
|
|
4890
|
+
* For video formats (mp4/webp/gif) with caching enabled, renders
|
|
4891
|
+
* each segment independently and concatenates the results.
|
|
4892
|
+
* Segments whose hash matches a cached file are skipped entirely.
|
|
4893
|
+
*
|
|
4681
4894
|
* @param scene The scene to render
|
|
4682
4895
|
* @param outputPath Output file or directory path (depends on format)
|
|
4683
4896
|
* @param config Optional render configuration
|
|
@@ -4690,6 +4903,20 @@ var Renderer = class {
|
|
|
4690
4903
|
const frameRenderer = new FrameRenderer(scene, width, height);
|
|
4691
4904
|
const totalFrames = Math.max(1, Math.floor(totalDuration * resolved.frameRate) + 1);
|
|
4692
4905
|
const progressReporter = new ProgressReporter(totalFrames, resolved.onProgress);
|
|
4906
|
+
const segments = scene.getSegments();
|
|
4907
|
+
const isVideoFormat = resolved.format === "mp4" || resolved.format === "webp" || resolved.format === "gif";
|
|
4908
|
+
const useCache = resolved.cache && isVideoFormat && segments.length > 0;
|
|
4909
|
+
if (useCache) {
|
|
4910
|
+
await this.renderSegmented(
|
|
4911
|
+
scene,
|
|
4912
|
+
frameRenderer,
|
|
4913
|
+
outputPath,
|
|
4914
|
+
resolved,
|
|
4915
|
+
segments,
|
|
4916
|
+
progressReporter
|
|
4917
|
+
);
|
|
4918
|
+
return;
|
|
4919
|
+
}
|
|
4693
4920
|
switch (resolved.format) {
|
|
4694
4921
|
case "sprite":
|
|
4695
4922
|
await this.ensureDirectory(outputPath);
|
|
@@ -4702,13 +4929,13 @@ var Renderer = class {
|
|
|
4702
4929
|
);
|
|
4703
4930
|
break;
|
|
4704
4931
|
case "png":
|
|
4705
|
-
await this.ensureDirectory(
|
|
4932
|
+
await this.ensureDirectory(dirname2(outputPath));
|
|
4706
4933
|
await this.renderSingleFrame(frameRenderer, outputPath, totalDuration, progressReporter);
|
|
4707
4934
|
break;
|
|
4708
4935
|
case "mp4":
|
|
4709
4936
|
case "webp":
|
|
4710
4937
|
case "gif":
|
|
4711
|
-
await this.ensureDirectory(
|
|
4938
|
+
await this.ensureDirectory(dirname2(outputPath));
|
|
4712
4939
|
await renderVideo(
|
|
4713
4940
|
frameRenderer,
|
|
4714
4941
|
outputPath,
|
|
@@ -4736,7 +4963,7 @@ var Renderer = class {
|
|
|
4736
4963
|
const height = resolved.quality === "preview" ? Math.floor(resolved.height / 2) : resolved.height;
|
|
4737
4964
|
const frameRenderer = new FrameRenderer(scene, width, height);
|
|
4738
4965
|
const progressReporter = new ProgressReporter(1, resolved.onProgress);
|
|
4739
|
-
await this.ensureDirectory(
|
|
4966
|
+
await this.ensureDirectory(dirname2(outputPath));
|
|
4740
4967
|
const canvas = frameRenderer.renderFrame(totalDuration);
|
|
4741
4968
|
await writePng(canvas, outputPath);
|
|
4742
4969
|
progressReporter.complete();
|
|
@@ -4745,21 +4972,25 @@ var Renderer = class {
|
|
|
4745
4972
|
* Resolves partial config with scene defaults.
|
|
4746
4973
|
*/
|
|
4747
4974
|
resolveConfig(scene, config) {
|
|
4975
|
+
const format = config.format ?? "sprite";
|
|
4976
|
+
const isVideoFormat = format === "mp4" || format === "webp" || format === "gif";
|
|
4748
4977
|
return {
|
|
4749
4978
|
width: config.width ?? scene.getWidth(),
|
|
4750
4979
|
height: config.height ?? scene.getHeight(),
|
|
4751
4980
|
frameRate: config.frameRate ?? scene.getFrameRate(),
|
|
4752
|
-
format
|
|
4981
|
+
format,
|
|
4753
4982
|
quality: config.quality ?? "production",
|
|
4754
|
-
onProgress: config.onProgress
|
|
4983
|
+
onProgress: config.onProgress,
|
|
4984
|
+
cache: config.cache ?? isVideoFormat,
|
|
4985
|
+
cacheDir: config.cacheDir
|
|
4755
4986
|
};
|
|
4756
4987
|
}
|
|
4757
4988
|
/**
|
|
4758
4989
|
* Ensures the directory exists.
|
|
4759
4990
|
*/
|
|
4760
4991
|
async ensureDirectory(dirPath) {
|
|
4761
|
-
if (!dirPath) return;
|
|
4762
|
-
await
|
|
4992
|
+
if (!dirPath || dirPath === ".") return;
|
|
4993
|
+
await mkdir2(dirPath, { recursive: true });
|
|
4763
4994
|
}
|
|
4764
4995
|
/**
|
|
4765
4996
|
* Renders a single frame (the last frame of the animation).
|
|
@@ -4769,6 +5000,103 @@ var Renderer = class {
|
|
|
4769
5000
|
await writePng(canvas, outputPath);
|
|
4770
5001
|
progressReporter.complete();
|
|
4771
5002
|
}
|
|
5003
|
+
/**
|
|
5004
|
+
* Cache-aware segmented rendering.
|
|
5005
|
+
*
|
|
5006
|
+
* For each segment:
|
|
5007
|
+
* 1. Check if a cached partial file exists (hash match)
|
|
5008
|
+
* 2. If miss, render that segment's frame range to a partial .mp4
|
|
5009
|
+
* 3. After all segments, concat partial files into final output
|
|
5010
|
+
* 4. Prune orphaned cache entries
|
|
5011
|
+
*/
|
|
5012
|
+
async renderSegmented(scene, frameRenderer, outputPath, config, segments, progressReporter) {
|
|
5013
|
+
const cacheDir = config.cacheDir ?? join5(dirname2(outputPath), ".anima-cache");
|
|
5014
|
+
const cache = new SegmentCache(cacheDir);
|
|
5015
|
+
await cache.init();
|
|
5016
|
+
await this.ensureDirectory(dirname2(outputPath));
|
|
5017
|
+
const segmentPaths = [];
|
|
5018
|
+
let framesRendered = 0;
|
|
5019
|
+
for (const segment of segments) {
|
|
5020
|
+
const segmentPath = cache.getPath(segment.hash);
|
|
5021
|
+
if (cache.has(segment.hash)) {
|
|
5022
|
+
const segmentFrames2 = Math.max(
|
|
5023
|
+
1,
|
|
5024
|
+
Math.floor((segment.endTime - segment.startTime) * config.frameRate) + 1
|
|
5025
|
+
);
|
|
5026
|
+
framesRendered += segmentFrames2;
|
|
5027
|
+
progressReporter.reportFrame(framesRendered - 1);
|
|
5028
|
+
segmentPaths.push(segmentPath);
|
|
5029
|
+
continue;
|
|
5030
|
+
}
|
|
5031
|
+
await this.renderSegmentToFile(
|
|
5032
|
+
frameRenderer,
|
|
5033
|
+
segmentPath,
|
|
5034
|
+
config.format,
|
|
5035
|
+
config.frameRate,
|
|
5036
|
+
segment,
|
|
5037
|
+
progressReporter,
|
|
5038
|
+
framesRendered
|
|
5039
|
+
);
|
|
5040
|
+
const segmentFrames = Math.max(
|
|
5041
|
+
1,
|
|
5042
|
+
Math.floor((segment.endTime - segment.startTime) * config.frameRate) + 1
|
|
5043
|
+
);
|
|
5044
|
+
framesRendered += segmentFrames;
|
|
5045
|
+
segmentPaths.push(segmentPath);
|
|
5046
|
+
}
|
|
5047
|
+
await concatSegments(segmentPaths, outputPath);
|
|
5048
|
+
const activeHashes = new Set(segments.map((s) => s.hash));
|
|
5049
|
+
await cache.prune(activeHashes);
|
|
5050
|
+
progressReporter.complete();
|
|
5051
|
+
}
|
|
5052
|
+
/**
|
|
5053
|
+
* Renders a single segment's frame range to a video file.
|
|
5054
|
+
*/
|
|
5055
|
+
async renderSegmentToFile(frameRenderer, outputPath, format, frameRate, segment, progressReporter, frameOffset) {
|
|
5056
|
+
const segmentDuration = segment.endTime - segment.startTime;
|
|
5057
|
+
const totalFrames = Math.max(1, Math.floor(segmentDuration * frameRate) + 1);
|
|
5058
|
+
const { width, height } = frameRenderer.getDimensions();
|
|
5059
|
+
const ffmpegArgs = [
|
|
5060
|
+
"-y",
|
|
5061
|
+
"-f",
|
|
5062
|
+
"image2pipe",
|
|
5063
|
+
"-vcodec",
|
|
5064
|
+
"png",
|
|
5065
|
+
"-r",
|
|
5066
|
+
frameRate.toString(),
|
|
5067
|
+
"-i",
|
|
5068
|
+
"-"
|
|
5069
|
+
];
|
|
5070
|
+
ffmpegArgs.push(
|
|
5071
|
+
"-c:v",
|
|
5072
|
+
"libx264",
|
|
5073
|
+
"-pix_fmt",
|
|
5074
|
+
"yuv420p",
|
|
5075
|
+
"-crf",
|
|
5076
|
+
"18"
|
|
5077
|
+
);
|
|
5078
|
+
ffmpegArgs.push(outputPath);
|
|
5079
|
+
const process = Bun.spawn(["ffmpeg", ...ffmpegArgs], {
|
|
5080
|
+
stdin: "pipe"
|
|
5081
|
+
});
|
|
5082
|
+
try {
|
|
5083
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
5084
|
+
const time = segment.startTime + i / frameRate;
|
|
5085
|
+
const canvas = frameRenderer.renderFrame(time);
|
|
5086
|
+
const pngBuffer = await canvas.toBuffer("image/png");
|
|
5087
|
+
process.stdin.write(pngBuffer);
|
|
5088
|
+
progressReporter.reportFrame(frameOffset + i);
|
|
5089
|
+
}
|
|
5090
|
+
process.stdin.end();
|
|
5091
|
+
const status = await process.exited;
|
|
5092
|
+
if (status !== 0) {
|
|
5093
|
+
throw new Error(`FFmpeg segment render exited with code ${status}`);
|
|
5094
|
+
}
|
|
5095
|
+
} catch (error) {
|
|
5096
|
+
process.kill();
|
|
5097
|
+
throw error;
|
|
5098
|
+
}
|
|
5099
|
+
}
|
|
4772
5100
|
};
|
|
4773
5101
|
|
|
4774
5102
|
// src/core/renderer/types.ts
|
|
@@ -4806,7 +5134,6 @@ export {
|
|
|
4806
5134
|
MorphTo,
|
|
4807
5135
|
MoveTo,
|
|
4808
5136
|
Parallel,
|
|
4809
|
-
Point,
|
|
4810
5137
|
Polygon,
|
|
4811
5138
|
ProgressReporter,
|
|
4812
5139
|
Rectangle,
|
|
@@ -4815,6 +5142,7 @@ export {
|
|
|
4815
5142
|
Rotate,
|
|
4816
5143
|
Scale,
|
|
4817
5144
|
Scene,
|
|
5145
|
+
SegmentCache,
|
|
4818
5146
|
Sequence,
|
|
4819
5147
|
Shake,
|
|
4820
5148
|
Text,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redwilly/anima",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "Animation library for mathematical visualizations",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -58,4 +58,4 @@
|
|
|
58
58
|
"commander": "^14.0.2",
|
|
59
59
|
"fontkit": "^2.0.4"
|
|
60
60
|
}
|
|
61
|
-
}
|
|
61
|
+
}
|