@nodetool-ai/node-sdk 0.8.4-a24.2 → 0.8.4
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/.nodetool-build-stamp +1 -1
- package/dist/_sandbox/README.md +176 -0
- package/dist/_sandbox/sandbox-aws/SKILL.md +98 -0
- package/dist/_sandbox/sandbox-aws/package.json +40 -0
- package/dist/_sandbox/sandbox-chrono/SKILL.md +39 -0
- package/dist/_sandbox/sandbox-chrono/package.json +40 -0
- package/dist/_sandbox/sandbox-color/SKILL.md +35 -0
- package/dist/_sandbox/sandbox-color/package.json +43 -0
- package/dist/_sandbox/sandbox-csv/SKILL.md +50 -0
- package/dist/_sandbox/sandbox-csv/package.json +39 -0
- package/dist/_sandbox/sandbox-dates/SKILL.md +47 -0
- package/dist/_sandbox/sandbox-dates/package.json +41 -0
- package/dist/_sandbox/sandbox-decimal/SKILL.md +36 -0
- package/dist/_sandbox/sandbox-decimal/package.json +43 -0
- package/dist/_sandbox/sandbox-diff/SKILL.md +36 -0
- package/dist/_sandbox/sandbox-diff/package.json +39 -0
- package/dist/_sandbox/sandbox-docx/SKILL.md +53 -0
- package/dist/_sandbox/sandbox-docx/package.json +40 -0
- package/dist/_sandbox/sandbox-dsl/SKILL.md +137 -0
- package/dist/_sandbox/sandbox-dsl/package.json +413 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/authoring.js +382 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/core.js +382 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/gemini.audio.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/gemini.image.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/gemini.text.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/gemini.video.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/index.js +149 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/kie.dynamic_schema.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.audio.js +68 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.browser.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.charts.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.comfy.js +16 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.grid.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.image.channel.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.image.color.js +32 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.image.color_grading.js +44 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.image.draw.js +36 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.image.effects.js +24 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.image.enhance.js +28 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.image.filter.js +64 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.image.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.image.keyer.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.image.mask.js +16 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.image.warp.js +36 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.pdf.js +32 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.sqlite.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.stable_diffusion_cpp.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.svg.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/lib.video.download.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/messaging.discord.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/messaging.telegram.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/mistral.embeddings.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/mistral.text.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/mistral.vision.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.agents.js +32 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.audio.js +104 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.audio.realtime.js +28 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.audio.synth.js +40 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.blender.js +28 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.code.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.compare.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.constant.js +132 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.control.js +96 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.creative.js +24 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.data.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.document.js +16 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.entity.js +16 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.fake.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.game.js +36 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.generators.js +24 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.image.js +120 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.input.js +140 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.manimgx.js +16 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.model3d.js +76 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.output.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.script.js +28 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.sketch.js +16 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.storyboard.js +32 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.text.js +56 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.timeline.js +24 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.triggers.js +24 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.variable.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.video.js +136 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.workflows.app_node.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.workflows.base_node.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.workflows.subgraph.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/nodetool.workflows.workflow_node.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/openai.agents.js +16 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/openai.audio.js +16 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/openai.image.js +16 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/openai.text.js +16 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/vector.js +56 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/whisper_cpp.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/xai.image.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/xai.text.js +12 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/generated/xai.vision.js +8 -0
- package/dist/_sandbox/sandbox-dsl/sandbox/index.js +13 -0
- package/dist/_sandbox/sandbox-dsl/scripts/build.mjs +116 -0
- package/dist/_sandbox/sandbox-epub/SKILL.md +61 -0
- package/dist/_sandbox/sandbox-epub/package.json +40 -0
- package/dist/_sandbox/sandbox-exif/SKILL.md +30 -0
- package/dist/_sandbox/sandbox-exif/package.json +40 -0
- package/dist/_sandbox/sandbox-expr/SKILL.md +28 -0
- package/dist/_sandbox/sandbox-expr/package.json +40 -0
- package/dist/_sandbox/sandbox-fabric/SKILL.md +113 -0
- package/dist/_sandbox/sandbox-fabric/package.json +40 -0
- package/dist/_sandbox/sandbox-flow/SKILL.md +131 -0
- package/dist/_sandbox/sandbox-flow/package.json +411 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/gemini.audio.js +12 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/gemini.image.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/gemini.text.js +12 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/gemini.video.js +12 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/kie.dynamic_schema.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.audio.js +68 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.browser.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.charts.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.comfy.js +25 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.grid.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.image.channel.js +12 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.image.color.js +32 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.image.color_grading.js +44 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.image.draw.js +36 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.image.effects.js +24 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.image.enhance.js +28 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.image.filter.js +64 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.image.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.image.keyer.js +12 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.image.mask.js +16 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.image.warp.js +36 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.pdf.js +32 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.sqlite.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.stable_diffusion_cpp.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.svg.js +12 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/lib.video.download.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/messaging.discord.js +11 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/messaging.telegram.js +11 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/mistral.embeddings.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/mistral.text.js +12 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/mistral.vision.js +12 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.agents.js +41 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.audio.js +110 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.audio.realtime.js +46 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.audio.synth.js +67 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.blender.js +28 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.code.js +11 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.compare.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.constant.js +132 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.control.js +165 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.creative.js +27 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.data.js +18 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.document.js +19 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.entity.js +19 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.fake.js +12 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.game.js +39 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.generators.js +30 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.image.js +126 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.input.js +143 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.manimgx.js +16 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.model3d.js +76 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.output.js +11 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.script.js +28 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.sketch.js +16 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.storyboard.js +35 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.text.js +68 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.timeline.js +24 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.triggers.js +33 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.variable.js +15 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.video.js +145 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.workflows.app_node.js +11 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.workflows.base_node.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.workflows.subgraph.js +11 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/nodetool.workflows.workflow_node.js +11 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/openai.agents.js +25 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/openai.audio.js +16 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/openai.image.js +16 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/openai.text.js +16 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/vector.js +56 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/whisper_cpp.js +11 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/xai.image.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/xai.text.js +12 -0
- package/dist/_sandbox/sandbox-flow/sandbox/generated/xai.vision.js +8 -0
- package/dist/_sandbox/sandbox-flow/sandbox/guest-core.js +35 -0
- package/dist/_sandbox/sandbox-flow/sandbox/index.js +10 -0
- package/dist/_sandbox/sandbox-flow/scripts/build.mjs +120 -0
- package/dist/_sandbox/sandbox-game/SKILL.md +116 -0
- package/dist/_sandbox/sandbox-game/package.json +36 -0
- package/dist/_sandbox/sandbox-game/sandbox/index.js +208 -0
- package/dist/_sandbox/sandbox-gif/SKILL.md +36 -0
- package/dist/_sandbox/sandbox-gif/package.json +43 -0
- package/dist/_sandbox/sandbox-html/SKILL.md +107 -0
- package/dist/_sandbox/sandbox-html/package.json +40 -0
- package/dist/_sandbox/sandbox-ics/SKILL.md +41 -0
- package/dist/_sandbox/sandbox-ics/package.json +40 -0
- package/dist/_sandbox/sandbox-jmespath/SKILL.md +26 -0
- package/dist/_sandbox/sandbox-jmespath/package.json +43 -0
- package/dist/_sandbox/sandbox-mammoth/SKILL.md +46 -0
- package/dist/_sandbox/sandbox-mammoth/package.json +41 -0
- package/dist/_sandbox/sandbox-markdown/SKILL.md +123 -0
- package/dist/_sandbox/sandbox-markdown/package.json +41 -0
- package/dist/_sandbox/sandbox-notion/SKILL.md +101 -0
- package/dist/_sandbox/sandbox-notion/package.json +38 -0
- package/dist/_sandbox/sandbox-ocr/SKILL.md +47 -0
- package/dist/_sandbox/sandbox-ocr/package.json +40 -0
- package/dist/_sandbox/sandbox-pdf/SKILL.md +47 -0
- package/dist/_sandbox/sandbox-pdf/package.json +39 -0
- package/dist/_sandbox/sandbox-pdflib/SKILL.md +56 -0
- package/dist/_sandbox/sandbox-pdflib/package.json +38 -0
- package/dist/_sandbox/sandbox-pptx/SKILL.md +42 -0
- package/dist/_sandbox/sandbox-pptx/package.json +40 -0
- package/dist/_sandbox/sandbox-pptxgen/SKILL.md +49 -0
- package/dist/_sandbox/sandbox-pptxgen/package.json +38 -0
- package/dist/_sandbox/sandbox-qr/SKILL.md +43 -0
- package/dist/_sandbox/sandbox-qr/package.json +42 -0
- package/dist/_sandbox/sandbox-rrule/SKILL.md +37 -0
- package/dist/_sandbox/sandbox-rrule/package.json +43 -0
- package/dist/_sandbox/sandbox-stats/SKILL.md +42 -0
- package/dist/_sandbox/sandbox-stats/package.json +43 -0
- package/dist/_sandbox/sandbox-subtitle/SKILL.md +38 -0
- package/dist/_sandbox/sandbox-subtitle/package.json +41 -0
- package/dist/_sandbox/sandbox-supabase/SKILL.md +108 -0
- package/dist/_sandbox/sandbox-supabase/package.json +39 -0
- package/dist/_sandbox/sandbox-tfjs/SKILL.md +76 -0
- package/dist/_sandbox/sandbox-tfjs/package.json +42 -0
- package/dist/_sandbox/sandbox-timeline/SKILL.md +647 -0
- package/dist/_sandbox/sandbox-timeline/package.json +46 -0
- package/dist/_sandbox/sandbox-timeline/sandbox/charts.js +356 -0
- package/dist/_sandbox/sandbox-timeline/sandbox/components.js +171 -0
- package/dist/_sandbox/sandbox-timeline/sandbox/index.d.ts +700 -0
- package/dist/_sandbox/sandbox-timeline/sandbox/index.js +1658 -0
- package/dist/_sandbox/sandbox-tokens/SKILL.md +46 -0
- package/dist/_sandbox/sandbox-tokens/package.json +40 -0
- package/dist/_sandbox/sandbox-twilio/SKILL.md +93 -0
- package/dist/_sandbox/sandbox-twilio/package.json +39 -0
- package/dist/_sandbox/sandbox-xlsx/SKILL.md +74 -0
- package/dist/_sandbox/sandbox-xlsx/package.json +40 -0
- package/dist/_sandbox/sandbox-xml/SKILL.md +36 -0
- package/dist/_sandbox/sandbox-xml/package.json +40 -0
- package/dist/_sandbox/sandbox-yaml/SKILL.md +48 -0
- package/dist/_sandbox/sandbox-yaml/package.json +41 -0
- package/dist/_sandbox/sandbox-zip/SKILL.md +54 -0
- package/dist/_sandbox/sandbox-zip/package.json +40 -0
- package/dist/code-node-validation.d.ts +2 -0
- package/dist/code-node-validation.d.ts.map +1 -1
- package/dist/code-node-validation.js +1 -1
- package/dist/code-node-validation.js.map +1 -1
- package/dist/pack-loader.d.ts +1 -0
- package/dist/pack-loader.d.ts.map +1 -1
- package/dist/pack-loader.js +1 -0
- package/dist/pack-loader.js.map +1 -1
- package/dist/sandbox-catalog-host.js +1 -1
- package/dist/sandbox-catalog-host.js.map +1 -1
- package/dist/sandbox-pack-discovery.d.ts +3 -0
- package/dist/sandbox-pack-discovery.d.ts.map +1 -1
- package/dist/sandbox-pack-discovery.js +7 -3
- package/dist/sandbox-pack-discovery.js.map +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,1658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A motion-graphics timeline authoring API for the sandbox.
|
|
3
|
+
*
|
|
4
|
+
* Author a cut as code: `video({...})` returns `v`; `v.scene(name, seconds,
|
|
5
|
+
* fn)` builds one scene at its own local clock; `v.series([...])` lays scenes
|
|
6
|
+
* end to end with authored transitions between them; `v.save(nodetool.timelines)`
|
|
7
|
+
* saves the result. Every time value anywhere in this API is seconds, local to
|
|
8
|
+
* its own scene or `seq`. There is no absolute-time option: `v.series` is the
|
|
9
|
+
* only place a scene's position in the whole video is decided. See the pack's
|
|
10
|
+
* SKILL.md.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { barChart, lineChart, areaChart, donut, statCounter } from "./charts.js";
|
|
14
|
+
import {
|
|
15
|
+
component as makeComponentDescriptor,
|
|
16
|
+
defaultComponentProps,
|
|
17
|
+
markerPropValue,
|
|
18
|
+
pointerForProp,
|
|
19
|
+
resolveComponentProps
|
|
20
|
+
} from "./components.js";
|
|
21
|
+
|
|
22
|
+
export const rad = (d) => (d * Math.PI) / 180;
|
|
23
|
+
export const hash = (n) => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A deterministic 1D value-noise generator, seeded — no `Math.random`, so two
|
|
27
|
+
* runs of the same script produce the same motion. `noise(seed)(x)` returns a
|
|
28
|
+
* smoothly-interpolated value in 0..1; feed it an increasing `x` (frame index,
|
|
29
|
+
* or `t` seconds) for organic jitter that never repeats and never re-rolls
|
|
30
|
+
* between renders.
|
|
31
|
+
*/
|
|
32
|
+
export function noise(seed = 0) {
|
|
33
|
+
const base = seed * 1013.9041;
|
|
34
|
+
return (x) => {
|
|
35
|
+
const xi = Math.floor(x), xf = x - xi;
|
|
36
|
+
const a = hash(xi + base), b = hash(xi + 1 + base);
|
|
37
|
+
const u = xf * xf * (3 - 2 * xf); // smoothstep
|
|
38
|
+
return a + (b - a) * u;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `[["M", x, y], ["L", x, y], …, ["Z"]?]` -> `{verts: [[x,y], …], closed}`. */
|
|
43
|
+
function polylineFromPoints(points) {
|
|
44
|
+
const verts = [];
|
|
45
|
+
let closed = false;
|
|
46
|
+
for (const [cmd, ...args] of points) {
|
|
47
|
+
if (cmd === "Z" || cmd === "z") { closed = true; continue; }
|
|
48
|
+
if (args.length >= 2) verts.push([args[args.length - 2], args[args.length - 1]]);
|
|
49
|
+
}
|
|
50
|
+
return { verts, closed };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Even arc-length resampling of a polyline to exactly `n` vertices. */
|
|
54
|
+
function resamplePolyline(verts, closed, n) {
|
|
55
|
+
const pts = closed ? [...verts, verts[0]] : verts;
|
|
56
|
+
const lens = [0];
|
|
57
|
+
for (let i = 1; i < pts.length; i++) {
|
|
58
|
+
lens.push(lens[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
|
|
59
|
+
}
|
|
60
|
+
const total = lens[lens.length - 1] || 1;
|
|
61
|
+
const out = [];
|
|
62
|
+
for (let i = 0; i < n; i++) {
|
|
63
|
+
const target = closed ? (i / n) * total : (i / Math.max(1, n - 1)) * total;
|
|
64
|
+
let seg = 1;
|
|
65
|
+
while (seg < lens.length - 1 && lens[seg] < target) seg++;
|
|
66
|
+
const segStart = lens[seg - 1], segEnd = lens[seg];
|
|
67
|
+
const u = segEnd === segStart ? 0 : (target - segStart) / (segEnd - segStart);
|
|
68
|
+
const a = pts[seg - 1], b = pts[seg];
|
|
69
|
+
out.push([a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u]);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resampled vertices back into `path()`'s point-command shape: one `M`, the rest `L`. */
|
|
75
|
+
function verticesToPoints(verts, closed) {
|
|
76
|
+
const cmds = verts.map((v, i) => [i === 0 ? "M" : "L", v[0], v[1]]);
|
|
77
|
+
if (closed) cmds.push(["Z"]);
|
|
78
|
+
return cmds;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Piecewise-linear keyframe reduction: drop a sample when the straight line
|
|
83
|
+
* between its neighbours already predicts it within `epsilon`, so a baked
|
|
84
|
+
* `el.expr()` curve stays small instead of one keyframe per frame.
|
|
85
|
+
*/
|
|
86
|
+
function reduceLinearKeyframes(points, epsilon) {
|
|
87
|
+
if (points.length <= 2) return points;
|
|
88
|
+
const kept = [points[0]];
|
|
89
|
+
let anchor = 0;
|
|
90
|
+
let i = 1;
|
|
91
|
+
while (i < points.length) {
|
|
92
|
+
let j = i;
|
|
93
|
+
while (j + 1 < points.length) {
|
|
94
|
+
const a = points[anchor], b = points[j + 1];
|
|
95
|
+
const span = b.t - a.t || 1;
|
|
96
|
+
let ok = true;
|
|
97
|
+
for (let k = anchor + 1; k <= j; k++) {
|
|
98
|
+
const p = points[k];
|
|
99
|
+
const expected = a.v + (b.v - a.v) * ((p.t - a.t) / span);
|
|
100
|
+
if (Math.abs(p.v - expected) > epsilon) { ok = false; break; }
|
|
101
|
+
}
|
|
102
|
+
if (!ok) break;
|
|
103
|
+
j++;
|
|
104
|
+
}
|
|
105
|
+
kept.push(points[j]);
|
|
106
|
+
anchor = j;
|
|
107
|
+
i = j + 1;
|
|
108
|
+
}
|
|
109
|
+
return kept;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Drop a sample when it repeats the last kept value — for string-valued style tracks. */
|
|
113
|
+
function reduceConstantRuns(points) {
|
|
114
|
+
if (points.length <= 2) return points;
|
|
115
|
+
const kept = [points[0]];
|
|
116
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
117
|
+
if (points[i].v !== kept[kept.length - 1].v) kept.push(points[i]);
|
|
118
|
+
}
|
|
119
|
+
kept.push(points[points.length - 1]);
|
|
120
|
+
return kept;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Ticks per quarter note in a midi clip's `notes`, as the document counts them. */
|
|
124
|
+
/**
|
|
125
|
+
* The asset id a media clip stores. Asset tools print `asset://<id>.<ext>`
|
|
126
|
+
* (often with a 12-character id), while every renderer and the editor read a
|
|
127
|
+
* bare id, so the scheme and extension come off here and `v.save()` resolves
|
|
128
|
+
* a short id to the full one. `package://` and `http(s)://` locators are not
|
|
129
|
+
* asset ids and pass through unchanged.
|
|
130
|
+
*/
|
|
131
|
+
export function clipAssetId(ref) {
|
|
132
|
+
if (typeof ref !== "string") return ref;
|
|
133
|
+
const trimmed = ref.trim();
|
|
134
|
+
if (trimmed.startsWith("asset://")) {
|
|
135
|
+
const rest = trimmed.slice("asset://".length).split(/[?#]/)[0];
|
|
136
|
+
return rest.replace(/\.[A-Za-z0-9]{1,8}$/, "") || rest;
|
|
137
|
+
}
|
|
138
|
+
const hexWithExt = /^([0-9a-f]{12,32})\.[A-Za-z0-9]{1,8}$/.exec(trimmed);
|
|
139
|
+
return hexWithExt ? hexWithExt[1] : trimmed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const MIDI_PPQ = 960;
|
|
143
|
+
/**
|
|
144
|
+
* `[beat, pitch, lenBeats?, velocity?]` tuples (musical time — beats, not
|
|
145
|
+
* seconds; a track's tempo maps them to real time) into the document's
|
|
146
|
+
* `notes: MidiNote[]`.
|
|
147
|
+
*/
|
|
148
|
+
function midiNotes(list, nid) {
|
|
149
|
+
return list.map(([beat, pitch, len = 0.25, velocity = 100]) => ({
|
|
150
|
+
id: nid("n"), pitch, velocity,
|
|
151
|
+
startTick: Math.round(beat * MIDI_PPQ), durationTick: Math.max(1, Math.round(len * MIDI_PPQ))
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const EASE = { in: "easeIn", out: "easeOut", inOut: "easeInOut", outExpo: "easeOutExpo", inExpo: "easeInExpo", linear: "linear" };
|
|
156
|
+
/** Resolve a short ease name (`"outExpo"`) or pass through a raw curve name (`"spring(160,20,1)"`). */
|
|
157
|
+
export const ease = (name) => EASE[name] ?? name;
|
|
158
|
+
|
|
159
|
+
/** A property's neutral pose: what `enter`/`exit` animate to/from by default. */
|
|
160
|
+
const REST_ONE = new Set(["opacity", "scale", "scaleX", "scaleY", "trimEnd", "wipeProgress"]);
|
|
161
|
+
const restOf = (property) => (REST_ONE.has(property) ? 1 : 0);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `animate()`'s default role when the caller names none: "in" is right for
|
|
165
|
+
* most calls (a slide, a scale bump), but a call whose curves end farther
|
|
166
|
+
* from every property's own rest pose than they started — opacity trailing
|
|
167
|
+
* off toward 0, an offset drifting away from center — is shaped like an
|
|
168
|
+
* exit, not an entrance, and defaulting it "in" anchors the window to the
|
|
169
|
+
* clip's start the way `enter()` does instead of hugging the end the way a
|
|
170
|
+
* fade-out needs. Unanimous across every curve, numeric values only —
|
|
171
|
+
* anything else (a glyph.* style-track curve, a curve with fewer than two
|
|
172
|
+
* keyframes) falls through to the safe "in" default.
|
|
173
|
+
*/
|
|
174
|
+
function isExitShapedCurves(curves) {
|
|
175
|
+
if (curves.length === 0) return false;
|
|
176
|
+
return curves.every((curve) => {
|
|
177
|
+
const kfs = curve.keyframes;
|
|
178
|
+
if (!kfs || kfs.length < 2) return false;
|
|
179
|
+
const first = kfs[0].value;
|
|
180
|
+
const last = kfs[kfs.length - 1].value;
|
|
181
|
+
if (typeof first !== "number" || typeof last !== "number") return false;
|
|
182
|
+
const rest = restOf(curve.property);
|
|
183
|
+
return Math.abs(last - rest) > Math.abs(first - rest);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `el.react()`'s default `[quiet, loud]` when the caller gives no `range`. */
|
|
188
|
+
const REACT_DEFAULT_RANGE = { scale: [1, 1.15], opacity: [0.55, 1], offsetX: [0, 24], offsetY: [0, 24] };
|
|
189
|
+
|
|
190
|
+
function keyframesFor(spec) {
|
|
191
|
+
// spec is [from, to] or [from, to, easing] (a cv-style pair), or a list of
|
|
192
|
+
// [t, value] / [t, value, easing] tuples across the animation's own window.
|
|
193
|
+
if (Array.isArray(spec[0])) {
|
|
194
|
+
return spec.map(([t, value, easing]) => (easing ? { t, value, easing: ease(easing) } : { t, value }));
|
|
195
|
+
}
|
|
196
|
+
const [a, b, easing] = spec;
|
|
197
|
+
return [{ t: 0, value: a }, { t: 1, value: b, easing: ease(easing ?? "outExpo") }];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const FIELDS = ["shapeStyle", "textStyle", "effects", "animations", "opacity", "blendMode", "layout", "flexItem", "repeater", "motionBlur",
|
|
201
|
+
"temporalEcho", "mask", "matte", "crop", "borderRadius", "currentAssetId", "caption", "transitionIn", "animationLinks", "steppedTime",
|
|
202
|
+
"inPointMs", "outPointMs", "volumeDb", "fadeInMs", "fadeOutMs", "fadeInShape", "fadeOutShape", "speedMultiplier", "speedBaked", "timeRemap", "muted"];
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `s.text()`'s own top-level options besides `fill` fall into two groups:
|
|
206
|
+
* the ones it destructures itself (size, weight, color, font, tracking,
|
|
207
|
+
* italic, anchor, mw, x, style, path — handled above) and every other
|
|
208
|
+
* general clip option (id/name/parent/at/dur/y/rotation/tx/transform/
|
|
209
|
+
* absolute/inset, plus every `FIELDS` entry) that `makeClip` understands
|
|
210
|
+
* regardless of element type. Anything else is a mistake — most often a
|
|
211
|
+
* textStyle-only field (`fill`'s own bug before this set existed, or
|
|
212
|
+
* `stroke`/`shadow`/`lineHeight`/...) written top-level instead of under
|
|
213
|
+
* `style`, which `text()` used to drop with no error.
|
|
214
|
+
*/
|
|
215
|
+
const TEXT_PASSTHROUGH_KEYS = new Set([
|
|
216
|
+
"id", "name", "parent", "at", "dur", "y", "s", "rotation", "tx", "transform", "absolute", "inset", ...FIELDS
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
function hexToRgba(hex, alpha) {
|
|
220
|
+
const h = hex.replace("#", "");
|
|
221
|
+
const n = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
|
222
|
+
const r = parseInt(n.slice(0, 2), 16), g = parseInt(n.slice(2, 4), 16), b = parseInt(n.slice(4, 6), 16);
|
|
223
|
+
return `rgba(${r},${g},${b},${alpha})`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `component(name, {props, duration}, (s, props) => {...})` — a reusable
|
|
228
|
+
* scene piece, used with `s.use(Comp, {...props}, {at, dur})`. The type/
|
|
229
|
+
* default checking lives in `./components.js`; this wrapper adds
|
|
230
|
+
* `Comp.saveAsComposition(timelines, {width, height, fps, palette, fonts,
|
|
231
|
+
* name, description})`, which needs `video()` (defined below, but hoisted)
|
|
232
|
+
* to build the throwaway documents it probes.
|
|
233
|
+
*/
|
|
234
|
+
export function component(name, spec, build) {
|
|
235
|
+
const Comp = makeComponentDescriptor(name, spec, build);
|
|
236
|
+
Comp.saveAsComposition = (timelines, o = {}) => saveComponentAsComposition(Comp, timelines, o);
|
|
237
|
+
return Comp;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Builds `Comp` once inside a throwaway `video()`/`scene()` (its own fresh id
|
|
242
|
+
* sequence), folds its clips into a group via `s.use()`, and returns that
|
|
243
|
+
* group's direct children — the same set `save_composition`'s server-side
|
|
244
|
+
* extraction will see, since a composition captures only a group's direct
|
|
245
|
+
* children (nested `stack`/`row` grandchildren are not captured — a
|
|
246
|
+
* component built for `saveAsComposition` should stay flat).
|
|
247
|
+
*/
|
|
248
|
+
/**
|
|
249
|
+
* Every clip transitively parented under `rootId` — a nested flex container
|
|
250
|
+
* (`s.stack`/`s.row`) inside the component names *it*, not `rootId`, as
|
|
251
|
+
* `parentId`, so a direct-children-only filter would miss it. Mirrors
|
|
252
|
+
* `descendantIds` in `packages/timeline/src/composition.ts`, which is what
|
|
253
|
+
* `save_composition`'s server-side extraction now walks — matching it here
|
|
254
|
+
* is what lets a prop reaching into a nested container still get a pointer.
|
|
255
|
+
*/
|
|
256
|
+
function descendantIds(clips, rootId) {
|
|
257
|
+
const found = new Set();
|
|
258
|
+
let frontier = new Set([rootId]);
|
|
259
|
+
while (frontier.size > 0) {
|
|
260
|
+
const next = new Set();
|
|
261
|
+
for (const clip of clips) {
|
|
262
|
+
if (clip.id !== rootId && frontier.has(clip.parentId) && !found.has(clip.id)) {
|
|
263
|
+
found.add(clip.id);
|
|
264
|
+
next.add(clip.id);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
frontier = next;
|
|
268
|
+
}
|
|
269
|
+
return found;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function buildComponentProbe(Comp, props, dims) {
|
|
273
|
+
const probe = video(dims);
|
|
274
|
+
let groupId;
|
|
275
|
+
const scene = probe.scene(Comp.name, Comp.duration ?? 2, (s) => {
|
|
276
|
+
groupId = s.use(Comp, props).id;
|
|
277
|
+
});
|
|
278
|
+
const ordered = [...scene.layers].sort((a, b) => a._z - b._z);
|
|
279
|
+
const descendants = descendantIds(ordered, groupId);
|
|
280
|
+
// A JSON round-trip, not a shallow spread: `attachMotion` puts the motion
|
|
281
|
+
// methods (`enter`, `exit`, ...) directly on the clip object as functions,
|
|
282
|
+
// which are never equal between two builds and would swamp the diff below
|
|
283
|
+
// in false positives. The real document a save writes never carries them,
|
|
284
|
+
// so stripping through JSON is also what makes this comparison the same
|
|
285
|
+
// shape `save_composition`'s server-side extraction sees.
|
|
286
|
+
const children = ordered
|
|
287
|
+
.filter((clip) => descendants.has(clip.id))
|
|
288
|
+
.map((clip) => JSON.parse(JSON.stringify(clip)));
|
|
289
|
+
return { probe, scene, groupId, children };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Save `Comp` as a composition: build it once at its declared prop defaults,
|
|
294
|
+
* save that as a scratch source timeline, then — for every declared prop —
|
|
295
|
+
* rebuild with a marker value distinct from its default and diff the two
|
|
296
|
+
* children arrays to find the one JSON pointer the prop reaches
|
|
297
|
+
* (`pointerForProp`). A prop with no single-field effect (unused, structural,
|
|
298
|
+
* or reaching more than one field) is left out of `params` and named in
|
|
299
|
+
* `skippedParams` rather than guessed. Requires `{width, height, fps}` — the
|
|
300
|
+
* frame `Comp` was authored for — since a bare `component()` descriptor
|
|
301
|
+
* carries none of its own.
|
|
302
|
+
*/
|
|
303
|
+
async function saveComponentAsComposition(Comp, timelines, o = {}) {
|
|
304
|
+
const { width, height, fps, palette, fonts, name = Comp.name, description } = o;
|
|
305
|
+
if (!timelines) {
|
|
306
|
+
throw new Error(`${Comp.name}.saveAsComposition(timelines, {...}) — pass the script's own nodetool.timelines.`);
|
|
307
|
+
}
|
|
308
|
+
if (!width || !height || !fps) {
|
|
309
|
+
throw new Error(`${Comp.name}.saveAsComposition(): pass {width, height, fps} — the frame this component targets, same as video({...}).`);
|
|
310
|
+
}
|
|
311
|
+
const dims = { width, height, fps, palette, fonts };
|
|
312
|
+
const defaults = defaultComponentProps(Comp);
|
|
313
|
+
const base = buildComponentProbe(Comp, defaults, dims);
|
|
314
|
+
const params = {};
|
|
315
|
+
const skippedParams = [];
|
|
316
|
+
for (const [propName, spec] of Object.entries(Comp.props)) {
|
|
317
|
+
const marker = markerPropValue(spec, defaults[propName]);
|
|
318
|
+
const marked = buildComponentProbe(Comp, { ...defaults, [propName]: marker }, dims);
|
|
319
|
+
const path = pointerForProp(base.children, marked.children, marker);
|
|
320
|
+
if (path === undefined) {
|
|
321
|
+
skippedParams.push(propName);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const param = { type: spec.type, default: spec.default, path };
|
|
325
|
+
if (spec.description !== undefined) param.description = spec.description;
|
|
326
|
+
params[propName] = param;
|
|
327
|
+
}
|
|
328
|
+
base.probe.series([base.scene]);
|
|
329
|
+
const saved = await base.probe.save(timelines, { name: `${name} (component source)` });
|
|
330
|
+
const savedComposition = await timelines.compositions.save(
|
|
331
|
+
saved.timeline_id,
|
|
332
|
+
base.groupId,
|
|
333
|
+
name,
|
|
334
|
+
params,
|
|
335
|
+
description !== undefined ? { description } : undefined
|
|
336
|
+
);
|
|
337
|
+
return { ...savedComposition, source_timeline_id: saved.timeline_id, skippedParams };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* `video({width, height, fps, palette, fonts})` — the entry point. `palette`
|
|
342
|
+
* supplies colours the craft helpers (`backdrop`, `glow`, `kicker`, `pill`,
|
|
343
|
+
* `streaks`, `finish`) fall back to (`ink`, `ink2`, `text`, `dim`, `accent`).
|
|
344
|
+
* `fonts` maps short keys (`display`, `body`) to real font family names; a
|
|
345
|
+
* text element's `font` option takes either a key or a literal family.
|
|
346
|
+
*/
|
|
347
|
+
/**
|
|
348
|
+
* Write `value` as a JS expression for the retained program `v.save()`
|
|
349
|
+
* prints (docs/timeline-code-capture.md). Plain data only: a function, a
|
|
350
|
+
* class instance or a cycle throws, and the caller reports why the code
|
|
351
|
+
* could not be kept.
|
|
352
|
+
*/
|
|
353
|
+
function printValue(value, seen = []) {
|
|
354
|
+
if (value === undefined) return "undefined";
|
|
355
|
+
if (value === null) return "null";
|
|
356
|
+
const type = typeof value;
|
|
357
|
+
if (type === "string" || type === "boolean") return JSON.stringify(value);
|
|
358
|
+
if (type === "number") {
|
|
359
|
+
if (Object.is(value, -0)) return "-0";
|
|
360
|
+
if (Number.isNaN(value)) return "NaN";
|
|
361
|
+
if (value === Infinity) return "Infinity";
|
|
362
|
+
if (value === -Infinity) return "-Infinity";
|
|
363
|
+
return String(value);
|
|
364
|
+
}
|
|
365
|
+
if (type === "bigint") return `${value}n`;
|
|
366
|
+
if (type === "function") throw new Error("is a function. Declare it at the top level with `function` or `const` so its source is kept");
|
|
367
|
+
if (type !== "object") throw new Error(`is a ${type}, which cannot be written as a value`);
|
|
368
|
+
if (seen.includes(value)) throw new Error("contains a cycle");
|
|
369
|
+
if (value instanceof Date) return `new Date(${value.getTime()})`;
|
|
370
|
+
seen.push(value);
|
|
371
|
+
try {
|
|
372
|
+
if (Array.isArray(value)) {
|
|
373
|
+
const items = [];
|
|
374
|
+
for (let i = 0; i < value.length; i++) items.push(printValue(value[i], seen));
|
|
375
|
+
return `[${items.join(", ")}]`;
|
|
376
|
+
}
|
|
377
|
+
const proto = Object.getPrototypeOf(value);
|
|
378
|
+
if (proto !== Object.prototype && proto !== null) throw new Error("is a class instance, which cannot be written as a value");
|
|
379
|
+
const entries = Object.keys(value).map((key) => {
|
|
380
|
+
// A literal `"__proto__": x` sets the prototype; a computed key does not.
|
|
381
|
+
const printedKey = key === "__proto__" ? `["__proto__"]` : JSON.stringify(key);
|
|
382
|
+
return `${printedKey}: ${printValue(value[key], seen)}`;
|
|
383
|
+
});
|
|
384
|
+
return `{${entries.join(", ")}}`;
|
|
385
|
+
} finally {
|
|
386
|
+
seen.pop();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const TIMELINE_PACK = "@nodetool-ai/sandbox-timeline";
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The top-level fields of `clip` whose JSON differs from `beforeJson`, with
|
|
394
|
+
* their current values. A removed field maps to `undefined`.
|
|
395
|
+
*/
|
|
396
|
+
function clipChanges(beforeJson, clip) {
|
|
397
|
+
const was = JSON.parse(beforeJson);
|
|
398
|
+
const is = JSON.parse(JSON.stringify(clip));
|
|
399
|
+
const changes = {};
|
|
400
|
+
for (const key of new Set([...Object.keys(was), ...Object.keys(is)])) {
|
|
401
|
+
if (JSON.stringify(was[key]) !== JSON.stringify(is[key])) changes[key] = is[key];
|
|
402
|
+
}
|
|
403
|
+
return changes;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Changes per clip id between the JSON in `before` and `clips` now, or null when a clip was added or removed. */
|
|
407
|
+
function clipPatch(before, clips) {
|
|
408
|
+
if (before.size !== clips.length) return null;
|
|
409
|
+
const patch = {};
|
|
410
|
+
for (const clip of clips) {
|
|
411
|
+
const beforeJson = before.get(clip.id);
|
|
412
|
+
if (beforeJson === undefined) return null;
|
|
413
|
+
if (beforeJson === JSON.stringify(clip)) continue;
|
|
414
|
+
patch[clip.id] = clipChanges(beforeJson, clip);
|
|
415
|
+
}
|
|
416
|
+
return patch;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function video(options = {}) {
|
|
420
|
+
const { width, height, fps, palette = {}, fonts = {} } = options;
|
|
421
|
+
const W = width, H = height, FPS = fps;
|
|
422
|
+
// Clip/effect/animation ids are deterministic and scoped, not one global
|
|
423
|
+
// counter: a counter shared by the whole script means editing scene B (or
|
|
424
|
+
// even a helper called before scene A in the script) shifts every id after
|
|
425
|
+
// the edit point, including scene A's — which breaks the editor's selection
|
|
426
|
+
// across a rebake and defeats `mergeTimelineSource`'s hash-of-subtree
|
|
427
|
+
// untouched check (a scene whose *content* did not change still hashes
|
|
428
|
+
// differently because its ids moved). Each scope — a scene's own name, or
|
|
429
|
+
// a stable key for a non-scene element — gets its own counter, so an id is
|
|
430
|
+
// `${scope}_${prefix}${n}`: identical for two bakes of the same code, and
|
|
431
|
+
// unaffected by anything outside its own scope.
|
|
432
|
+
const idCounters = new Map();
|
|
433
|
+
const nid = (prefix, scope = "root") => {
|
|
434
|
+
const n = (idCounters.get(scope) ?? 0) + 1;
|
|
435
|
+
idCounters.set(scope, n);
|
|
436
|
+
return `${scope}_${prefix}${n}`;
|
|
437
|
+
};
|
|
438
|
+
/** The id scope for an element built inside a scene: that scene's own name. */
|
|
439
|
+
const sceneScope = (ctx) => (ctx && ctx.scene && ctx.scene.name ? String(ctx.scene.name) : "root");
|
|
440
|
+
/** Snap a second value to the frame grid and convert to ms. */
|
|
441
|
+
const msFor = (sec) => Math.round(Math.round((sec ?? 0) * FPS) * 1000 / FPS);
|
|
442
|
+
|
|
443
|
+
const v = { width: W, height: H, fps: FPS };
|
|
444
|
+
/** `el.react()` calls waiting for `v.save()` to bake them onto the saved document. */
|
|
445
|
+
v._pendingReacts = [];
|
|
446
|
+
/**
|
|
447
|
+
* What `v.save()` needs to print the retained program: the options this
|
|
448
|
+
* video was made with, each captured scene, the series, and the document
|
|
449
|
+
* as `v.series()` left it. See docs/timeline-code-capture.md.
|
|
450
|
+
*/
|
|
451
|
+
const capture = { options: undefined, optionsError: undefined, scenes: [], series: null, afterSeries: null };
|
|
452
|
+
try {
|
|
453
|
+
capture.options = printValue(options);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
capture.optionsError = `video() options ${error.message}.`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// -------------------------------------------------------------------------
|
|
459
|
+
// Elements: plain clip objects with motion methods attached.
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Fill an effect list's bookkeeping — `id` and `enabled: true` — the
|
|
463
|
+
* document schema requires but an author has no reason to hand-write.
|
|
464
|
+
* A value already present (an explicit `id`, `enabled: false` to author a
|
|
465
|
+
* disabled effect) is kept as authored.
|
|
466
|
+
*/
|
|
467
|
+
function fillEffects(effects, scope) {
|
|
468
|
+
return effects?.map((fx) => ({ ...fx, id: fx.id ?? nid("fx", scope), enabled: fx.enabled ?? true }));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function makeClip(mediaType, ctx, o) {
|
|
472
|
+
const at = o.at ?? 0;
|
|
473
|
+
const dur = o.dur ?? (ctx.durationSec - at);
|
|
474
|
+
const startMs = ctx.startMs + msFor(at);
|
|
475
|
+
const durationMs = Math.max(1, msFor(dur));
|
|
476
|
+
const scope = sceneScope(ctx);
|
|
477
|
+
const clip = {
|
|
478
|
+
id: o.id ?? nid(mediaType[0], scope), name: o.name ?? mediaType, startMs, durationMs,
|
|
479
|
+
mediaType, sourceType: "imported", status: "generated", locked: false, versions: [],
|
|
480
|
+
parentId: o.parent ?? ctx.scene.id,
|
|
481
|
+
// Private authoring-time bookkeeping (stripped in v.series(), same as
|
|
482
|
+
// _pathPoints/_staggerAt/_lastStagger): the scope this clip's own id,
|
|
483
|
+
// effect ids and animation ids were minted under, so a motion method
|
|
484
|
+
// attached below (which only ever sees `clip`, not `ctx`) can keep
|
|
485
|
+
// minting into the same scope.
|
|
486
|
+
_scope: scope
|
|
487
|
+
};
|
|
488
|
+
if (mediaType !== "adjustment") {
|
|
489
|
+
// `rotation` (degrees, the author-facing unit everywhere else in this
|
|
490
|
+
// API) and `anchor` ({x, y} pivot fraction, default {0.5, 0.5}) are
|
|
491
|
+
// convenience options for the two `transform` fields callers reach for
|
|
492
|
+
// most; `tx` remains the raw escape hatch for the rest (rotationX/Y,
|
|
493
|
+
// perspective, depthPx, …). An explicit `o.transform` wins outright.
|
|
494
|
+
const extra = { ...(o.tx ?? {}) };
|
|
495
|
+
if (o.rotation !== undefined) extra.rotation = rad(o.rotation);
|
|
496
|
+
if (o.anchor !== undefined) extra.anchor = o.anchor;
|
|
497
|
+
clip.transform = o.transform ?? tf(o.x ?? 0, o.y ?? 0, o.s ?? 1, extra);
|
|
498
|
+
}
|
|
499
|
+
for (const k of FIELDS) if (o[k] !== undefined) clip[k] = o[k];
|
|
500
|
+
if (clip.effects) clip.effects = fillEffects(clip.effects, scope);
|
|
501
|
+
// `absolute: true` is sugar for the flex item shape a background plate
|
|
502
|
+
// (a pill's fill, a card's backdrop) needs: taken out of flow, sized to
|
|
503
|
+
// its flex-parent's own computed box. `inset` (default 0, fully covering)
|
|
504
|
+
// takes the same px/edges shape as everywhere else. Only meaningful when
|
|
505
|
+
// this clip's `parentId` is itself a flex container.
|
|
506
|
+
if (o.absolute) clip.flexItem = { ...(clip.flexItem ?? {}), position: "absolute", inset: o.inset ?? 0 };
|
|
507
|
+
clip._z = ctx.scene.layers.length;
|
|
508
|
+
ctx.scene.layers.push(clip);
|
|
509
|
+
attachMotion(clip);
|
|
510
|
+
return clip;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function tf(x = 0, y = 0, s = 1, extra = {}) {
|
|
514
|
+
return { position: { x, y }, scale: { x: s, y: s }, rotation: 0, anchor: { x: 0.5, y: 0.5 }, ...extra };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* SVG path data for points in px from the frame centre, normalized to the
|
|
519
|
+
* frame. Most commands (`M`/`L`/`T`/`C`/`S`/`Q`) are pure x,y pairs and
|
|
520
|
+
* normalize by alternating X/Y; `H`/`V` take one coordinate on their own
|
|
521
|
+
* axis, and `A rx ry xRotDeg largeArc sweep x y` mixes two radii (scaled as
|
|
522
|
+
* sizes, by W and H respectively), an angle and two flags (passed through
|
|
523
|
+
* unscaled) with a trailing point. Lives at this scope (not inside
|
|
524
|
+
* `makeSceneApi`) so `el.morph()`, which builds a `d` string outside any
|
|
525
|
+
* scene's own closure, can call it too.
|
|
526
|
+
*/
|
|
527
|
+
function pathDataFor(points) {
|
|
528
|
+
const X = (val) => ((W / 2 + val) / W).toFixed(5);
|
|
529
|
+
const Y = (val) => ((H / 2 + val) / H).toFixed(5);
|
|
530
|
+
const RX = (val) => (val / W).toFixed(5);
|
|
531
|
+
const RY = (val) => (val / H).toFixed(5);
|
|
532
|
+
return points.map(([cmd, ...args]) => {
|
|
533
|
+
if (cmd === "Z" || cmd === "z") return cmd;
|
|
534
|
+
if (cmd === "H" || cmd === "h") return cmd + X(args[0]);
|
|
535
|
+
if (cmd === "V" || cmd === "v") return cmd + Y(args[0]);
|
|
536
|
+
if (cmd === "A" || cmd === "a") {
|
|
537
|
+
const [rx, ry, xRotDeg, largeArc, sweep, x, y] = args;
|
|
538
|
+
return cmd + [RX(rx), RY(ry), xRotDeg, largeArc, sweep, X(x), Y(y)].join(" ");
|
|
539
|
+
}
|
|
540
|
+
return cmd + args.map((val, i) => (i % 2 ? Y(val) : X(val))).join(" ");
|
|
541
|
+
}).join(" ");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function pushAnim(clip, partial) {
|
|
545
|
+
clip.animations = [...(clip.animations ?? []), { id: nid("a", clip._scope), ...partial }];
|
|
546
|
+
return clip;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Attach the motion primitives to a clip in place. `at`/`dur` on every
|
|
551
|
+
* primitive are seconds local to the clip's own start (frame `0` is the
|
|
552
|
+
* moment the clip appears) unless `stagger()` set a default `at` for it.
|
|
553
|
+
*/
|
|
554
|
+
function attachMotion(clip) {
|
|
555
|
+
clip.enter = (o = {}) => {
|
|
556
|
+
const { from, at = clip._staggerAt ?? 0, dur = 0.3, ease: e = "outExpo", by, staggerMs, mask } = o;
|
|
557
|
+
const curves = Object.entries(from).map(([prop, val]) => ({ property: prop, ...curvePair(rotDeg(prop, val), restOf(prop), e) }));
|
|
558
|
+
const stagger = staggerOpt(by, staggerMs);
|
|
559
|
+
// Remembered so a `tween()` authored alongside this `enter()` — the
|
|
560
|
+
// common case for a glyph.* style track riding the same reveal — picks
|
|
561
|
+
// up the same per-character/word/line split without repeating `by`.
|
|
562
|
+
if (stagger.stagger) clip._lastStagger = stagger.stagger;
|
|
563
|
+
pushAnim(clip, { role: "in", delayMs: msFor(at), durationMs: msFor(dur), preset: "custom", custom: { curves, ...maskOpt(mask) }, ...stagger });
|
|
564
|
+
return clip;
|
|
565
|
+
};
|
|
566
|
+
clip.exit = (o = {}) => {
|
|
567
|
+
const { to, dur = 0.3, ease: e = "inExpo" } = o;
|
|
568
|
+
const at = o.at ?? Math.max(0, clip.durationMs / 1000 - dur);
|
|
569
|
+
const curves = Object.entries(to).map(([prop, val]) => ({ property: prop, ...curvePair(restOf(prop), rotDeg(prop, val), e) }));
|
|
570
|
+
// The compiler places an "out" role's window by counting `delayMs` back
|
|
571
|
+
// from the clip's own end (windowEndMs = clipDurationMs - delayMs), not
|
|
572
|
+
// forward from the start like every other role. `at`/`dur` here are
|
|
573
|
+
// authored the same way as everywhere else in this API — seconds from
|
|
574
|
+
// the clip's own start — so convert to that end-relative offset here,
|
|
575
|
+
// once, rather than asking every caller to do the arithmetic.
|
|
576
|
+
const windowEndMs = msFor(at + dur);
|
|
577
|
+
const delayMs = Math.max(0, clip.durationMs - windowEndMs);
|
|
578
|
+
pushAnim(clip, { role: "out", delayMs, durationMs: msFor(dur), preset: "custom", custom: { curves } });
|
|
579
|
+
return clip;
|
|
580
|
+
};
|
|
581
|
+
clip.animate = (props, o = {}) => {
|
|
582
|
+
const { at = clip._staggerAt ?? 0, dur = 0.3, ease: e = "outExpo", by, staggerMs, mask } = o;
|
|
583
|
+
const curves = Object.entries(props).map(([prop, spec]) => ({ property: prop, keyframes: keyframesFor(rotDegSpec(prop, withEase(spec, e))) }));
|
|
584
|
+
// "in" unless the caller named a role: an unnamed role that looks like
|
|
585
|
+
// an exit (see isExitShapedCurves) gets "out" instead, so its window
|
|
586
|
+
// anchors to the clip's end the way exit() already does.
|
|
587
|
+
const role = o.role ?? (isExitShapedCurves(curves) ? "out" : "in");
|
|
588
|
+
const stagger = staggerOpt(by, staggerMs);
|
|
589
|
+
if (stagger.stagger) clip._lastStagger = stagger.stagger;
|
|
590
|
+
// `at`/`dur` are always authored the same way as every other role: seconds
|
|
591
|
+
// from the clip's own start. The compiler places an "out" role's window
|
|
592
|
+
// by counting `delayMs` back from the clip's own end, not forward from
|
|
593
|
+
// the start (see exit(), above) — so a role of "out" here, whether named
|
|
594
|
+
// explicitly or picked by isExitShapedCurves, needs the same forward-to
|
|
595
|
+
// backward conversion exit() already does, once, rather than asking the
|
|
596
|
+
// caller to do that arithmetic or reason about which role they got.
|
|
597
|
+
const delayMs = role === "out" ? Math.max(0, clip.durationMs - msFor(at + dur)) : msFor(at);
|
|
598
|
+
pushAnim(clip, { role, delayMs, durationMs: msFor(dur), preset: "custom", custom: { curves, ...maskOpt(mask) }, ...stagger });
|
|
599
|
+
return clip;
|
|
600
|
+
};
|
|
601
|
+
clip.loop = (props, period, o = {}) => {
|
|
602
|
+
const { ease: e = "linear" } = o;
|
|
603
|
+
const curves = Object.entries(props).map(([prop, spec]) => ({ property: prop, keyframes: keyframesFor(rotDegSpec(prop, withEase(spec, e))) }));
|
|
604
|
+
pushAnim(clip, { role: "loop", delayMs: 0, durationMs: msFor(period), preset: "custom", custom: { curves } });
|
|
605
|
+
return clip;
|
|
606
|
+
};
|
|
607
|
+
clip.count = (o = {}) => {
|
|
608
|
+
const { from, to, at = clip._staggerAt ?? 0, dur = 1, prefix, suffix, decimals, padTo, groupSeparator, ease: e = "outExpo" } = o;
|
|
609
|
+
// No property curve drives this reveal — it is `textAnimator` alone —
|
|
610
|
+
// so `custom.curves` is empty rather than the `NOOP` 1→1 opacity curve
|
|
611
|
+
// this used to carry. `styleOnly` in `animation/compile.ts` already
|
|
612
|
+
// treats an empty-curves custom animation with a `textAnimator` as
|
|
613
|
+
// valid; a curve that never changes just added dead weight to the
|
|
614
|
+
// document for the compiler to skip.
|
|
615
|
+
pushAnim(clip, { role: "in", delayMs: msFor(at), durationMs: msFor(dur), preset: "custom", custom: { curves: [] }, textAnimator: { kind: "ticker", from, to, prefix, suffix, decimals, padTo, groupSeparator }, easing: ease(e) });
|
|
616
|
+
return clip;
|
|
617
|
+
};
|
|
618
|
+
/** A scramble-in reveal: the ticker sibling, `kind: "scramble"`. */
|
|
619
|
+
clip.scramble = (o = {}) => {
|
|
620
|
+
const { at = clip._staggerAt ?? 0, dur = 0.5, charset, seed, ease: e = "outExpo" } = o;
|
|
621
|
+
pushAnim(clip, { role: "in", delayMs: msFor(at), durationMs: msFor(dur), preset: "custom", custom: { curves: [NOOP] }, textAnimator: { kind: "scramble", charset, seed }, easing: ease(e) });
|
|
622
|
+
return clip;
|
|
623
|
+
};
|
|
624
|
+
clip.tween = (target, keyframes, o = {}) => {
|
|
625
|
+
const { at = clip._staggerAt ?? 0, dur = 0.3, by, staggerMs } = o;
|
|
626
|
+
const kfs = keyframes.map(([t, value, easing]) => (easing ? { t, value, easing: ease(easing) } : { t, value }));
|
|
627
|
+
// A `glyph.*` target (glyph.color, glyph.blurPx, glyph.trackingPx, …)
|
|
628
|
+
// needs a stagger or the document validator refuses it — the whole
|
|
629
|
+
// point of a per-glyph style track is to ride a per-character/word/line
|
|
630
|
+
// split, not animate as one block. `by` here works the same as on
|
|
631
|
+
// `enter`/`animate`; left unset, a tween authored right after an
|
|
632
|
+
// `enter()`/`animate()` call on this same clip inherits THAT call's
|
|
633
|
+
// stagger, so the common case (a glyph.color tween riding the same
|
|
634
|
+
// by:"character" reveal as its enter) needs no repeated `by`.
|
|
635
|
+
const stagger = by ? staggerOpt(by, staggerMs) : clip._lastStagger ? { stagger: clip._lastStagger } : {};
|
|
636
|
+
pushAnim(clip, { role: "in", delayMs: msFor(at), durationMs: msFor(dur), preset: "custom", custom: { curves: [NOOP] }, styleTracks: [{ target, keyframes: kfs }], ...stagger });
|
|
637
|
+
return clip;
|
|
638
|
+
};
|
|
639
|
+
clip.draw = (o = {}) => clip.enter({ from: { trimEnd: 0 }, at: o.at, dur: o.dur ?? 0.5, ease: o.ease ?? "outExpo", mask: o.mask });
|
|
640
|
+
/**
|
|
641
|
+
* A catalog animation by name — `"pop"`, `"shake"`, `"spin"`, `"float"`,
|
|
642
|
+
* `"rotate"`, `"hueShift"`, `"kenBurns"`, `"pulse"`, `"bounce"`,
|
|
643
|
+
* `"squash"`, `"breathe"`, `"orbit"`, `"followPath"`, `"wipe"`, `"blur"`,
|
|
644
|
+
* `"colorFade"`, `"flash"`, `"slide"`, `"fade"`, `"typewriter"` — every
|
|
645
|
+
* preset the document's animation compiler knows, baked server-side from
|
|
646
|
+
* its own curve generator rather than hand-authored keyframes here. `o`'s
|
|
647
|
+
* own named fields (`at`, `dur`, `ease`, `role`, `by`, `staggerMs`)
|
|
648
|
+
* control the animation window; every other key in `o` is a preset
|
|
649
|
+
* param, passed through as-is (read a preset's `PresetParamSpec[]` in
|
|
650
|
+
* `packages/timeline/src/animation/presets.ts` for its names and
|
|
651
|
+
* defaults).
|
|
652
|
+
*/
|
|
653
|
+
clip.preset = (name, o = {}) => {
|
|
654
|
+
const { at = clip._staggerAt ?? 0, dur = 0.5, ease: e, role = "in", by, staggerMs, ...params } = o;
|
|
655
|
+
pushAnim(clip, {
|
|
656
|
+
role, preset: name, delayMs: msFor(at), durationMs: msFor(dur),
|
|
657
|
+
...(e !== undefined ? { easing: ease(e) } : {}),
|
|
658
|
+
...(Object.keys(params).length ? { params } : {}),
|
|
659
|
+
...staggerOpt(by, staggerMs)
|
|
660
|
+
});
|
|
661
|
+
return clip;
|
|
662
|
+
};
|
|
663
|
+
/**
|
|
664
|
+
* A typewriter reveal: `set_timeline_document` resolves `dur` to one
|
|
665
|
+
* reveal per character. Replaces the clip's animations — a typewriter is
|
|
666
|
+
* the whole entrance, not one animation among others. `o.caret`:
|
|
667
|
+
* `{color, widthPx, blinkPeriodMs}`.
|
|
668
|
+
*/
|
|
669
|
+
clip.typewriter = (o = {}) => {
|
|
670
|
+
const { at = 0, dur = 1, caret } = o;
|
|
671
|
+
clip.animations = [{ id: nid("a", clip._scope), role: "in", preset: "typewriter", delayMs: msFor(at), durationMs: msFor(dur), ...(caret ? { caret } : {}) }];
|
|
672
|
+
return clip;
|
|
673
|
+
};
|
|
674
|
+
/**
|
|
675
|
+
* A hand-written motion function, baked to a keyframe-per-frame custom
|
|
676
|
+
* animation at authoring time — nothing runs at render time (per
|
|
677
|
+
* docs/timeline-custom-animations.md). `fn(t, {p, frame, fps})` is
|
|
678
|
+
* sampled once per frame across the window: `t` is seconds local to the
|
|
679
|
+
* window, `p` its 0..1 progress. `fn` returns an object of animatable
|
|
680
|
+
* properties (`opacity`, `offsetX`, `scale`, …) and/or style-track
|
|
681
|
+
* targets (any key with a `.`, e.g. `"text.color"`, `"shape.fill"`).
|
|
682
|
+
* A property whose samples never change emits no curve at all; a curve
|
|
683
|
+
* that does change is reduced to the fewest keyframes a straight-line
|
|
684
|
+
* interpolation still reproduces within a small tolerance, not one
|
|
685
|
+
* keyframe per frame.
|
|
686
|
+
*/
|
|
687
|
+
clip.expr = (fn, o = {}) => {
|
|
688
|
+
const { at = clip._staggerAt ?? 0, dur = clip.durationMs / 1000, role = "in" } = o;
|
|
689
|
+
const frameCount = Math.max(1, Math.round(dur * FPS));
|
|
690
|
+
const samples = [];
|
|
691
|
+
for (let i = 0; i <= frameCount; i++) {
|
|
692
|
+
const p = i / frameCount;
|
|
693
|
+
samples.push({ p, values: fn(p * dur, { p, frame: i, fps: FPS }) ?? {} });
|
|
694
|
+
}
|
|
695
|
+
const keys = Object.keys(samples[0].values);
|
|
696
|
+
const curves = [];
|
|
697
|
+
const styleTracks = [];
|
|
698
|
+
for (const key of keys) {
|
|
699
|
+
const pts = samples.map((s) => ({ t: s.p, v: s.values[key] }));
|
|
700
|
+
if (key.includes(".")) {
|
|
701
|
+
const kept = reduceConstantRuns(pts);
|
|
702
|
+
if (kept.length > 1 || kept[0].v !== pts[0].v) styleTracks.push({ target: key, keyframes: kept.map((pt) => ({ t: pt.t, value: pt.v })) });
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
if (pts.every((pt) => Math.abs(pt.v - pts[0].v) < 1e-6)) continue; // constant — no curve
|
|
706
|
+
const kept = reduceLinearKeyframes(pts, 1e-4);
|
|
707
|
+
curves.push({ property: key, keyframes: kept.map((pt) => ({ t: pt.t, value: pt.v })) });
|
|
708
|
+
}
|
|
709
|
+
if (curves.length === 0 && styleTracks.length === 0) return clip; // fn produced no motion
|
|
710
|
+
pushAnim(clip, {
|
|
711
|
+
role, delayMs: msFor(at), durationMs: msFor(dur), preset: "custom",
|
|
712
|
+
custom: { curves: curves.length ? curves : [NOOP] },
|
|
713
|
+
...(styleTracks.length ? { styleTracks } : {})
|
|
714
|
+
});
|
|
715
|
+
return clip;
|
|
716
|
+
};
|
|
717
|
+
/**
|
|
718
|
+
* Morphs a path shape's outline toward `toPoints` (the same point
|
|
719
|
+
* commands `s.path()` takes) via a `shape.d` style track. Only straight
|
|
720
|
+
* segments (`M`/`L`) morph — the document's morph only interpolates when
|
|
721
|
+
* both paths share the same command sequence and token count
|
|
722
|
+
* (`morphCompatiblePath`, and it refuses arcs outright), so both the
|
|
723
|
+
* clip's own current outline and the target are resampled to the same
|
|
724
|
+
* vertex count by even arc length, and the clip's own `shapeStyle.d` is
|
|
725
|
+
* rewritten to that resampled start shape so the morph's first frame
|
|
726
|
+
* matches what was already authored. A curved (`C`/`A`/…) path throws —
|
|
727
|
+
* approximate it with enough `L` segments first.
|
|
728
|
+
*/
|
|
729
|
+
clip.morph = (toPoints, o = {}) => {
|
|
730
|
+
const { at = clip._staggerAt ?? 0, dur = 0.4, ease: e = "outExpo" } = o;
|
|
731
|
+
if (!clip._pathPoints) throw new Error("morph(): the clip must be created with s.path(points, o) — its own points are what gets resampled toward the target");
|
|
732
|
+
for (const points of [clip._pathPoints, toPoints]) {
|
|
733
|
+
if (points.some(([cmd]) => !["M", "L", "Z", "z"].includes(cmd))) {
|
|
734
|
+
throw new Error(`morph(): only straight (M/L/Z) paths morph; approximate curves with more L segments first (got "${points.find(([cmd]) => !["M", "L", "Z", "z"].includes(cmd))?.[0]}")`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const from = polylineFromPoints(clip._pathPoints);
|
|
738
|
+
const to = polylineFromPoints(toPoints);
|
|
739
|
+
if (from.verts.length < 2 || to.verts.length < 2) throw new Error("morph(): both shapes need at least two points");
|
|
740
|
+
const n = Math.max(from.verts.length, to.verts.length, 3);
|
|
741
|
+
const closed = from.closed || to.closed;
|
|
742
|
+
const fromD = pathDataFor(verticesToPoints(resamplePolyline(from.verts, closed, n), closed));
|
|
743
|
+
const toD = pathDataFor(verticesToPoints(resamplePolyline(to.verts, closed, n), closed));
|
|
744
|
+
clip.shapeStyle.d = fromD;
|
|
745
|
+
clip.tween("shape.d", [[0, fromD], [1, toD, e]], { at, dur });
|
|
746
|
+
return clip;
|
|
747
|
+
};
|
|
748
|
+
/**
|
|
749
|
+
* Drive `prop` from a piece of audio — no per-frame JS. Records the
|
|
750
|
+
* request; `v.save()` bakes it onto this clip right after the document
|
|
751
|
+
* is written, through `bake_audio_animation` (the same op
|
|
752
|
+
* `nodetool.timelines.bakeAudioAnimation` exposes directly), additive
|
|
753
|
+
* with anything hand-keyframed on the same property. `audioClip` is the
|
|
754
|
+
* clip `s.audio()`/`v.audio()` returned — its `.id` must be in the saved
|
|
755
|
+
* document, which is why this only takes effect at save time, not now.
|
|
756
|
+
*
|
|
757
|
+
* `o`: `prop` (`"scale"|"opacity"|"offsetX"|"offsetY"`), `mode`
|
|
758
|
+
* (`"envelope"` follows loudness continuously, `"beats"` pulses on each
|
|
759
|
+
* onset; default `"envelope"`), `range` (`[quiet, loud]`, defaulted per
|
|
760
|
+
* `prop`), `gain` (sensitivity: higher reaches the loud end of `range`
|
|
761
|
+
* at a quieter level), `smooth` (seconds, sets both `attack`/`release`),
|
|
762
|
+
* `attack`/`release` (seconds), `offset` (seconds, shifts the whole
|
|
763
|
+
* curve along the timeline), `replace` (default true — overwrite an
|
|
764
|
+
* earlier bake on this property rather than stack another one).
|
|
765
|
+
*
|
|
766
|
+
* **No frequency band.** `bake_audio_animation` measures the full mix
|
|
767
|
+
* (`envelope`) or its onsets (`beats`); there is no band-pass filter
|
|
768
|
+
* behind it. `o.band` throws rather than silently reacting to the whole
|
|
769
|
+
* mix under a name that promises otherwise — isolate that range in the
|
|
770
|
+
* source audio first if the cut needs it.
|
|
771
|
+
*/
|
|
772
|
+
clip.react = (audioClip, o = {}) => {
|
|
773
|
+
if (o.band !== undefined) {
|
|
774
|
+
throw new Error(
|
|
775
|
+
`${clip.name || clip.id}.react(): band-pass audio-reactive motion isn't supported — ` +
|
|
776
|
+
'bake_audio_animation only measures the full mix (mode: "envelope") or its onsets ' +
|
|
777
|
+
'(mode: "beats"), never one frequency band. Drop `band`, or isolate that range in the ' +
|
|
778
|
+
"source audio before importing it."
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
if (!audioClip || typeof audioClip.id !== "string") {
|
|
782
|
+
throw new Error(`${clip.name || clip.id}.react(): pass the clip s.audio()/v.audio() returned, not an asset id.`);
|
|
783
|
+
}
|
|
784
|
+
const property = o.prop ?? o.property;
|
|
785
|
+
if (!property) throw new Error(`${clip.name || clip.id}.react(): 'prop' is required — scale, opacity, offsetX or offsetY.`);
|
|
786
|
+
const range = o.range ?? REACT_DEFAULT_RANGE[property];
|
|
787
|
+
if (!range) throw new Error(`${clip.name || clip.id}.react(): no default range for "${property}" — pass range: [quiet, loud].`);
|
|
788
|
+
const { mode = "envelope", gain, smooth, attack = smooth, release = smooth, offset = 0, tolerance, maxPoints, frameMs, maxSeconds, replace } = o;
|
|
789
|
+
v._pendingReacts.push({
|
|
790
|
+
audioClipId: audioClip.id,
|
|
791
|
+
targetClipId: clip.id,
|
|
792
|
+
params: {
|
|
793
|
+
property, mode, output_range: range,
|
|
794
|
+
...(gain !== undefined ? { sensitivity: gain } : {}),
|
|
795
|
+
...(attack !== undefined ? { attack_ms: msFor(attack) } : {}),
|
|
796
|
+
...(release !== undefined ? { release_ms: msFor(release) } : {}),
|
|
797
|
+
offset_ms: msFor(offset),
|
|
798
|
+
...(tolerance !== undefined ? { tolerance } : {}),
|
|
799
|
+
...(maxPoints !== undefined ? { max_points: maxPoints } : {}),
|
|
800
|
+
...(frameMs !== undefined ? { frame_ms: frameMs } : {}),
|
|
801
|
+
...(maxSeconds !== undefined ? { max_seconds: maxSeconds } : {}),
|
|
802
|
+
...(replace !== undefined ? { replace } : {})
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
return clip;
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function curvePair(from, to, easing) { return { keyframes: [{ t: 0, value: from }, { t: 1, value: to, easing: ease(easing) }] }; }
|
|
810
|
+
/**
|
|
811
|
+
* `rotation` is the one curve property authored in degrees, like the
|
|
812
|
+
* friendly `rotation` option at element creation (`rad()`, near
|
|
813
|
+
* `makeClip`) — every other curve property, including `rotationX`/
|
|
814
|
+
* `rotationY` (already degrees in the document) and `tx`'s raw escape
|
|
815
|
+
* hatch, takes its document unit as-is. `enter()`/`exit()` pass one scalar
|
|
816
|
+
* per property; `rotDegSpec` does the same for `animate()`/`loop()`'s
|
|
817
|
+
* `[from, to]`/waypoint spec shape.
|
|
818
|
+
*/
|
|
819
|
+
function rotDeg(prop, value) { return prop === "rotation" && typeof value === "number" ? rad(value) : value; }
|
|
820
|
+
function rotDegSpec(prop, spec) {
|
|
821
|
+
if (prop !== "rotation") return spec;
|
|
822
|
+
if (Array.isArray(spec[0])) return spec.map((tuple) => [tuple[0], rotDeg(prop, tuple[1]), ...tuple.slice(2)]);
|
|
823
|
+
const [a, b, e] = spec;
|
|
824
|
+
return e !== undefined ? [rotDeg(prop, a), rotDeg(prop, b), e] : [rotDeg(prop, a), rotDeg(prop, b)];
|
|
825
|
+
}
|
|
826
|
+
function withEase(spec, defaultEase) {
|
|
827
|
+
if (Array.isArray(spec[0])) return spec.map((tuple) => (tuple.length === 3 ? tuple : [...tuple]));
|
|
828
|
+
const [a, b, e] = spec;
|
|
829
|
+
return [a, b, e ?? defaultEase];
|
|
830
|
+
}
|
|
831
|
+
function staggerOpt(by, staggerMs) { return by ? { stagger: { unit: by, offsetMs: staggerMs ?? 90 } } : {}; }
|
|
832
|
+
/** `mask: {direction, softness}` — required by the document whenever a curve drives `wipeProgress`. */
|
|
833
|
+
function maskOpt(mask) { return mask ? { mask } : {}; }
|
|
834
|
+
|
|
835
|
+
/** A curve that changes nothing, for an animation that only carries a text animator or a style track. */
|
|
836
|
+
const NOOP = { property: "opacity", keyframes: [{ t: 0, value: 1 }, { t: 1, value: 1, easing: "linear" }] };
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Offset each item's default motion `at` by its index times `offsetSec`,
|
|
840
|
+
* then call `fn(item, index)`. `fn` calling `.enter()`/`.animate()`/`.count()`
|
|
841
|
+
* with no explicit `at` picks up that offset.
|
|
842
|
+
*/
|
|
843
|
+
function stagger(items, offsetSec, fn) {
|
|
844
|
+
items.forEach((item, i) => {
|
|
845
|
+
item._staggerAt = i * offsetSec;
|
|
846
|
+
fn(item, i);
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// -------------------------------------------------------------------------
|
|
851
|
+
// Layout containers.
|
|
852
|
+
|
|
853
|
+
function reparent(el, parentId) { el.parentId = parentId; return el; }
|
|
854
|
+
|
|
855
|
+
function group(ctx, o = {}) {
|
|
856
|
+
return makeClip("group", ctx, o);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Named anchor points, `{x, y}` fractions of the container's own computed box. */
|
|
860
|
+
const ANCHOR_NAMES = {
|
|
861
|
+
"top-left": { x: 0, y: 0 }, top: { x: 0.5, y: 0 }, "top-right": { x: 1, y: 0 },
|
|
862
|
+
left: { x: 0, y: 0.5 }, center: { x: 0.5, y: 0.5 }, right: { x: 1, y: 0.5 },
|
|
863
|
+
"bottom-left": { x: 0, y: 1 }, bottom: { x: 0.5, y: 1 }, "bottom-right": { x: 1, y: 1 }
|
|
864
|
+
};
|
|
865
|
+
function resolveAnchor(anchor) {
|
|
866
|
+
if (anchor === undefined) return { x: 0.5, y: 0.5 };
|
|
867
|
+
if (typeof anchor !== "string") return anchor;
|
|
868
|
+
const named = ANCHOR_NAMES[anchor];
|
|
869
|
+
if (!named) throw new Error(`anchor: unknown name ${JSON.stringify(anchor)} — pass {x, y} (0..1) or one of ${Object.keys(ANCHOR_NAMES).join(", ")}.`);
|
|
870
|
+
return named;
|
|
871
|
+
}
|
|
872
|
+
const JUSTIFY_NAMES = { start: "flex-start", center: "center", end: "flex-end", between: "space-between", around: "space-around", evenly: "space-evenly" };
|
|
873
|
+
const ALIGN_NAMES = { start: "flex-start", center: "center", end: "flex-end", stretch: "stretch", baseline: "baseline" };
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* `direction` `"row"` | `"column"`. A real CSS/Yoga flex container: its
|
|
877
|
+
* `children` become its REAL children (`parentId`), laid out by the
|
|
878
|
+
* document's own flex resolver — nothing here computes a position. The
|
|
879
|
+
* container's own placement in the frame is `at` (default the frame
|
|
880
|
+
* centre) + `anchor` (default `"center"`): `at` is canvas-centre-relative,
|
|
881
|
+
* the same coordinate space every clip's own position already is (0,0 is
|
|
882
|
+
* frame centre; negative x is left of centre, negative y is above it).
|
|
883
|
+
* `anchor` names the point of the container's *computed* box that lands
|
|
884
|
+
* at `at` — so `anchor: "left"` with `at: {x: -860, y: -212}` puts that
|
|
885
|
+
* box's left edge near the left of a 1920-wide frame (-860 = 960px left
|
|
886
|
+
* of centre), not near `x: 110`, which sits just right of centre. No
|
|
887
|
+
* measuring a sibling's width required either way. `align` (default
|
|
888
|
+
* `"start"`, not CSS's own `"stretch"` default — stretching a text child to
|
|
889
|
+
* a sibling's width is rarely what an author wants) is the cross axis:
|
|
890
|
+
* `alignItems`. `justify` is the main axis: `justifyContent`.
|
|
891
|
+
*/
|
|
892
|
+
function container(ctx, direction, children, o = {}) {
|
|
893
|
+
const at = o.at ?? { x: 0, y: 0 };
|
|
894
|
+
const anchor = resolveAnchor(o.anchor);
|
|
895
|
+
const align = o.align ?? "start";
|
|
896
|
+
if (!(align in ALIGN_NAMES)) throw new Error(`${direction === "column" ? "stack" : "row"}(): align must be one of ${Object.keys(ALIGN_NAMES).join(", ")}, not ${JSON.stringify(align)}.`);
|
|
897
|
+
const layout = { display: "flex", flexDirection: direction, alignItems: ALIGN_NAMES[align] };
|
|
898
|
+
if (o.justify !== undefined) {
|
|
899
|
+
if (!(o.justify in JUSTIFY_NAMES)) throw new Error(`${direction === "column" ? "stack" : "row"}(): justify must be one of ${Object.keys(JUSTIFY_NAMES).join(", ")}, not ${JSON.stringify(o.justify)}.`);
|
|
900
|
+
layout.justifyContent = JUSTIFY_NAMES[o.justify];
|
|
901
|
+
}
|
|
902
|
+
if (o.gap !== undefined) layout.gap = o.gap;
|
|
903
|
+
if (o.padding !== undefined) layout.padding = o.padding;
|
|
904
|
+
if (o.width !== undefined) layout.width = o.width;
|
|
905
|
+
if (o.height !== undefined) layout.height = o.height;
|
|
906
|
+
if (o.wrap) layout.flexWrap = o.wrap === true ? "wrap" : o.wrap;
|
|
907
|
+
// `at` is the container's frame position (team-lead's call), so its own
|
|
908
|
+
// clip *time* window uses `start`/`dur` instead — the one place in this
|
|
909
|
+
// API "at" doesn't mean seconds. Defaults to the whole enclosing scope,
|
|
910
|
+
// same as every other element.
|
|
911
|
+
const g = group(ctx, { name: o.name ?? direction, parent: o.parent, transform: tf(at.x, at.y, 1, { anchor }), layout, flexItem: o.item, at: o.start, dur: o.dur });
|
|
912
|
+
for (const c of children) reparent(c, g.id);
|
|
913
|
+
return g;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// -------------------------------------------------------------------------
|
|
917
|
+
// Scene-local API: everything a scene builder function receives.
|
|
918
|
+
|
|
919
|
+
function makeSceneApi(ctx) {
|
|
920
|
+
const text = (str, o = {}) => {
|
|
921
|
+
const { size = 48, weight = 500, color = "#ffffff", font, tracking, italic, anchor = "center", mw = 0.9, x = 0, style: styleOverride, path: onPath, fill, ...rest } = o;
|
|
922
|
+
const family = fonts[font] ?? font ?? fonts.body ?? "Inter";
|
|
923
|
+
const style = { text: str, fontFamily: family, fontSizePx: size, fontWeight: weight, color, align: anchor, maxWidthFrac: mw };
|
|
924
|
+
if (tracking !== undefined) {
|
|
925
|
+
if (Math.abs(tracking) > 1) throw new Error(`text "${str}": tracking is in em (a fraction of the font size, e.g. -0.04 or 0.2), not ${tracking}`);
|
|
926
|
+
style.letterSpacingPx = tracking * size;
|
|
927
|
+
}
|
|
928
|
+
if (italic) style.fontStyle = "italic";
|
|
929
|
+
// Text-on-path: the same point commands `path()` takes, in px from the
|
|
930
|
+
// frame centre — converted through the one `pathData` formatter so
|
|
931
|
+
// both draw from the same normalized `d` string.
|
|
932
|
+
if (onPath) style.path = pathData(onPath);
|
|
933
|
+
// `fill` (a colour or a gradient) is a recognized top-level option,
|
|
934
|
+
// same as `style.fill` — the friendly spelling for the one textStyle
|
|
935
|
+
// field callers reach for as often as `color`. `style` is checked
|
|
936
|
+
// after, so an explicit `style: {fill: ...}` still wins over it.
|
|
937
|
+
if (fill !== undefined) style.fill = fill;
|
|
938
|
+
// Passthrough for every other textStyle field the document supports —
|
|
939
|
+
// lineHeight, verticalAlign, stroke, shadow, background — so nothing
|
|
940
|
+
// needs mutating the clip after creation.
|
|
941
|
+
Object.assign(style, styleOverride ?? {});
|
|
942
|
+
const x0 = anchor === "left" ? x + (mw * W) / 2 : anchor === "right" ? x - (mw * W) / 2 : x;
|
|
943
|
+
for (const key of Object.keys(rest)) {
|
|
944
|
+
if (!TEXT_PASSTHROUGH_KEYS.has(key)) {
|
|
945
|
+
throw new Error(`text "${str}": unknown option "${key}" — a textStyle field (lineHeight, verticalAlign, stroke, shadow, background, ...) goes under style: {${key}: ...}, not top-level.`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return makeClip("text", ctx, { ...rest, x: x0, textStyle: style });
|
|
949
|
+
};
|
|
950
|
+
const rect = (w, h, fill, o = {}) => {
|
|
951
|
+
const shape = { kind: o.kind ?? "rect", x: 0.5 - w / 2 / W, y: 0.5 - h / 2 / H, width: w / W, height: h / H, cornerRadius: (o.r ?? 0) / W };
|
|
952
|
+
if (typeof fill === "string") shape.fill = fill; else if (fill) shape.fillStyle = fill;
|
|
953
|
+
if (o.stroke) { shape.stroke = o.stroke; shape.strokeWidthPx = o.sw ?? 1; }
|
|
954
|
+
Object.assign(shape, o.shape ?? {});
|
|
955
|
+
return makeClip("shape", ctx, { ...o, shapeStyle: shape });
|
|
956
|
+
};
|
|
957
|
+
const ellipse = (d, fill, o = {}) => rect(d, d, fill, { ...o, kind: "ellipse" });
|
|
958
|
+
/**
|
|
959
|
+
* SVG path data for points in px from the frame centre, normalized to the
|
|
960
|
+
* frame. Most commands (`M`/`L`/`T`/`C`/`S`/`Q`) are pure x,y pairs and
|
|
961
|
+
* normalize by alternating X/Y; `H`/`V` take one coordinate on their own
|
|
962
|
+
* axis, and `A rx ry xRotDeg largeArc sweep x y` mixes two radii (scaled
|
|
963
|
+
* as sizes, by W and H respectively), an angle and two flags (passed
|
|
964
|
+
* through unscaled) with a trailing point.
|
|
965
|
+
*/
|
|
966
|
+
const pathData = pathDataFor;
|
|
967
|
+
const path = (points, o = {}) => {
|
|
968
|
+
const shape = { kind: "path", d: pathData(points), lineCap: "round", lineJoin: "round" };
|
|
969
|
+
if (o.fill) { if (typeof o.fill === "string") shape.fill = o.fill; else shape.fillStyle = o.fill; }
|
|
970
|
+
if (o.stroke) { shape.stroke = o.stroke; shape.strokeWidthPx = o.sw ?? 4; }
|
|
971
|
+
const clip = makeClip("shape", ctx, { ...o, shapeStyle: shape });
|
|
972
|
+
// Stashed so a later el.morph(toPoints) can resample from this shape's
|
|
973
|
+
// own authored points, not re-parse the normalized `d` string.
|
|
974
|
+
clip._pathPoints = points;
|
|
975
|
+
return clip;
|
|
976
|
+
};
|
|
977
|
+
const image = (assetId, o = {}) => makeClip("image", ctx, { ...o, currentAssetId: clipAssetId(assetId) });
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* `media(mediaType, assetId, o)` is the shared body for `video`/`audio`:
|
|
981
|
+
* friendly names for the clip fields that carry a source-media edit —
|
|
982
|
+
* `in` (`inPointMs`), `volume` (`volumeDb`), `fadeIn`/`fadeOut`
|
|
983
|
+
* (`fadeInMs`/`fadeOutMs`), `speed` (`speedMultiplier`), `mute`
|
|
984
|
+
* (`muted`), and `remap` (a `timeRemap`: `[{t, sourceAt, ease?}, …]`,
|
|
985
|
+
* `t` 0..1 across the clip, `sourceAt` seconds into the *source* media —
|
|
986
|
+
* not scene-local time). Every other clip field (`outPointMs`,
|
|
987
|
+
* `speedBaked`, `fadeInShape`/`fadeOutShape`, …) still passes through
|
|
988
|
+
* raw, unmutated after creation.
|
|
989
|
+
*/
|
|
990
|
+
function media(mediaType, assetId, o = {}) {
|
|
991
|
+
const { in: inAt, volume, fadeIn, fadeOut, speed, mute, remap, ...rest } = o;
|
|
992
|
+
const clip = { ...rest, currentAssetId: clipAssetId(assetId) };
|
|
993
|
+
if (inAt !== undefined) clip.inPointMs = msFor(inAt);
|
|
994
|
+
if (volume !== undefined) clip.volumeDb = volume;
|
|
995
|
+
if (fadeIn !== undefined) clip.fadeInMs = msFor(fadeIn);
|
|
996
|
+
if (fadeOut !== undefined) clip.fadeOutMs = msFor(fadeOut);
|
|
997
|
+
if (speed !== undefined) clip.speedMultiplier = speed;
|
|
998
|
+
if (mute !== undefined) clip.muted = mute;
|
|
999
|
+
if (remap) clip.timeRemap = { keyframes: remap.map((k) => (k.ease ? { t: k.t, sourceMs: msFor(k.sourceAt), easing: k.ease } : { t: k.t, sourceMs: msFor(k.sourceAt) })) };
|
|
1000
|
+
return makeClip(mediaType, ctx, clip);
|
|
1001
|
+
}
|
|
1002
|
+
const video = (assetId, o = {}) => media("video", assetId, o);
|
|
1003
|
+
const audio = (assetId, o = {}) => media("audio", assetId, o);
|
|
1004
|
+
const adjust = (effects, o = {}) => makeClip("adjustment", ctx, { name: "adjust", ...o, effects });
|
|
1005
|
+
const fill = (color, o = {}) => rect(W, H, color, { name: "fill", x: 0, y: 0, ...o });
|
|
1006
|
+
|
|
1007
|
+
const stack = (children, o = {}) => container(ctx, "column", children, o);
|
|
1008
|
+
const row = (children, o = {}) => container(ctx, "row", children, o);
|
|
1009
|
+
const center = (child, o = {}) => { child.transform.position = o.at === "center" || o.at === undefined ? { x: 0, y: 0 } : o.at; return child; };
|
|
1010
|
+
|
|
1011
|
+
/** A nested local clock: elements/motion inside `fn` are local to `atSec` within the enclosing scope. */
|
|
1012
|
+
const seq = (atSec, durSec, fn) => {
|
|
1013
|
+
const child = { scene: ctx.scene, startMs: ctx.startMs + msFor(atSec), durationMs: msFor(durSec), durationSec: durSec };
|
|
1014
|
+
return fn(makeSceneApi(child));
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
// -----------------------------------------------------------------------
|
|
1018
|
+
// Craft: the recurring structural vocabulary, bound to this scene's
|
|
1019
|
+
// palette. Every helper has a default, so `s.backdrop()` alone renders a
|
|
1020
|
+
// full bleed. `palette` colours are read fresh from `video()`'s palette.
|
|
1021
|
+
const {
|
|
1022
|
+
ink = "#06110e", ink2 = "#0c2a21", text: textColor = "#f4fbf8",
|
|
1023
|
+
dim = "#8fb3a6", accent = "#34d399"
|
|
1024
|
+
} = palette;
|
|
1025
|
+
|
|
1026
|
+
const backdrop = (o = {}) => {
|
|
1027
|
+
const { colors = [ink, ink2], opacity = 0.7, seed = 3 } = o;
|
|
1028
|
+
const [colorA, colorB] = colors;
|
|
1029
|
+
rect(W, H, colorA, { name: "ink" });
|
|
1030
|
+
return rect(W, H, colorA, {
|
|
1031
|
+
name: "field", opacity,
|
|
1032
|
+
effects: [{ id: nid("gf", sceneScope(ctx)), type: "generator", enabled: true, mode: "gradientField", colorA, colorB, scale: 3, animate: true, seed }]
|
|
1033
|
+
});
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
const glow = (o = {}) => {
|
|
1037
|
+
const { size = 1200, alpha = 0.25, color = accent, ...rest } = o;
|
|
1038
|
+
const c = (a) => hexToRgba(color, a);
|
|
1039
|
+
return ellipse(size, { type: "radial", stops: [{ offset: 0, color: c(alpha) }, { offset: 0.35, color: c(alpha * 0.4) }, { offset: 0.7071, color: c(0) }] }, { name: "glow", ...rest });
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const flash = (o = {}) => {
|
|
1043
|
+
const { at = 0, dur = 0.27, peak = 0.7, color = "#ffffff" } = o;
|
|
1044
|
+
const c = rect(W, H, color, { name: "flash", at, dur, blendMode: "screen" });
|
|
1045
|
+
// Opacity ramps from `peak` to 0 — a fade-out — so it is tagged
|
|
1046
|
+
// `role: "out"`. Untagged it defaulted to "in", which happened to
|
|
1047
|
+
// render identically only because this window spans the clip's whole
|
|
1048
|
+
// duration; a caller widening the flash clip without widening this
|
|
1049
|
+
// animation would have kept an "in"-anchored ramp pinned to the start
|
|
1050
|
+
// instead of the end it is meant to hug.
|
|
1051
|
+
return c.animate({ opacity: [peak, 0] }, { at: 0, dur, ease: "linear", role: "out" });
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
const kicker = (str, o = {}) => {
|
|
1055
|
+
const { x = 0, y = 0, at = 0, dur = 0.33, size = 28, weight = 600, color = accent, font = "display", parent, ...rest } = o;
|
|
1056
|
+
const k = text(str.toUpperCase(), { name: "kicker", anchor: "left", mw: 0.4, x, y, size, weight, color, font, tracking: 5 / size, parent, ...rest });
|
|
1057
|
+
k.animate({ wipeProgress: [0, 1], opacity: [0, 1] }, { at, dur, ease: "outExpo", mask: { direction: "left", softness: 0.05 } });
|
|
1058
|
+
return k;
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
/** A pill: a flex row (optional leading dot + label) over an absolute, inset-0 background plate created first so it draws behind. */
|
|
1062
|
+
const pill = (label, o = {}) => {
|
|
1063
|
+
const {
|
|
1064
|
+
size = 28, weight = 600, color = textColor, fillColor = ink2, stroke = "rgba(255,255,255,0.14)",
|
|
1065
|
+
dot, shadow, padX, padY, name = "pill", x = 0, y = 0, anchor, at, dur, font = "display", parent
|
|
1066
|
+
} = o;
|
|
1067
|
+
const plate = rect(10, 10, fillColor, {
|
|
1068
|
+
name: `${name}-bg`, stroke, r: size * 1.1, at, dur, absolute: true,
|
|
1069
|
+
effects: shadow ? [{ id: nid("sh", sceneScope(ctx)), type: "dropShadow", enabled: true, offsetX: 0, offsetY: 12, blur: 30, color: "rgba(0,0,0,0.4)" }] : undefined
|
|
1070
|
+
});
|
|
1071
|
+
const kids = [];
|
|
1072
|
+
if (dot) kids.push(ellipse(size * 0.5, dot, { name: `${name}-dot`, at, dur }));
|
|
1073
|
+
kids.push(text(label, { name: `${name}-label`, at, dur, font, size, weight, color, mw: 1 }));
|
|
1074
|
+
const g = row(kids, {
|
|
1075
|
+
name, parent, at: { x, y }, anchor, start: at, dur, align: "center", gap: size * 0.4,
|
|
1076
|
+
padding: { top: padY ?? size * 0.55, bottom: padY ?? size * 0.55, left: padX ?? size * 1.4, right: padX ?? size * 1.4 }
|
|
1077
|
+
});
|
|
1078
|
+
reparent(plate, g.id);
|
|
1079
|
+
return g;
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
const streaks = (o = {}) => {
|
|
1083
|
+
const { count = 3, colors = [accent], name = "streaks", rotationDeg = -8, opacity = 0.9 } = o;
|
|
1084
|
+
const g = group(ctx, { name, opacity, transform: tf(0, 0, 1, { rotation: rad(rotationDeg) }) });
|
|
1085
|
+
for (let i = 0; i < count; i++) {
|
|
1086
|
+
const width = 260 + i * 180, barH = 3 + i * 2.5, color = colors[i % colors.length];
|
|
1087
|
+
const copies = Math.max(4, 14 - i * 4), dy = 84 + i * 34;
|
|
1088
|
+
const cycleSec = Math.max(8, 34 - i * 9) / 30; // seconds; the reference authored these as frames at 30fps
|
|
1089
|
+
const cycleMs = msFor(cycleSec);
|
|
1090
|
+
const c = rect(width, barH, color, {
|
|
1091
|
+
name: `streak-${i}`, parent: g.id, r: barH / 2, opacity: Math.min(1, 0.8 + i * 0.05), y: -H / 2 - dy,
|
|
1092
|
+
repeater: { count: copies, positionStep: { x: 0, y: dy }, timeStepMs: Math.round((cycleMs / copies) * 3.7) % cycleMs },
|
|
1093
|
+
motionBlur: { samplesPerFrame: 6, shutterAngle: 240 }
|
|
1094
|
+
});
|
|
1095
|
+
const drift = hash(i + 1) * 400;
|
|
1096
|
+
c.loop({ offsetX: [[0, W * 0.8 + drift], [1, -W * 0.8 + drift]] }, cycleSec, { ease: "linear" });
|
|
1097
|
+
}
|
|
1098
|
+
return g;
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
const finish = (o = {}) => {
|
|
1102
|
+
const { vignette = 0.25, softness = 0.7, grain: grainAmt = 0.04, seed = 1, saturation = 1, contrast = 1, ...rest } = o;
|
|
1103
|
+
const effects = [
|
|
1104
|
+
{ id: nid("vig", sceneScope(ctx)), type: "vignette", enabled: true, amount: vignette, softness },
|
|
1105
|
+
{ id: nid("grain", sceneScope(ctx)), type: "grain", enabled: true, amount: grainAmt, animate: true, seed }
|
|
1106
|
+
];
|
|
1107
|
+
if (saturation !== 1 || contrast !== 1) effects.push({ id: nid("color", sceneScope(ctx)), type: "color", enabled: true, saturation, contrast });
|
|
1108
|
+
return adjust(effects, { name: "finish", ...rest });
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Instantiate a `component()` descriptor: fold its build's clips into one
|
|
1113
|
+
* group, reparented the way `pill()` folds its own dot+label+plate —
|
|
1114
|
+
* anything the build left parented to this enclosing scope becomes a
|
|
1115
|
+
* direct child of the returned group; anything the build itself
|
|
1116
|
+
* reparented deeper (its own `stack`/`row`) stays there. `propsIn` is
|
|
1117
|
+
* checked and defaulted against `Comp.props`, throwing and naming the
|
|
1118
|
+
* offending prop. `o.at`/`o.dur` place the group in this scope's own
|
|
1119
|
+
* local time (seconds), defaulting to `Comp.duration` then the whole
|
|
1120
|
+
* enclosing scope, same as every other element.
|
|
1121
|
+
*/
|
|
1122
|
+
const use = (Comp, propsIn = {}, o = {}) => {
|
|
1123
|
+
if (!Comp || Comp.__component !== true) {
|
|
1124
|
+
throw new Error("use(): expected a component() descriptor as the first argument.");
|
|
1125
|
+
}
|
|
1126
|
+
const resolved = resolveComponentProps(Comp, propsIn);
|
|
1127
|
+
const at = o.at ?? 0;
|
|
1128
|
+
const dur = o.dur ?? Comp.duration ?? (ctx.durationSec - at);
|
|
1129
|
+
const g = group(ctx, { name: o.name ?? Comp.name, parent: o.parent, x: o.x ?? 0, y: o.y ?? 0, at, dur });
|
|
1130
|
+
const before = ctx.scene.layers.length;
|
|
1131
|
+
const childCtx = { scene: ctx.scene, startMs: ctx.startMs + msFor(at), durationMs: g.durationMs, durationSec: dur };
|
|
1132
|
+
Comp.build(makeSceneApi(childCtx), resolved);
|
|
1133
|
+
for (let i = before; i < ctx.scene.layers.length; i++) {
|
|
1134
|
+
const clip = ctx.scene.layers[i];
|
|
1135
|
+
if (clip.id !== g.id && clip.parentId === ctx.scene.id) reparent(clip, g.id);
|
|
1136
|
+
}
|
|
1137
|
+
return g;
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
const s = { text, rect, ellipse, path, image, video, audio, group: (o) => group(ctx, o), adjust, fill, stack, row, center, seq, stagger, backdrop, glow, flash, kicker, pill, streaks, finish, noise, use };
|
|
1141
|
+
// Charts are pure functions over this same `s` (packages/sandbox-packs/
|
|
1142
|
+
// sandbox-timeline/sandbox/charts.js) — wiring them here just binds them
|
|
1143
|
+
// to this scene's own `s` and palette defaults, which a caller's own `o`
|
|
1144
|
+
// still overrides.
|
|
1145
|
+
s.barChart = (data, o = {}) => barChart(s, data, { color: accent, dim, ...o });
|
|
1146
|
+
s.lineChart = (series, o = {}) => lineChart(s, series, { color: accent, ...o });
|
|
1147
|
+
s.areaChart = (series, o = {}) => areaChart(s, series, { color: accent, ...o });
|
|
1148
|
+
s.donut = (data, o = {}) => donut(s, data, { color: accent, ...o });
|
|
1149
|
+
s.statCounter = (value, o = {}) => statCounter(s, value, { color: textColor, ...o });
|
|
1150
|
+
return s;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// -------------------------------------------------------------------------
|
|
1154
|
+
// Scenes and series.
|
|
1155
|
+
|
|
1156
|
+
/** Read a scene's snapshots and print its call arguments, collecting what cannot be kept. */
|
|
1157
|
+
function captureScene(scene, record) {
|
|
1158
|
+
const errors = [...record.errors];
|
|
1159
|
+
const snaps = [];
|
|
1160
|
+
for (const [key, read] of record.snap) {
|
|
1161
|
+
let value;
|
|
1162
|
+
try {
|
|
1163
|
+
value = read();
|
|
1164
|
+
} catch {
|
|
1165
|
+
errors.push(`\`${key}\` is not set yet where scene "${scene.name}" is built.`);
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (value === v) {
|
|
1169
|
+
snaps.push([key, null]);
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
try {
|
|
1173
|
+
snaps.push([key, printValue(value)]);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
errors.push(`scene "${scene.name}": \`${key}\` ${error.message}.`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
let args;
|
|
1179
|
+
try {
|
|
1180
|
+
args = `${printValue(scene.name)}, ${printValue(scene.durationSec)}, ${record.src}, ${printValue(scene.extra)}`;
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
errors.push(`scene "${scene.name}": its arguments ${error.message}.`);
|
|
1183
|
+
}
|
|
1184
|
+
return { record, snaps, args, errors };
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** One scene: its own local clock from 0. `fn(s)` builds its content. */
|
|
1188
|
+
v.scene = (name, durationSec, fn, extra = {}, captureRecord) => {
|
|
1189
|
+
const scene = { name, id: name, layers: [], durationMs: msFor(durationSec), durationSec, extra };
|
|
1190
|
+
// `captureRecord` is added by the sandbox's capture step, never by an
|
|
1191
|
+
// author. The snapshots are read before `fn` runs: the values the scene
|
|
1192
|
+
// is built from, not what `fn` leaves behind.
|
|
1193
|
+
if (captureRecord && captureRecord.__ntCapture === 1) {
|
|
1194
|
+
scene._capture = captureScene(scene, captureRecord);
|
|
1195
|
+
capture.scenes.push(scene);
|
|
1196
|
+
}
|
|
1197
|
+
const ctx = { scene, startMs: 0, durationMs: scene.durationMs, durationSec };
|
|
1198
|
+
fn(makeSceneApi(ctx));
|
|
1199
|
+
scene.group = {
|
|
1200
|
+
id: name, name, trackId: "t_scenes", startMs: 0, durationMs: scene.durationMs,
|
|
1201
|
+
mediaType: "group", sourceType: "imported", status: "generated", locked: false, versions: [],
|
|
1202
|
+
transform: tf(), sourceScene: name, ...extra
|
|
1203
|
+
};
|
|
1204
|
+
if (scene._capture) {
|
|
1205
|
+
scene._capture.built = new Map([...scene.layers, scene.group].map((clip) => [clip.id, JSON.stringify(clip)]));
|
|
1206
|
+
}
|
|
1207
|
+
return scene;
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
/** Apply `patch` (field changes per clip id) to a scene's clips. Written by `v.save()`'s printer. */
|
|
1211
|
+
v.__patchScene = (scene, patch) => {
|
|
1212
|
+
const clips = [...scene.layers, scene.group];
|
|
1213
|
+
for (const [id, changes] of Object.entries(patch)) {
|
|
1214
|
+
const clip = clips.find((c) => c.id === id);
|
|
1215
|
+
if (!clip) throw new Error(`v.__patchScene(): scene "${scene.name}" has no clip "${id}"`);
|
|
1216
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
1217
|
+
if (value === undefined) delete clip[key];
|
|
1218
|
+
else clip[key] = value;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return scene;
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
/** A transition item for `series()`: goes between the two scenes it joins. */
|
|
1225
|
+
v.transition = (type, durationSec, opts = {}) => {
|
|
1226
|
+
const item = { __transition: true, type, durationMs: msFor(durationSec), opts };
|
|
1227
|
+
try {
|
|
1228
|
+
item._args = `${printValue(type)}, ${printValue(durationSec)}, ${printValue(opts)}`;
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
item._argsError = `v.transition() ${error.message}.`;
|
|
1231
|
+
}
|
|
1232
|
+
return item;
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Lay scenes end to end. A transition item between two scenes overlaps them
|
|
1237
|
+
* by its own duration and sets `transitionIn` on the incoming scene's group;
|
|
1238
|
+
* omitting one between a pair makes a hard cut. Scenes are banked onto two
|
|
1239
|
+
* alternating track sets so an overlapping pair never shares a track.
|
|
1240
|
+
*/
|
|
1241
|
+
v.series = (items) => {
|
|
1242
|
+
capture.series = [...items];
|
|
1243
|
+
// Changes the top level made to a scene's clips between `v.scene()` and
|
|
1244
|
+
// here. They are printed as a `v.__patchScene()` call.
|
|
1245
|
+
for (const item of items) {
|
|
1246
|
+
if (!item || item.__transition || !item._capture || !item._capture.built) continue;
|
|
1247
|
+
const patch = clipPatch(item._capture.built, [...item.layers, item.group]);
|
|
1248
|
+
if (patch === null) item._capture.errors.push(`scene "${item.name}": a clip was added or removed after the scene was built.`);
|
|
1249
|
+
else if (Object.keys(patch).length) item._capture.prePatch = patch;
|
|
1250
|
+
}
|
|
1251
|
+
const scenes = [];
|
|
1252
|
+
let cursorMs = 0;
|
|
1253
|
+
let pending = null;
|
|
1254
|
+
for (const item of items) {
|
|
1255
|
+
if (item && item.__transition) { pending = item; continue; }
|
|
1256
|
+
const scene = item;
|
|
1257
|
+
const overlapMs = pending ? pending.durationMs : 0;
|
|
1258
|
+
const absStart = cursorMs - overlapMs;
|
|
1259
|
+
if (pending) {
|
|
1260
|
+
scene.group.transitionIn = { type: pending.type, durationMs: pending.durationMs, ...pending.opts };
|
|
1261
|
+
}
|
|
1262
|
+
for (const clip of scene.layers) clip.startMs += absStart;
|
|
1263
|
+
scene.group.startMs = absStart;
|
|
1264
|
+
cursorMs = absStart + scene.durationMs;
|
|
1265
|
+
scene.bank = scenes.length % 2 === 0 ? "A" : "B";
|
|
1266
|
+
scenes.push(scene);
|
|
1267
|
+
pending = null;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const banks = ["A", "B"];
|
|
1271
|
+
const tracks = [];
|
|
1272
|
+
const clips = [];
|
|
1273
|
+
let offset = 0;
|
|
1274
|
+
for (const bank of banks) {
|
|
1275
|
+
const inBank = scenes.filter((s) => s.bank === bank);
|
|
1276
|
+
const size = Math.max(0, ...inBank.map((s) => s.layers.length));
|
|
1277
|
+
for (let i = 0; i < size; i++) tracks.push({ id: `t_${bank}${i}`, name: `${bank}${i}`, type: "video", index: offset + i, visible: true, locked: false });
|
|
1278
|
+
for (const s of inBank) {
|
|
1279
|
+
[...s.layers].sort((a, b) => a._z - b._z).forEach((clip, i) => {
|
|
1280
|
+
clip.trackId = `t_${bank}${size - 1 - i}`;
|
|
1281
|
+
delete clip._z;
|
|
1282
|
+
// Private authoring-time bookkeeping, not a document field: morph()
|
|
1283
|
+
// reads `_pathPoints` back off its own clip, stagger() sets
|
|
1284
|
+
// `_staggerAt` as a default `at` for later motion calls,
|
|
1285
|
+
// enter()/animate() remember `_lastStagger` so a co-authored
|
|
1286
|
+
// tween() can inherit it, and `_scope` is the id scope a motion
|
|
1287
|
+
// method mints new effect/animation ids into. Left on, any of
|
|
1288
|
+
// these shows up as a `field_stripped` warning on the next schema
|
|
1289
|
+
// round trip.
|
|
1290
|
+
delete clip._pathPoints;
|
|
1291
|
+
delete clip._staggerAt;
|
|
1292
|
+
delete clip._lastStagger;
|
|
1293
|
+
delete clip._scope;
|
|
1294
|
+
clips.push(clip);
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
offset += size;
|
|
1298
|
+
}
|
|
1299
|
+
for (const s of scenes) clips.push(s.group);
|
|
1300
|
+
|
|
1301
|
+
v._durationMs = cursorMs;
|
|
1302
|
+
/** The video's total duration in ms, once `v.series()` has run. */
|
|
1303
|
+
v.durationMs = cursorMs;
|
|
1304
|
+
v._document = {
|
|
1305
|
+
tracks: [{ id: "t_scenes", name: "scenes", type: "video", index: tracks.length, visible: true, locked: false }, ...tracks],
|
|
1306
|
+
clips,
|
|
1307
|
+
markers: []
|
|
1308
|
+
};
|
|
1309
|
+
capture.afterSeries = {
|
|
1310
|
+
tracks: JSON.stringify(v._document.tracks),
|
|
1311
|
+
clipCount: clips.length,
|
|
1312
|
+
clips: new Map(clips.map((clip) => [clip.id, JSON.stringify(clip)]))
|
|
1313
|
+
};
|
|
1314
|
+
return v;
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* Put back what the top level added after `v.series()` — tracks, clips,
|
|
1319
|
+
* markers and document fields — as the retained program stores it: as
|
|
1320
|
+
* data. Written by `v.save()`'s printer, not by an author.
|
|
1321
|
+
*/
|
|
1322
|
+
v.__restore = ({ tracks, patch, clips, markers, fields } = {}) => {
|
|
1323
|
+
if (!v._document) throw new Error("v.__restore() runs after v.series()");
|
|
1324
|
+
if (tracks) v._document.tracks = tracks;
|
|
1325
|
+
for (const [id, changes] of Object.entries(patch ?? {})) {
|
|
1326
|
+
const clip = v._document.clips.find((c) => c.id === id);
|
|
1327
|
+
if (!clip) throw new Error(`v.__restore(): no clip "${id}" to patch`);
|
|
1328
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
1329
|
+
if (value === undefined) delete clip[key];
|
|
1330
|
+
else clip[key] = value;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (clips) v._document.clips.push(...clips);
|
|
1334
|
+
if (markers) v._document.markers = markers;
|
|
1335
|
+
if (fields) Object.assign(v._document, fields);
|
|
1336
|
+
return v;
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Print the retained program for this video, or return null when no scene
|
|
1341
|
+
* was captured (code that did not pass through the sandbox's capture step).
|
|
1342
|
+
*/
|
|
1343
|
+
function printProgram({ name, showcase, ops }) {
|
|
1344
|
+
const inSeries = capture.series.filter((item) => item && !item.__transition);
|
|
1345
|
+
const captured = inSeries.filter((scene) => scene._capture);
|
|
1346
|
+
if (captured.length === 0) return null;
|
|
1347
|
+
const errors = [];
|
|
1348
|
+
if (capture.optionsError) errors.push(capture.optionsError);
|
|
1349
|
+
for (const scene of inSeries) {
|
|
1350
|
+
if (!scene._capture) errors.push(`scene "${scene.name}" was built without capture.`);
|
|
1351
|
+
else errors.push(...scene._capture.errors);
|
|
1352
|
+
}
|
|
1353
|
+
for (const item of capture.series) {
|
|
1354
|
+
if (item && item.__transition && item._argsError) errors.push(item._argsError);
|
|
1355
|
+
}
|
|
1356
|
+
if (v._pendingReacts.length > 0) {
|
|
1357
|
+
errors.push("el.react() needs an audio decode that a rebake cannot do.");
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const V = captured[0]._capture.record.video ?? "__ntV";
|
|
1361
|
+
const imports = new Map();
|
|
1362
|
+
const topDecls = new Map();
|
|
1363
|
+
for (const scene of captured) {
|
|
1364
|
+
for (const imp of scene._capture.record.imports) {
|
|
1365
|
+
if (!imports.has(imp.m)) imports.set(imp.m, new Map());
|
|
1366
|
+
imports.get(imp.m).set(imp.l, imp.i);
|
|
1367
|
+
}
|
|
1368
|
+
for (const decl of scene._capture.record.decls) {
|
|
1369
|
+
if (decl.top) topDecls.set(decl.at, decl.src);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (!imports.has(TIMELINE_PACK)) imports.set(TIMELINE_PACK, new Map());
|
|
1374
|
+
const lines = [];
|
|
1375
|
+
for (const [module, locals] of imports) {
|
|
1376
|
+
const named = module === TIMELINE_PACK ? ["video as __ntVideo"] : [];
|
|
1377
|
+
for (const [local, imported] of locals) {
|
|
1378
|
+
if (imported === "*") lines.push(`import * as ${local} from ${JSON.stringify(module)};`);
|
|
1379
|
+
else if (imported === "default") lines.push(`import ${local} from ${JSON.stringify(module)};`);
|
|
1380
|
+
else named.push(imported === local ? local : `${imported} as ${local}`);
|
|
1381
|
+
}
|
|
1382
|
+
if (named.length) lines.push(`import { ${named.join(", ")} } from ${JSON.stringify(module)};`);
|
|
1383
|
+
}
|
|
1384
|
+
lines.push("", `const ${V} = __ntVideo(${capture.options});`);
|
|
1385
|
+
const sortedTop = [...topDecls.entries()].sort(([a], [b]) => a - b);
|
|
1386
|
+
if (sortedTop.length) lines.push("", ...sortedTop.map(([, src]) => src));
|
|
1387
|
+
|
|
1388
|
+
const sceneVar = new Map();
|
|
1389
|
+
const usedVars = new Set();
|
|
1390
|
+
for (const scene of capture.scenes) {
|
|
1391
|
+
if (!inSeries.includes(scene) || sceneVar.has(scene)) continue;
|
|
1392
|
+
const base = `__scene_${String(scene.name).replace(/[^A-Za-z0-9_$]/g, "_")}`;
|
|
1393
|
+
let variable = base;
|
|
1394
|
+
for (let n = 2; usedVars.has(variable); n++) variable = `${base}_${n}`;
|
|
1395
|
+
usedVars.add(variable);
|
|
1396
|
+
sceneVar.set(scene, variable);
|
|
1397
|
+
const { record, snaps, args } = scene._capture;
|
|
1398
|
+
const block = [];
|
|
1399
|
+
for (const [key, value] of snaps) {
|
|
1400
|
+
if (value === null) {
|
|
1401
|
+
if (key !== V) block.push(` let ${key} = ${V};`);
|
|
1402
|
+
} else {
|
|
1403
|
+
block.push(` let ${key} = ${value};`);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
for (const decl of record.decls) {
|
|
1407
|
+
if (!decl.top) block.push(` ${decl.src}`);
|
|
1408
|
+
}
|
|
1409
|
+
// A scene that needs no snapshot is a plain call. One that does gets
|
|
1410
|
+
// its own block, so two scenes can hold different values of one name.
|
|
1411
|
+
if (block.length === 0) {
|
|
1412
|
+
lines.push("", `const ${variable} = ${V}.scene(${args});`);
|
|
1413
|
+
} else {
|
|
1414
|
+
lines.push("", `const ${variable} = (() => {`, ...block, ` return ${V}.scene(${args});`, "})();");
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
for (const [scene, variable] of sceneVar) {
|
|
1419
|
+
if (scene._capture.prePatch) lines.push(`${V}.__patchScene(${variable}, ${printValue(scene._capture.prePatch)});`);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const seriesItems = capture.series.map((item) =>
|
|
1423
|
+
item && item.__transition ? `${V}.transition(${item._args})` : sceneVar.get(item)
|
|
1424
|
+
);
|
|
1425
|
+
lines.push("", `${V}.series([${seriesItems.join(", ")}]);`);
|
|
1426
|
+
|
|
1427
|
+
const doc = v._document;
|
|
1428
|
+
const extras = {};
|
|
1429
|
+
const tracksJson = JSON.stringify(doc.tracks);
|
|
1430
|
+
if (tracksJson !== capture.afterSeries.tracks) extras.tracks = JSON.parse(tracksJson);
|
|
1431
|
+
// Fields the top level changed on a scene clip after `v.series()`.
|
|
1432
|
+
const patch = clipPatch(capture.afterSeries.clips, doc.clips.slice(0, capture.afterSeries.clipCount));
|
|
1433
|
+
if (patch === null) errors.push("a clip from v.series() was removed or replaced before v.save().");
|
|
1434
|
+
else if (Object.keys(patch).length) extras.patch = patch;
|
|
1435
|
+
const added = doc.clips.slice(capture.afterSeries.clipCount);
|
|
1436
|
+
if (added.length) extras.clips = JSON.parse(JSON.stringify(added));
|
|
1437
|
+
if (doc.markers.length) extras.markers = JSON.parse(JSON.stringify(doc.markers));
|
|
1438
|
+
const fields = {};
|
|
1439
|
+
for (const key of Object.keys(doc)) {
|
|
1440
|
+
if (key === "tracks" || key === "clips" || key === "markers" || doc[key] === undefined) continue;
|
|
1441
|
+
fields[key] = JSON.parse(JSON.stringify(doc[key]));
|
|
1442
|
+
}
|
|
1443
|
+
if (Object.keys(fields).length) extras.fields = fields;
|
|
1444
|
+
if (Object.keys(extras).length) lines.push(`${V}.__restore(${printValue(extras)});`);
|
|
1445
|
+
|
|
1446
|
+
let saveOptions;
|
|
1447
|
+
try {
|
|
1448
|
+
saveOptions = printValue({ name, showcase, ops });
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
errors.push(`v.save() options ${error.message}.`);
|
|
1451
|
+
}
|
|
1452
|
+
lines.push(`return await ${V}.save(nodetool.timelines, ${saveOptions});`);
|
|
1453
|
+
return { program: `${lines.join("\n")}\n`, errors };
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* Print the retained program and attach it through `code.set` with
|
|
1458
|
+
* `require_match`. The document is already saved, so a failure here is a
|
|
1459
|
+
* warning, never a failed save.
|
|
1460
|
+
*/
|
|
1461
|
+
async function attachCode(timelines, id, saveOptions) {
|
|
1462
|
+
if (!capture.series) return undefined;
|
|
1463
|
+
try {
|
|
1464
|
+
const printed = printProgram(saveOptions);
|
|
1465
|
+
if (!printed) return undefined;
|
|
1466
|
+
if (printed.errors.length > 0) {
|
|
1467
|
+
return { embedded: false, warnings: printed.errors.map((e) => `Code not embedded: ${e}`) };
|
|
1468
|
+
}
|
|
1469
|
+
const attached = await timelines.code.set(id, printed.program, { require_match: true });
|
|
1470
|
+
return {
|
|
1471
|
+
embedded: attached.embedded === true,
|
|
1472
|
+
warnings: [...(attached.errors ?? []), ...(attached.warnings ?? [])]
|
|
1473
|
+
};
|
|
1474
|
+
} catch (error) {
|
|
1475
|
+
return { embedded: false, warnings: [`Code not embedded: ${error && error.message ? error.message : error}`] };
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* A top-level timed adjustment layer, in absolute video seconds — for a
|
|
1481
|
+
* whole-video finish (grain/vignette across everything) or a short local
|
|
1482
|
+
* effect at a cut (a glitch burst spanning both sides of a hard cut). Adds
|
|
1483
|
+
* its own track above every scene bank so it always sees the composite.
|
|
1484
|
+
*/
|
|
1485
|
+
v.adjust = (effects, o = {}) => {
|
|
1486
|
+
if (!v._document) throw new Error("v.adjust() runs after v.series()");
|
|
1487
|
+
const at = o.at ?? 0, dur = o.dur ?? (v._durationMs / 1000 - at);
|
|
1488
|
+
const clip = {
|
|
1489
|
+
id: o.id ?? nid("adj", `adjust:${o.trackId ?? "t_top"}`), name: o.name ?? "adjust", trackId: o.trackId ?? "t_top",
|
|
1490
|
+
startMs: msFor(at), durationMs: Math.max(1, msFor(dur)),
|
|
1491
|
+
mediaType: "adjustment", sourceType: "imported", status: "generated", locked: false, versions: [],
|
|
1492
|
+
effects: fillEffects(effects, `adjust:${o.trackId ?? "t_top"}`)
|
|
1493
|
+
};
|
|
1494
|
+
if (o.animations) clip.animations = o.animations;
|
|
1495
|
+
attachMotion(clip);
|
|
1496
|
+
const trackId = clip.trackId;
|
|
1497
|
+
if (!v._document.tracks.some((t) => t.id === trackId)) {
|
|
1498
|
+
for (const t of v._document.tracks) t.index += 1;
|
|
1499
|
+
v._document.tracks.unshift({ id: trackId, name: trackId, type: "video", index: 0, visible: true, locked: false });
|
|
1500
|
+
}
|
|
1501
|
+
v._document.clips.push(clip);
|
|
1502
|
+
return clip;
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* A top-level audio clip in absolute video seconds — a score, a countdown,
|
|
1507
|
+
* a narration bed spanning (part of) the series. Lands on its own audio
|
|
1508
|
+
* track (default `t_audio`) that the author never has to name; two calls
|
|
1509
|
+
* with different `trackId`s (or `v.music`, which defaults to `t_music`)
|
|
1510
|
+
* land on separate tracks so two beds can overlap in time. Same friendly
|
|
1511
|
+
* fields as a scene's `s.audio()`: `volume`, `fadeIn`, `fadeOut`, `mute`.
|
|
1512
|
+
*/
|
|
1513
|
+
v.audio = (assetId, o = {}) => {
|
|
1514
|
+
if (!v._document) throw new Error("v.audio()/v.music() run after v.series()");
|
|
1515
|
+
const { at = 0, dur, trackId = "t_audio", trackName, volume, fadeIn, fadeOut, mute, ...rest } = o;
|
|
1516
|
+
const clip = {
|
|
1517
|
+
id: o.id ?? nid("aud", `audio:${trackId}`), name: o.name ?? "audio", trackId,
|
|
1518
|
+
startMs: msFor(at), durationMs: Math.max(1, msFor(dur ?? v._durationMs / 1000 - at)),
|
|
1519
|
+
mediaType: "audio", sourceType: "imported", status: "generated", locked: false, versions: [],
|
|
1520
|
+
currentAssetId: clipAssetId(assetId)
|
|
1521
|
+
};
|
|
1522
|
+
for (const k of FIELDS) if (rest[k] !== undefined) clip[k] = rest[k];
|
|
1523
|
+
if (volume !== undefined) clip.volumeDb = volume;
|
|
1524
|
+
if (fadeIn !== undefined) clip.fadeInMs = msFor(fadeIn);
|
|
1525
|
+
if (fadeOut !== undefined) clip.fadeOutMs = msFor(fadeOut);
|
|
1526
|
+
if (mute !== undefined) clip.muted = mute;
|
|
1527
|
+
if (!v._document.tracks.some((t) => t.id === trackId)) {
|
|
1528
|
+
v._document.tracks.push({ id: trackId, name: trackName ?? clip.name, type: "audio", index: v._document.tracks.length, visible: true, locked: false });
|
|
1529
|
+
}
|
|
1530
|
+
v._document.clips.push(clip);
|
|
1531
|
+
return clip;
|
|
1532
|
+
};
|
|
1533
|
+
/** `v.audio` on its own `t_music` track, so a score and a narration bed never collide. */
|
|
1534
|
+
v.music = (assetId, o = {}) => v.audio(assetId, { trackId: "t_music", name: "music", ...o });
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* A midi clip in absolute video seconds, on its own midi track (creates it
|
|
1538
|
+
* the first time `trackId` is seen). `notes` is `[beat, pitch, lenBeats?,
|
|
1539
|
+
* velocity?]` tuples — musical time, independent of the clip's own
|
|
1540
|
+
* placement, exactly the way a score is written. `o.instrument` is a
|
|
1541
|
+
* preset id (`"bl1-acid"`) or a full instrument object; `set_timeline_document`
|
|
1542
|
+
* resolves the shorthand. Two calls with different `trackId`s (a drum
|
|
1543
|
+
* track, a bass track) never collide.
|
|
1544
|
+
*/
|
|
1545
|
+
v.midi = (trackId, notes, o = {}) => {
|
|
1546
|
+
if (!v._document) throw new Error("v.midi() runs after v.series()");
|
|
1547
|
+
const { at = 0, dur, instrument, name } = o;
|
|
1548
|
+
if (!v._document.tracks.some((t) => t.id === trackId)) {
|
|
1549
|
+
v._document.tracks.push({
|
|
1550
|
+
id: trackId, name: name ?? trackId, type: "midi", index: v._document.tracks.length, visible: true, locked: false,
|
|
1551
|
+
instrument: typeof instrument === "string" ? { preset: instrument } : instrument
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
const clip = {
|
|
1555
|
+
id: o.id ?? nid("midi", `midi:${trackId}`), name: o.name ?? trackId, trackId,
|
|
1556
|
+
startMs: msFor(at), durationMs: Math.max(1, msFor(dur ?? v.durationMs / 1000 - at)),
|
|
1557
|
+
mediaType: "midi", sourceType: "imported", status: "generated", locked: false, versions: [],
|
|
1558
|
+
notes: midiNotes(notes, (p) => nid(p, `midi:${trackId}:notes`))
|
|
1559
|
+
};
|
|
1560
|
+
v._document.clips.push(clip);
|
|
1561
|
+
return clip;
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Sets the document's `tempo` (so midi and beat-driven motion play at
|
|
1566
|
+
* `bpm`) and returns edit ops for `v.save`'s `ops`: a marker per beat, then
|
|
1567
|
+
* (only when `snap` names clip ids) those clips' starts snapped onto the
|
|
1568
|
+
* grid. Pass the result straight through:
|
|
1569
|
+
* `v.save(nodetool.timelines, {ops: v.beats({bpm: 120})})`.
|
|
1570
|
+
*/
|
|
1571
|
+
v.beats = ({ bpm, offset = 0, count, snap, timeSignature } = {}) => {
|
|
1572
|
+
if (!v._document) throw new Error("v.beats() runs after v.series()");
|
|
1573
|
+
v._document.tempo = { bpm, offsetMs: msFor(offset), timeSignature: timeSignature ?? { beatsPerBar: 4, beatUnit: 4 } };
|
|
1574
|
+
const beatCount = count ?? Math.floor(((v.durationMs / 1000) - offset) * (bpm / 60));
|
|
1575
|
+
// Markers are a plain document field (`v._document.markers` already
|
|
1576
|
+
// exists), so they are written directly here — the same grid math
|
|
1577
|
+
// `buildBeatGrid`/`set_markers_from_beats` use (`offset + i * interval`,
|
|
1578
|
+
// not an accumulated sum, so a fractional interval like 140 BPM's
|
|
1579
|
+
// 428.571…ms does not drift) — rather than through an edit op. A code
|
|
1580
|
+
// bake is hermetic (no `ops`, see `timeline-code-bake.ts`), so a script
|
|
1581
|
+
// that only marks beats — the common case — now bakes clean; only
|
|
1582
|
+
// `snap` (which moves existing clips, an edit only `edit_timeline` can
|
|
1583
|
+
// do) still needs one.
|
|
1584
|
+
const offsetMs = msFor(offset);
|
|
1585
|
+
const intervalMs = 60000 / bpm;
|
|
1586
|
+
const taken = new Set(v._document.markers.map((m) => m.timeMs));
|
|
1587
|
+
for (let i = 0; i < beatCount; i++) {
|
|
1588
|
+
const timeMs = Math.round(offsetMs + i * intervalMs);
|
|
1589
|
+
if (taken.has(timeMs)) continue;
|
|
1590
|
+
taken.add(timeMs);
|
|
1591
|
+
v._document.markers.push({ id: nid("marker", "beats"), timeMs, label: `Beat ${i + 1}` });
|
|
1592
|
+
}
|
|
1593
|
+
// An empty/absent `snap` must only add markers: `snap_to_beats` treats an
|
|
1594
|
+
// empty `targets` as "every clip", which would move clips nobody asked to.
|
|
1595
|
+
const ops = [];
|
|
1596
|
+
if (Array.isArray(snap) && snap.length > 0) {
|
|
1597
|
+
ops.push({ op: "snap_to_beats", targets: snap, bpm, offset_ms: offsetMs, mode: "start", action: "move", tolerance_ms: 40 });
|
|
1598
|
+
}
|
|
1599
|
+
return ops;
|
|
1600
|
+
};
|
|
1601
|
+
|
|
1602
|
+
/** Extra top-level document fields (`camera2d`, `trackFolders`, `tempo`) applied verbatim before save. */
|
|
1603
|
+
v.document = (fields) => { Object.assign(v._document, fields); return v; };
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Create the timeline, write the document, validate (with `tier:
|
|
1607
|
+
* "showcase"` when `showcase: true`), and return `{timeline_id, fps, width,
|
|
1608
|
+
* height, durationMs, errors, warnings, code?}`. A validation error throws,
|
|
1609
|
+
* so `errors` is always empty. Never throws after create without naming
|
|
1610
|
+
* the timeline id.
|
|
1611
|
+
*/
|
|
1612
|
+
v.save = async (timelines, { name, showcase = false, ops = [], timeline_id } = {}) => {
|
|
1613
|
+
if (!timelines) throw new Error("v.save(nodetool.timelines, {name}) — pass the script's nodetool.timelines.");
|
|
1614
|
+
if (!v._document) throw new Error("v.save() runs after v.series()");
|
|
1615
|
+
const document = v._document;
|
|
1616
|
+
const preflight = await timelines.validate(document, { normalize: true, fps: FPS, width: W, height: H });
|
|
1617
|
+
if (!preflight.ok) throw new Error(`validate_timeline: ${JSON.stringify(preflight.errors, null, 2)}`);
|
|
1618
|
+
|
|
1619
|
+
// Resolve where to write: an explicit timeline_id, else a new timeline.
|
|
1620
|
+
// A code-backed timeline's own rebake goes through
|
|
1621
|
+
// `nodetool.timelines.code.rebake()`, not through re-running this script
|
|
1622
|
+
// by hand — that call bakes the code itself and merges the result, so
|
|
1623
|
+
// `v.save()` here needs no id threading of its own.
|
|
1624
|
+
let id = timeline_id;
|
|
1625
|
+
const created = !id;
|
|
1626
|
+
if (created) {
|
|
1627
|
+
({ timeline_id: id } = await timelines.create(name, { fps: FPS, width: W, height: H }));
|
|
1628
|
+
}
|
|
1629
|
+
try {
|
|
1630
|
+
const set = await timelines.setDocument(id, document);
|
|
1631
|
+
if (!set.written) throw new Error(`set_timeline_document: ${JSON.stringify(set.validation ?? set.error, null, 2)}`);
|
|
1632
|
+
if (ops.length) {
|
|
1633
|
+
const edit = await timelines.edit(id, ops);
|
|
1634
|
+
if (edit.failed) throw new Error(`edit_timeline: ${JSON.stringify(edit.ops.filter((r) => !r.ok), null, 2)}`);
|
|
1635
|
+
}
|
|
1636
|
+
// el.react() calls only recorded intent; the audio and target clips
|
|
1637
|
+
// are now in the saved document, so this is the first point their ids
|
|
1638
|
+
// resolve. Runs before the final validate() so that validation sees
|
|
1639
|
+
// the baked curves too.
|
|
1640
|
+
for (const react of v._pendingReacts) {
|
|
1641
|
+
const bake = await timelines.bakeAudioAnimation(id, react.audioClipId, react.targetClipId, react.params);
|
|
1642
|
+
if (!bake || bake.error) {
|
|
1643
|
+
throw new Error(`bake_audio_animation (${react.targetClipId} <- ${react.audioClipId}): ${JSON.stringify((bake && bake.error) ?? bake, null, 2)}`);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
const validation = await timelines.validate(id, showcase ? { tier: "showcase" } : undefined);
|
|
1647
|
+
if (!validation.ok) throw new Error(`validate_timeline: ${JSON.stringify(validation.errors, null, 2)}`);
|
|
1648
|
+
const result = { timeline_id: id, fps: FPS, width: W, height: H, durationMs: v.durationMs, errors: validation.errors ?? [], warnings: validation.warnings ?? [] };
|
|
1649
|
+
const code = await attachCode(timelines, id, { name, showcase, ops });
|
|
1650
|
+
if (code) result.code = code;
|
|
1651
|
+
return result;
|
|
1652
|
+
} catch (error) {
|
|
1653
|
+
throw new Error(`${name} (timeline ${id}): ${error && error.message ? error.message : error}`);
|
|
1654
|
+
}
|
|
1655
|
+
};
|
|
1656
|
+
|
|
1657
|
+
return v;
|
|
1658
|
+
}
|