reframe-video 0.6.25 → 0.6.27

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/diff.js CHANGED
@@ -13,6 +13,30 @@ import { dirname, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
 
15
15
  // ../core/src/ir.ts
16
+ var SFX_NAMES = [
17
+ "whoosh",
18
+ "swish",
19
+ "rise",
20
+ "riser",
21
+ "warp",
22
+ "tick",
23
+ "click",
24
+ "blip",
25
+ "pop",
26
+ "select",
27
+ "thud",
28
+ "boom",
29
+ "knock",
30
+ "chime",
31
+ "ding",
32
+ "coin",
33
+ "sparkle",
34
+ "shimmer",
35
+ "success",
36
+ "zap",
37
+ "error"
38
+ ];
39
+ var BGM_SYNTHS = ["ambient-pad", "lofi", "pulse", "tension", "uplift"];
16
40
  var DEFAULT_TO_DURATION = 0.5;
17
41
  var DEFAULT_TWEEN_DURATION = 0.5;
18
42
  var DEFAULT_MOTIONPATH_DURATION = 1;
@@ -662,7 +686,6 @@ function validateScene(ir) {
662
686
  }
663
687
  }
664
688
  }
665
- const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
666
689
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
667
690
  if (typeof cue.at === "string" && !labels.has(cue.at)) {
668
691
  problems.push(
@@ -698,6 +721,10 @@ function validateScene(ir) {
698
721
  if (ir.audio?.bgm?.file !== void 0 && ir.audio.bgm.synth !== void 0) {
699
722
  problems.push('audio.bgm: use either "file" or "synth", not both');
700
723
  }
724
+ const bgmSynth = ir.audio?.bgm?.synth;
725
+ if (bgmSynth !== void 0 && !BGM_SYNTHS.includes(bgmSynth)) {
726
+ problems.push(`audio.bgm.synth: unknown synth "${bgmSynth}" \u2014 valid: ${BGM_SYNTHS.join(", ")}`);
727
+ }
701
728
  if (problems.length > 0) throw new SceneValidationError(problems);
702
729
  }
703
730
  var TRANSITIONS = ["cut", "crossfade"];
package/dist/frame.js CHANGED
@@ -40,6 +40,30 @@ import { existsSync } from "node:fs";
40
40
  import { extname, isAbsolute, resolve } from "node:path";
41
41
 
42
42
  // ../core/src/ir.ts
43
+ var SFX_NAMES = [
44
+ "whoosh",
45
+ "swish",
46
+ "rise",
47
+ "riser",
48
+ "warp",
49
+ "tick",
50
+ "click",
51
+ "blip",
52
+ "pop",
53
+ "select",
54
+ "thud",
55
+ "boom",
56
+ "knock",
57
+ "chime",
58
+ "ding",
59
+ "coin",
60
+ "sparkle",
61
+ "shimmer",
62
+ "success",
63
+ "zap",
64
+ "error"
65
+ ];
66
+ var BGM_SYNTHS = ["ambient-pad", "lofi", "pulse", "tension", "uplift"];
43
67
  var DEFAULT_TO_DURATION = 0.5;
44
68
  var DEFAULT_TWEEN_DURATION = 0.5;
45
69
  var DEFAULT_MOTIONPATH_DURATION = 1;
@@ -689,7 +713,6 @@ function validateScene(ir) {
689
713
  }
690
714
  }
691
715
  }
692
- const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
693
716
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
694
717
  if (typeof cue.at === "string" && !labels.has(cue.at)) {
695
718
  problems.push(
@@ -725,6 +748,10 @@ function validateScene(ir) {
725
748
  if (ir.audio?.bgm?.file !== void 0 && ir.audio.bgm.synth !== void 0) {
726
749
  problems.push('audio.bgm: use either "file" or "synth", not both');
727
750
  }
751
+ const bgmSynth = ir.audio?.bgm?.synth;
752
+ if (bgmSynth !== void 0 && !BGM_SYNTHS.includes(bgmSynth)) {
753
+ problems.push(`audio.bgm.synth: unknown synth "${bgmSynth}" \u2014 valid: ${BGM_SYNTHS.join(", ")}`);
754
+ }
728
755
  if (problems.length > 0) throw new SceneValidationError(problems);
729
756
  }
730
757
  var TRANSITIONS = ["cut", "crossfade"];
package/dist/index.js CHANGED
@@ -1,4 +1,28 @@
1
1
  // ../core/src/ir.ts
2
+ var SFX_NAMES = [
3
+ "whoosh",
4
+ "swish",
5
+ "rise",
6
+ "riser",
7
+ "warp",
8
+ "tick",
9
+ "click",
10
+ "blip",
11
+ "pop",
12
+ "select",
13
+ "thud",
14
+ "boom",
15
+ "knock",
16
+ "chime",
17
+ "ding",
18
+ "coin",
19
+ "sparkle",
20
+ "shimmer",
21
+ "success",
22
+ "zap",
23
+ "error"
24
+ ];
25
+ var BGM_SYNTHS = ["ambient-pad", "lofi", "pulse", "tension", "uplift"];
2
26
  var DEFAULT_CROSSFADE = 0.5;
3
27
  var DEFAULT_TO_DURATION = 0.5;
4
28
  var DEFAULT_TWEEN_DURATION = 0.5;
@@ -785,7 +809,6 @@ function validateScene(ir) {
785
809
  }
786
810
  }
787
811
  }
788
- const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
789
812
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
790
813
  if (typeof cue.at === "string" && !labels.has(cue.at)) {
791
814
  problems.push(
@@ -821,6 +844,10 @@ function validateScene(ir) {
821
844
  if (ir.audio?.bgm?.file !== void 0 && ir.audio.bgm.synth !== void 0) {
822
845
  problems.push('audio.bgm: use either "file" or "synth", not both');
823
846
  }
847
+ const bgmSynth = ir.audio?.bgm?.synth;
848
+ if (bgmSynth !== void 0 && !BGM_SYNTHS.includes(bgmSynth)) {
849
+ problems.push(`audio.bgm.synth: unknown synth "${bgmSynth}" \u2014 valid: ${BGM_SYNTHS.join(", ")}`);
850
+ }
824
851
  if (problems.length > 0) throw new SceneValidationError(problems);
825
852
  }
826
853
  var TRANSITIONS = ["cut", "crossfade"];
@@ -1208,6 +1235,14 @@ function cameraMatrix(cam, size) {
1208
1235
  const d = Math.cos(r) * zoom;
1209
1236
  return [a, b, c, d, W / 2 - a * x - c * y, H / 2 - b * x - d * y];
1210
1237
  }
1238
+ function cameraFit(box, opts = {}) {
1239
+ const W = opts.size?.width ?? 1920;
1240
+ const H = opts.size?.height ?? 1080;
1241
+ const m = opts.margin ?? 80;
1242
+ const fit = Math.min(W / (box.width + 2 * m), H / (box.height + 2 * m));
1243
+ const zoom = Math.min(fit, opts.maxZoom ?? 2.4);
1244
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2, zoom };
1245
+ }
1211
1246
  function cameraTo(props, opts = {}) {
1212
1247
  return tween(CAMERA_ID, props, opts);
1213
1248
  }
@@ -2881,12 +2916,32 @@ function motionOp(name, target, opts = {}) {
2881
2916
 
2882
2917
  // ../core/src/audio.ts
2883
2918
  var SFX_DURATION = {
2919
+ // transition
2884
2920
  whoosh: 0.35,
2885
- pop: 0.12,
2886
- tick: 0.03,
2921
+ swish: 0.32,
2887
2922
  rise: 0.5,
2923
+ riser: 0.85,
2924
+ warp: 0.5,
2925
+ // ui
2926
+ tick: 0.03,
2927
+ click: 0.05,
2928
+ blip: 0.1,
2929
+ pop: 0.12,
2930
+ select: 0.18,
2931
+ // impact
2932
+ thud: 0.25,
2933
+ boom: 0.6,
2934
+ knock: 0.14,
2935
+ // positive
2936
+ chime: 0.7,
2937
+ ding: 0.5,
2938
+ coin: 0.3,
2939
+ sparkle: 0.6,
2888
2940
  shimmer: 0.9,
2889
- thud: 0.25
2941
+ success: 0.6,
2942
+ // alert
2943
+ zap: 0.22,
2944
+ error: 0.4
2890
2945
  };
2891
2946
  var FILE_CUE_DURATION = 0.4;
2892
2947
  function collectClipAudio(ir, duration, warnings) {
@@ -2946,7 +3001,11 @@ function resolveAudioPlan(compiled) {
2946
3001
  fadeIn: cue.fadeIn ?? 0,
2947
3002
  fadeOut: cue.fadeOut ?? 0,
2948
3003
  pan: cue.pan ?? 0,
2949
- source: cue.sfx ? { kind: "sfx", name: cue.sfx, params: cue.params ?? {} } : { kind: "file", path: cue.file }
3004
+ source: cue.sfx ? (
3005
+ // auto-vary: default the seed to the cue's order so repeated sfx differ
3006
+ // (pitch/texture); an explicit params.seed always wins.
3007
+ { kind: "sfx", name: cue.sfx, params: { seed: index, ...cue.params } }
3008
+ ) : { kind: "file", path: cue.file }
2950
3009
  });
2951
3010
  }
2952
3011
  cues.sort((a, b) => a.t - b.t);
@@ -3026,7 +3085,11 @@ function resolveCompositionAudioPlan(comp) {
3026
3085
  fadeIn: cue.fadeIn ?? 0,
3027
3086
  fadeOut: cue.fadeOut ?? 0,
3028
3087
  pan: cue.pan ?? 0,
3029
- source: cue.sfx ? { kind: "sfx", name: cue.sfx, params: cue.params ?? {} } : { kind: "file", path: cue.file }
3088
+ source: cue.sfx ? (
3089
+ // auto-vary: default the seed to the cue's order so repeated sfx differ
3090
+ // (pitch/texture); an explicit params.seed always wins.
3091
+ { kind: "sfx", name: cue.sfx, params: { seed: index, ...cue.params } }
3092
+ ) : { kind: "file", path: cue.file }
3030
3093
  });
3031
3094
  }
3032
3095
  if (!audio?.bgm && cues.length === 0 && clipAudio.length === 0) return null;
@@ -3585,6 +3648,7 @@ function sketchToTimeline(sketch, nodeIds) {
3585
3648
  return par(...steps);
3586
3649
  }
3587
3650
  export {
3651
+ BGM_SYNTHS,
3588
3652
  CAMERA_ID,
3589
3653
  CAMERA_PROPS2 as CAMERA_PROPS,
3590
3654
  CHARACTER_PRESET_NAMES,
@@ -3600,8 +3664,10 @@ export {
3600
3664
  PRESET_NAMES,
3601
3665
  PROPS_BY_TYPE,
3602
3666
  SFX_DURATION,
3667
+ SFX_NAMES,
3603
3668
  SceneValidationError,
3604
3669
  beat,
3670
+ cameraFit,
3605
3671
  cameraMatrix,
3606
3672
  cameraTo,
3607
3673
  characterPreset,
package/dist/labels.js CHANGED
@@ -1,6 +1,30 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
3
  // ../core/src/ir.ts
4
+ var SFX_NAMES = [
5
+ "whoosh",
6
+ "swish",
7
+ "rise",
8
+ "riser",
9
+ "warp",
10
+ "tick",
11
+ "click",
12
+ "blip",
13
+ "pop",
14
+ "select",
15
+ "thud",
16
+ "boom",
17
+ "knock",
18
+ "chime",
19
+ "ding",
20
+ "coin",
21
+ "sparkle",
22
+ "shimmer",
23
+ "success",
24
+ "zap",
25
+ "error"
26
+ ];
27
+ var BGM_SYNTHS = ["ambient-pad", "lofi", "pulse", "tension", "uplift"];
4
28
  var DEFAULT_TO_DURATION = 0.5;
5
29
  var DEFAULT_TWEEN_DURATION = 0.5;
6
30
  var DEFAULT_MOTIONPATH_DURATION = 1;
@@ -650,7 +674,6 @@ function validateScene(ir) {
650
674
  }
651
675
  }
652
676
  }
653
- const SFX_NAMES = ["whoosh", "pop", "tick", "rise", "shimmer", "thud"];
654
677
  for (const [i, cue] of (ir.audio?.cues ?? []).entries()) {
655
678
  if (typeof cue.at === "string" && !labels.has(cue.at)) {
656
679
  problems.push(
@@ -686,6 +709,10 @@ function validateScene(ir) {
686
709
  if (ir.audio?.bgm?.file !== void 0 && ir.audio.bgm.synth !== void 0) {
687
710
  problems.push('audio.bgm: use either "file" or "synth", not both');
688
711
  }
712
+ const bgmSynth = ir.audio?.bgm?.synth;
713
+ if (bgmSynth !== void 0 && !BGM_SYNTHS.includes(bgmSynth)) {
714
+ problems.push(`audio.bgm.synth: unknown synth "${bgmSynth}" \u2014 valid: ${BGM_SYNTHS.join(", ")}`);
715
+ }
689
716
  if (problems.length > 0) throw new SceneValidationError(problems);
690
717
  }
691
718
 
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { CompiledScene } from "./compile.js";
10
10
  import type { CompiledComposition } from "./composeComposition.js";
11
- import type { SfxName } from "./ir.js";
11
+ import type { BgmSynth, SfxName } from "./ir.js";
12
12
  /** Nominal cue lengths (s) for duck-window math; file cues use a default. */
13
13
  export declare const SFX_DURATION: Record<SfxName, number>;
14
14
  export interface ResolvedCue {
@@ -55,7 +55,7 @@ export interface AudioPlan {
55
55
  path: string;
56
56
  } | {
57
57
  kind: "synth";
58
- name: "ambient-pad";
58
+ name: BgmSynth;
59
59
  };
60
60
  gain: number;
61
61
  fadeIn: number;
@@ -24,6 +24,33 @@ export declare const CAMERA_PROPS: readonly ["x", "y", "zoom", "rotation", "pers
24
24
  * collapse to the identity.
25
25
  */
26
26
  export declare function cameraMatrix(cam: CameraIR, size: Size): Mat2D;
27
+ /**
28
+ * Frame a scene-space bounding box in the viewport — returns `{ x, y, zoom }` to
29
+ * spread into `cameraTo`, GUARANTEED not to clip the box. The visible scene rect
30
+ * is `W/zoom × H/zoom` centred on `(x, y)`; fitting `box` (+ `margin` padding on
31
+ * the tight axis) means `zoom = min(W/(box.w+2m), H/(box.h+2m))`, capped by
32
+ * `maxZoom` so a tiny target doesn't zoom absurdly close.
33
+ *
34
+ * cameraTo(cameraFit({ x: 200, y: 760, width: 740, height: 360 }, { margin: 90 }),
35
+ * { duration: 1, ease: "easeInOutCubic" })
36
+ *
37
+ * `box` is a top-left rect in scene coords (a centre-anchored panel at (px,py) of
38
+ * size (pw,ph) is `{ x: px-pw/2, y: py-ph/2, width: pw, height: ph }`).
39
+ */
40
+ export declare function cameraFit(box: {
41
+ x: number;
42
+ y: number;
43
+ width: number;
44
+ height: number;
45
+ }, opts?: {
46
+ size?: Size;
47
+ margin?: number;
48
+ maxZoom?: number;
49
+ }): {
50
+ x: number;
51
+ y: number;
52
+ zoom: number;
53
+ };
27
54
  /** Keyframe the camera: a `tween` on the reserved "camera" target. */
28
55
  export declare function cameraTo(props: CameraIR, opts?: {
29
56
  duration?: number;
@@ -5,7 +5,7 @@ export { compileComposition, type CompiledComposition, type ScenePlacement, } fr
5
5
  export { composeScene, formatComposeReport, type OverlayDoc, type ComposeReport, } from "./compose.js";
6
6
  export { compileScene, type CompiledScene, type PropertySegment, type LabelSpan, type MotionDriver } from "./compile.js";
7
7
  export { pathPoint, pathTangentAngle, type Pt } from "./path.js";
8
- export { cameraTo, cameraMatrix, CAMERA_ID, CAMERA_PROPS } from "./camera.js";
8
+ export { cameraTo, cameraFit, cameraMatrix, CAMERA_ID, CAMERA_PROPS } from "./camera.js";
9
9
  export { linearGradient, radialGradient, conicGradient, isGradient } from "./gradient.js";
10
10
  export { glow, dropShadow } from "./effects.js";
11
11
  export { row, column, grid, type RowOpts, type GridOpts } from "./layout.js";
@@ -412,7 +412,20 @@ export interface BehaviorIR {
412
412
  };
413
413
  };
414
414
  }
415
- export type SfxName = "whoosh" | "pop" | "tick" | "rise" | "shimmer" | "thud";
415
+ /**
416
+ * The procedural sfx palette — the single source of truth (the type, validation,
417
+ * and the render-cli synth recipes all key off this). Grouped by use:
418
+ * transition: whoosh swish rise riser warp · ui: tick click blip pop select
419
+ * impact: thud boom knock · positive: chime ding coin sparkle shimmer success
420
+ * alert: zap error
421
+ * Every cue's pitch/texture varies with its `seed` (auto-seeded by cue order), so
422
+ * repeated cues sound different; `params.pitch` is an explicit multiplier.
423
+ */
424
+ export declare const SFX_NAMES: readonly ["whoosh", "swish", "rise", "riser", "warp", "tick", "click", "blip", "pop", "select", "thud", "boom", "knock", "chime", "ding", "coin", "sparkle", "shimmer", "success", "zap", "error"];
425
+ export type SfxName = (typeof SFX_NAMES)[number];
426
+ /** Synthesized background-music beds (license-free). */
427
+ export declare const BGM_SYNTHS: readonly ["ambient-pad", "lofi", "pulse", "tension", "uplift"];
428
+ export type BgmSynth = (typeof BGM_SYNTHS)[number];
416
429
  export interface AudioCueIR {
417
430
  /** Anchor: a timeline label (the step's start) or absolute seconds. */
418
431
  at: string | number;
@@ -430,14 +443,18 @@ export interface AudioCueIR {
430
443
  fadeOut?: number;
431
444
  /** Stereo balance: -1 full left, 0 centre (default), +1 full right. */
432
445
  pan?: number;
433
- /** Synth parameter overrides (seed, duration, …) — numbers only. */
446
+ /**
447
+ * Synth parameter overrides — numbers only. `seed` varies pitch/texture
448
+ * (defaults to the cue's order so repeats differ); `pitch` is an explicit
449
+ * frequency multiplier (1 = unchanged, 2 = an octave up); `gainDb` trims level.
450
+ */
434
451
  params?: Record<string, number>;
435
452
  }
436
453
  export interface AudioIR {
437
454
  bgm?: {
438
455
  file?: string;
439
- /** License-free synthesized bed. */
440
- synth?: "ambient-pad";
456
+ /** License-free synthesized bed (see {@link BGM_SYNTHS}). */
457
+ synth?: BgmSynth;
441
458
  gain?: number;
442
459
  fadeIn?: number;
443
460
  fadeOut?: number;
@@ -90,6 +90,14 @@ row(3, { center: 960, gap: 60, itemWidth: 440 }).map((x, i) =>
90
90
 
91
91
  `column` is `row` for the y axis.
92
92
 
93
+ **Charts/widgets in a panel — derive geometry from the box, and `clip` it.** Don't
94
+ hand-pick a pixels-per-unit scale (bars routinely overflow the panel that way).
95
+ Define the panel rect ONCE, then size from it — bar height `(v/max) · innerH`, x via
96
+ `row(...)` across the panel width — so a tall value can't exceed the box. As a safety
97
+ net, wrap the chart in a clipped group so nothing can ever punch out the panel:
98
+ `group({ clip: { kind: "rect", x, y, width, height, radius } }, [ ...bars ])`. See
99
+ `examples/scenes/annual-report.ts` (and `cameraFit` above to frame the panel).
100
+
93
101
  ## States: declare looks, not motion
94
102
 
95
103
  Base props on nodes describe the **finished design**. A state is a sparse
@@ -178,6 +186,14 @@ scene({
178
186
  `tween` on the `"camera"` target, so `motionPath("camera", pts, …)` (pan along
179
187
  a curve) and `oscillate/wiggle("camera", "rotation"|"x"|…)` (handheld drift)
180
188
  also work.
189
+ - **Frame a region without clipping — use `cameraFit`, not a guessed `zoom`.** The
190
+ visible scene rect is `W/zoom × H/zoom` centred on `(camera.x, camera.y)`, so a
191
+ hand-picked `zoom` that's too big crops the target. `cameraFit(box, { margin })`
192
+ returns `{ x, y, zoom }` that frames a scene-space bbox (top-left `{x,y,width,
193
+ height}`) with padding, guaranteed in-bounds: `cameraTo(cameraFit({ x, y, width,
194
+ height }, { margin: 80 }), { duration: 1, ease: "easeInOutCubic" })`. A centre-
195
+ anchored panel at `(px,py)` size `(pw,ph)` is `{ x: px-pw/2, y: py-ph/2, width:
196
+ pw, height: ph }`. `maxZoom` (default 2.4) caps absurd close-ups.
181
197
  - **Pin HUD/titles to the screen** with `fixed: true` on a TOP-LEVEL node — the
182
198
  camera won't move it (for overlays, watermarks, captions).
183
199
  - Defaults are the identity, so a scene without a camera is unchanged. Don't name
@@ -542,12 +558,28 @@ audio: {
542
558
  }
543
559
  ```
544
560
 
545
- Procedural sfx names: `whoosh` `pop` `tick` `rise` `shimmer` `thud` (deterministic,
546
- seedable via `params: { seed }`). Exactly one of `sfx`/`file` per cue.
547
- **Mixing**: any cue takes `fadeIn`/`fadeOut` (seconds) and `pan` (-1 left … 0 centre …
548
- +1 right). A `video` clip's audio takes `fadeIn` and `pan` too (clip fade-out isn't
549
- supported yet a clip has no fixed length in the plan). The bed auto-ducks under cues
550
- (`bgm.duck`).
561
+ **Procedural sfx palette** (deterministic; exactly one of `sfx`/`file` per cue):
562
+
563
+ | group | names |
564
+ | --- | --- |
565
+ | transition | `whoosh` `swish` `rise` `riser` `warp` |
566
+ | ui | `tick` `click` `blip` `pop` `select` |
567
+ | impact | `thud` `boom` `knock` |
568
+ | positive | `chime` `ding` `coin` `sparkle` `shimmer` `success` |
569
+ | alert | `zap` `error` |
570
+
571
+ **Variation — repeats don't sound the same.** Each cue's `seed` shifts the sound's
572
+ PITCH (a musical step) and texture, and it **defaults to the cue's order**, so a run of
573
+ the same sfx becomes a little phrase instead of a stuck note — no setup needed. Override
574
+ explicitly with `params`: `{ sfx: "blip", params: { seed: 4 } }` (pick the variant) or
575
+ `{ sfx: "tick", params: { pitch: 1.5 } }` (an explicit frequency multiplier; `2` = octave
576
+ up). `params.gainDb` trims a single hit.
577
+
578
+ **bgm beds** (`bgm.synth`): `ambient-pad` `lofi` `pulse` `tension` `uplift` — or
579
+ `bgm.file` for your own. **Mixing**: any cue takes `fadeIn`/`fadeOut` (seconds) and `pan`
580
+ (-1 left … 0 centre … +1 right). A `video` clip's audio takes `fadeIn` and `pan` too
581
+ (clip fade-out isn't supported yet). The bed auto-ducks under cues (`bgm.duck`). See
582
+ `examples/scenes/sfx-showcase.ts` to audition the whole palette.
551
583
 
552
584
  ## Rules
553
585
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.6.25",
3
+ "version": "0.6.27",
4
4
  "description": "Declarative motion graphics that AI can write and humans can tweak — human edits survive AI regeneration. Deterministic mp4 renders from a plain-data scene format.",
5
5
  "keywords": [
6
6
  "motion-graphics",