@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 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
- scene = scenes.values().next().value;
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: options.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(vertices: Vector2[]);
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 style;
1357
+ private fontSize;
1311
1358
  private fontPath;
1312
- constructor(text: string, fontPath?: string, options?: Partial<TextStyle>);
1359
+ constructor(text: string, fontPath?: string, options?: {
1360
+ fontSize?: number;
1361
+ });
1313
1362
  private buildGlyphs;
1314
- private getCharacterForGlyph;
1315
- private applyStyle;
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 and provides progress callbacks.
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
- 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 Keyframe, KeyframeAnimation, KeyframeTrack, type LayoutConfig, type LayoutType, Line, Mobject, MorphTo, MoveTo, type NodeConfig, Parallel, Point, Polygon, type ProgressCallback, ProgressReporter, Rectangle, type RenderConfig, type RenderFormat, type RenderProgress, type RenderQuality, Renderer, Resolution, type ResolvedCameraConfig, Rotate, Scale, Scene, type SceneConfig, type ScheduledAnimation, Sequence, Shake, Text, type TextStyle, 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 };
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 += maxDuration;
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 += seconds;
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
- constructor(vertices) {
3639
+ vertices;
3640
+ constructor(...points) {
3502
3641
  super();
3503
- this.vertices = 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
- new Vector2(-halfW, -halfH),
3662
+ super(
3663
+ [-halfW, -halfH],
3525
3664
  // Top-Left
3526
- new Vector2(halfW, -halfH),
3665
+ [halfW, -halfH],
3527
3666
  // Top-Right
3528
- new Vector2(halfW, halfH),
3667
+ [halfW, halfH],
3529
3668
  // Bottom-Right
3530
- new Vector2(-halfW, halfH)
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
- style;
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.style = { ...DEFAULT_TEXT_STYLE, ...options };
3774
+ this.fontSize = options.fontSize ?? 1;
3643
3775
  this.buildGlyphs();
3644
3776
  centerGroup(this);
3645
- this.applyStyle();
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.style.fontSize / font.unitsPerEm;
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.getCharacterForGlyph(i);
3793
+ const char = this.text.charAt(i) || "";
3662
3794
  const glyphX = penX + pos.xOffset * scale;
3663
3795
  const glyphY = penY + pos.yOffset * scale;
3664
- const glyphMobject = new Glyph(glyph, char, scale, glyphX, glyphY);
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
- getCharacterForGlyph(index) {
3671
- return this.text.charAt(index) || "";
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.applyStyle();
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.applyStyle();
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(dirname(outputPath));
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(dirname(outputPath));
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(dirname(outputPath));
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: config.format ?? "sprite",
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 mkdir(dirPath, { recursive: true });
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.22",
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
+ }