reframe-video 0.6.18 → 0.6.20

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/labels.js CHANGED
@@ -678,42 +678,76 @@ import { dirname, resolve } from "node:path";
678
678
  import { fileURLToPath } from "node:url";
679
679
  var HERE = dirname(fileURLToPath(import.meta.url));
680
680
  var CORE_ENTRY = true ? resolve(HERE, "index.js") : resolve(HERE, "..", "..", "core", "src", "index.ts");
681
- async function loadDefault(path3) {
682
- if (path3.endsWith(".json")) return JSON.parse(await readFile(path3, "utf8"));
683
- let code;
681
+ var SceneLoadError = class extends Error {
682
+ kind;
683
+ constructor(kind, message, options) {
684
+ super(message, options);
685
+ this.name = "SceneLoadError";
686
+ this.kind = kind;
687
+ }
688
+ };
689
+ var clean = (err) => (err instanceof Error ? err.message : String(err)).replace(
690
+ /data:text\/javascript;base64,[A-Za-z0-9+/=]+/g,
691
+ "<scene bundle>"
692
+ );
693
+ var ALIAS = { "@reframe/core": CORE_ENTRY, "reframe-video": CORE_ENTRY };
694
+ async function bundle(input) {
695
+ const common = {
696
+ bundle: true,
697
+ format: "esm",
698
+ platform: "neutral",
699
+ write: false,
700
+ logLevel: "silent",
701
+ sourcemap: "inline",
702
+ alias: ALIAS
703
+ };
684
704
  try {
685
- const out = await build({
686
- entryPoints: [path3],
687
- bundle: true,
688
- format: "esm",
689
- platform: "neutral",
690
- write: false,
691
- logLevel: "silent",
692
- sourcemap: "inline",
693
- // both specifiers accepted: the guide's canonical "@reframe/core" and
694
- // the published package name
695
- alias: { "@reframe/core": CORE_ENTRY, "reframe-video": CORE_ENTRY }
696
- });
697
- code = out.outputFiles[0].text;
705
+ const out = await build(
706
+ "path" in input ? { ...common, entryPoints: [input.path] } : { ...common, stdin: { contents: input.code, resolveDir: input.resolveDir, loader: "ts", sourcefile: "scene.ts" } }
707
+ );
708
+ return out.outputFiles[0].text;
698
709
  } catch (err) {
699
- throw new Error(`failed to bundle ${path3}:
700
- ${err instanceof Error ? err.message : String(err)}`);
710
+ throw new SceneLoadError("bundle", clean(err), { cause: err });
701
711
  }
702
- const mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
703
- if (mod.default === void 0) throw new Error(`${path3} must default-export a scene or composition`);
712
+ }
713
+ async function importDefault(code, label) {
714
+ let mod;
715
+ try {
716
+ mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
717
+ } catch (err) {
718
+ const kind = err instanceof Error && err.name === "SceneValidationError" ? "validation" : "eval";
719
+ throw new SceneLoadError(kind, clean(err), { cause: err });
720
+ }
721
+ if (mod.default === void 0) throw new SceneLoadError("eval", `${label} must default-export a scene or composition`);
704
722
  return mod.default;
705
723
  }
724
+ async function loadDefault(path3) {
725
+ if (path3.endsWith(".json")) {
726
+ try {
727
+ return JSON.parse(await readFile(path3, "utf8"));
728
+ } catch (err) {
729
+ throw new SceneLoadError("eval", `failed to read ${path3}: ${clean(err)}`, { cause: err });
730
+ }
731
+ }
732
+ return importDefault(await bundle({ path: path3 }), path3);
733
+ }
706
734
  function isComposition(def) {
707
735
  return typeof def === "object" && def !== null && Array.isArray(def.scenes);
708
736
  }
709
- async function loadScene(path3) {
710
- const def = await loadDefault(path3);
737
+ function asScene(def, label) {
711
738
  if (isComposition(def)) {
712
- throw new Error(`${path3} is a composition \u2014 render it directly, not as a single scene`);
739
+ throw new SceneLoadError("validation", `${label} is a composition \u2014 render it directly, not as a single scene`);
740
+ }
741
+ try {
742
+ validateScene(def);
743
+ } catch (err) {
744
+ throw new SceneLoadError("validation", clean(err), { cause: err });
713
745
  }
714
- validateScene(def);
715
746
  return def;
716
747
  }
748
+ async function loadScene(path3) {
749
+ return asScene(await loadDefault(path3), path3);
750
+ }
717
751
 
718
752
  // ../render-cli/src/labels.ts
719
753
  var path2 = process.argv[2];
@@ -721,11 +755,17 @@ if (!path2) {
721
755
  console.error("usage: reframe labels <scene.ts|.json>");
722
756
  process.exit(1);
723
757
  }
724
- var scene = await loadScene(path2);
725
- var compiled = compileScene(scene);
726
- var rows = [...compiled.labelTimes.entries()].sort((a, b) => a[1].t0 - b[1].t0 || a[0].localeCompare(b[0]));
727
- console.log(`# ${scene.id} \u2014 ${rows.length} labels \xB7 ${compiled.duration.toFixed(2)}s @ ${scene.fps ?? 30}fps`);
728
- console.log(`# ${"start".padStart(7)} ${"end".padStart(7)} label`);
729
- for (const [name, { t0, t1 }] of rows) {
730
- console.log(`${`${t0.toFixed(2)}s`.padStart(8)} ${`${t1.toFixed(2)}s`.padStart(8)} ${name}`);
758
+ async function main() {
759
+ const scene = await loadScene(path2);
760
+ const compiled = compileScene(scene);
761
+ const rows = [...compiled.labelTimes.entries()].sort((a, b) => a[1].t0 - b[1].t0 || a[0].localeCompare(b[0]));
762
+ console.log(`# ${scene.id} \u2014 ${rows.length} labels \xB7 ${compiled.duration.toFixed(2)}s @ ${scene.fps ?? 30}fps`);
763
+ console.log(`# ${"start".padStart(7)} ${"end".padStart(7)} label`);
764
+ for (const [name, { t0, t1 }] of rows) {
765
+ console.log(`${`${t0.toFixed(2)}s`.padStart(8)} ${`${t1.toFixed(2)}s`.padStart(8)} ${name}`);
766
+ }
731
767
  }
768
+ main().catch((err) => {
769
+ console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
770
+ process.exit(1);
771
+ });
@@ -0,0 +1 @@
1
+ export * from "./types-renderer/index.js";
@@ -1,5 +1,5 @@
1
1
  // ../renderer-canvas/src/index.ts
2
- import { evaluate } from "@reframe/core";
2
+ import { evaluate } from "reframe-video";
3
3
  function resolvePaint(ctx, paint, box) {
4
4
  if (typeof paint === "string") return paint;
5
5
  const { x, y, w, h } = box;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * DisplayList -> Canvas 2D. Drawing only — all animation math lives in
3
+ * @reframe/core's evaluate(). Restricted to the plain Canvas 2D API so the
4
+ * same code runs in the browser (preview), under Playwright (export), and
5
+ * could port to skia-canvas later.
6
+ */
7
+ import type { CompiledScene, DisplayList, SceneIR } from "../types/index.js";
8
+ /**
9
+ * Decoded images keyed by the RAW src string from the IR (never a resolved
10
+ * path/URL — the DisplayList stays machine-independent). Consumers populate
11
+ * it before the first frame; a plain Map satisfies the interface.
12
+ */
13
+ export interface ImageRegistry {
14
+ get(src: string): CanvasImageSource | undefined;
15
+ }
16
+ /**
17
+ * Decoded video frames keyed by the RAW src string + frame index. A video is
18
+ * rendered as a frame sequence (extracted at the scene fps): `frame(src, i)`
19
+ * returns the i-th source frame, clamped to the available range by the consumer.
20
+ */
21
+ export interface VideoRegistry {
22
+ frame(src: string, index: number): CanvasImageSource | undefined;
23
+ }
24
+ export declare function renderFrame(ctx: CanvasRenderingContext2D, compiled: CompiledScene, t: number, images?: ImageRegistry, videos?: VideoRegistry): void;
25
+ export declare function drawDisplayList(ctx: CanvasRenderingContext2D, ops: DisplayList, images?: ImageRegistry, videos?: VideoRegistry): void;
26
+ /** Center cover-crop: the source rect (in image pixels) that fills a dw×dh box at
27
+ * the image's natural aspect — the larger axis is cropped equally on both sides. */
28
+ export declare function coverRect(iw: number, ih: number, dw: number, dh: number): {
29
+ sx: number;
30
+ sy: number;
31
+ sw: number;
32
+ sh: number;
33
+ };
34
+ export type { SceneIR };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reframe-video",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
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",
@@ -28,13 +28,24 @@
28
28
  ".": {
29
29
  "types": "./dist/index.d.ts",
30
30
  "import": "./dist/index.js"
31
- }
31
+ },
32
+ "./renderer": {
33
+ "types": "./dist/renderer-canvas.d.ts",
34
+ "import": "./dist/renderer-canvas.js"
35
+ },
36
+ "./compile": {
37
+ "types": "./dist/compile-api.d.ts",
38
+ "import": "./dist/compile-api.js"
39
+ },
40
+ "./package.json": "./package.json"
32
41
  },
33
42
  "files": [
34
43
  "dist",
35
44
  "assets",
36
45
  "guides",
37
- "preview"
46
+ "preview",
47
+ ".claude-plugin",
48
+ "skills"
38
49
  ],
39
50
  "engines": {
40
51
  "node": ">=20"
@@ -0,0 +1,91 @@
1
+ ---
2
+ name: reframe
3
+ description: Create and iterate motion-graphics videos (mp4) — title cards, lower thirds, kinetic typography, product teasers, data-driven video batches. Use when the user asks to make, edit, retime, personalize, or add sound to an animated video. Scenes are declarative data; renders are deterministic; human edits survive regeneration.
4
+ ---
5
+
6
+ # reframe — motion graphics as addressable data
7
+
8
+ All commands run through npx; no install or project setup is needed. The
9
+ runtime needs ffmpeg on PATH and a one-time `npx playwright install chromium`
10
+ (the render command prints an actionable hint if either is missing).
11
+
12
+ ## Creating a scene
13
+
14
+ 1. **Read the guide first** — it is the complete, current syntax (~1,700
15
+ tokens) and one read is enough to write valid scenes:
16
+ `npx -y reframe-video guide`
17
+ 2. Write a single self-contained `<name>.ts` in the user's directory
18
+ (`npx -y reframe-video new <name>` scaffolds a documented starter).
19
+ Scenes must be pure functions of time: no `Math.random()`/`Date` — use
20
+ `wiggle` with a seed. Give every node a meaningful stable `id` and label
21
+ the key timeline moments — those names are addresses for everything below.
22
+ 3. Render and verify: `npx -y reframe-video render <name>.ts` → `out/<name>.mp4`.
23
+
24
+ ## Directing a high-end piece (cinematic / reference-faithful)
25
+
26
+ Simple jobs (a lower-third, a logo sting, a KPI card) just work from the guide.
27
+ But a CINEMATIC or REFERENCE-FAITHFUL piece (a product teaser, a UI/session
28
+ reproduction, a title sequence) needs a director's process — **read it first**:
29
+ `npx -y reframe-video guide --directing`. The short version:
30
+
31
+ 1. Get the spec from the user: concept, **references** (screenshots / a reference
32
+ video / pasted real content — save them to disk), exact brand colors, length +
33
+ aspect, and tone. Vague prompts are why these take many rounds.
34
+ 2. **Storyboard the beats** with `beat("setup"/"rising"/"climax"/…)` BEFORE animating.
35
+ 3. **Match references with the `diff` tool** instead of eyeballing:
36
+ `npx -y reframe-video diff ref.png --mode grid` (measure a screenshot),
37
+ then `... diff ref.png scene.ts --mode side|diff` (compare a render) → fix → repeat.
38
+ 4. Apply cinematic craft: camera push-in per beat (`cameraTo` in `par`), curved
39
+ entrances (`motionPath` + `easeOutBack`), fake/real depth, layered `oscillate`
40
+ idle, and label-anchored sound.
41
+ 5. **Verify objectively**: `... labels` (exact beat seconds), `... motion out.mp4`
42
+ (makes "more dynamic" measurable), `... trace ref.mp4 --apply scene.ts` (borrow a
43
+ reference VIDEO's timing), `... preview` (hand-tune → overlay that survives regen).
44
+
45
+ ## Modifying an existing scene — the contract
46
+
47
+ Before rewriting any existing scene, read the regeneration contract:
48
+ `npx -y reframe-video guide --regen`. The core rule: **never rename node ids,
49
+ state names, or timeline labels for concepts that survive the redesign** —
50
+ the user's overlay documents hold their hand edits at those addresses.
51
+
52
+ The user may keep personal edits in an overlay JSON and render with
53
+ `--overlay <file>`. Check the conversation for overlay usage. Two situations
54
+ to handle explicitly:
55
+
56
+ - After your rewrite, the render's compose report lists orphaned edits for
57
+ concepts that were genuinely removed — relay that report to the user; never
58
+ let an edit disappear silently.
59
+ - If the user asks you to change a property their overlay already overrides,
60
+ editing the scene alone will be invisible in their renders. Resolve the
61
+ mask (update the scene AND remove/update the superseded overlay entry) and
62
+ tell them why.
63
+
64
+ ## Other capabilities
65
+
66
+ - **Batch**: `npx -y reframe-video batch scene.ts data.json` — one mp4 per
67
+ data row; row keys are overlay addresses (`nodes.<id>.<prop>`,
68
+ `timeline.<label>.duration`, ...). CSV works too (headers = addresses).
69
+ - **Preview editor**: `npx -y reframe-video preview` — scrub/play/knobs for
70
+ scenes in the current directory; the user's knob edits export as an overlay
71
+ JSON they can pass to render.
72
+ - **Audio**: `scene.audio` cues anchor to timeline labels, so sound follows
73
+ retiming and regeneration. Procedural sfx (whoosh/pop/tick/rise/shimmer/
74
+ thud) plus bundled CC0 samples (mechanical keypresses, clicks). The guide's
75
+ Audio section has the schema.
76
+ - **Motion check**: `npx -y reframe-video motion out/<name>.mp4` prints a
77
+ calibrated motion profile (speeds, static fraction, discontinuities) —
78
+ useful to verify a vague request like "make it more dynamic" objectively.
79
+ - **Image sequences** (the "glyph reveal" / stop-motion format): generated
80
+ stills become `image` nodes stacked in painter's order; hard cuts are
81
+ 0.01s opacity steps every ~0.15s, a slow camera-group scale tween adds the
82
+ push-in, `wiggle` adds shake, and a label per cut anchors a tick sfx.
83
+ Keep frame ids stable (`frame-0..N`) so the user can swap any plate via
84
+ overlay or batch row (`nodes.frame-3.src`). Image `src` paths resolve
85
+ relative to the scene file.
86
+
87
+ ## Verification habits
88
+
89
+ Render after every change. For visual checks, extract a few frames with
90
+ ffmpeg and look at them. Same input renders byte-identically, so "it changed"
91
+ or "it didn't change" is always provable.