fablecut 1.13.0 → 1.15.0

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/templates.js ADDED
@@ -0,0 +1,576 @@
1
+ /* FableCut animation presets and templates. Zero dependencies. Loaded by the
2
+ editor as a plain script (global `FableCutTemplates`) and required by the MCP
3
+ server and the tests.
4
+
5
+ A PRESET is a named animation that fits any visual clip:
6
+ { id, name, group, anchor: "in" | "out" | "at" | "whole", length, keys: { channel: [{t, v, ease?}] }, props? }
7
+ "in" plays from the clip's start, "out" ends at its end, "at" plays at a
8
+ given time (an emphasis), "whole" spans the clip (a loop; its key times are
9
+ fractions 0…1 of the clip). Key values are relative to the clip's own value
10
+ of that channel at rest: `scale` and `opacity` multiply it, everything else
11
+ adds to it. So "Slide up" works wherever the clip sits and however big it is.
12
+
13
+ A TEMPLATE is a composition with editable fields:
14
+ { id, name, group, width, height, duration, hold, background?, tracks?, clips, media?, fields }
15
+ fields: [{ key, label, type: "text" | "color" | "media", default?, targets: ["<clip id>:<path>" | "comp:background"] }]
16
+ Placing one makes its own comp (so two lower thirds can say different
17
+ names) and records the field values in comp.template. `hold` is the time the
18
+ exit animation starts: retime() keeps everything after it at the same
19
+ distance from the end, so a longer template just holds longer. */
20
+ (function (root, factory) {
21
+ const api = factory();
22
+ if (typeof module === "object" && module.exports) module.exports = api;
23
+ else root.FableCutTemplates = api;
24
+ })(typeof self !== "undefined" ? self : this, function () {
25
+ "use strict";
26
+
27
+ const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
28
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
29
+ const r4 = (v) => +(+v).toFixed(4);
30
+ const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
31
+ const clone = (v) => JSON.parse(JSON.stringify(v));
32
+ const slug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
33
+ const EPS = 1e-4;
34
+
35
+ /* ── Presets ── */
36
+ const EASES = ["linear", "ease-in", "ease-out", "ease-in-out"];
37
+ const ANCHORS = ["in", "out", "at", "whole"];
38
+ /** Channels a preset may key. scale and opacity multiply the clip's value; the rest add to it. */
39
+ const CHANNELS = ["x", "y", "scale", "rotation", "opacity", "blur", "anchorX", "anchorY", "trimStart", "trimEnd", "trimOffset",
40
+ "brightness", "saturation", "hue", "grayscale", "shake", "rgbSplit", "glow", "fontSize", "letterSpacing", "pathOffset"];
41
+ const MULTIPLY = new Set(["scale", "opacity"]);
42
+ const PRESET_PROPS = ["motionBlur", "motionBlurSamples"];
43
+ const REST = { scale: 1, opacity: 1, brightness: 100, saturation: 100, trimEnd: 100 };
44
+
45
+ const k = (t, v, ease) => (ease ? { t, v, ease } : { t, v });
46
+ const FLIP = { "ease-in": "ease-out", "ease-out": "ease-in" };
47
+ /** An exit from an entrance: the same moves played backwards (eases flip with them). */
48
+ function mirror(p, id, name) {
49
+ const keys = {};
50
+ for (const [ch, list] of Object.entries(p.keys)) {
51
+ const rev = list.map((x, i) => ({ t: r4(p.length - x.t), v: x.v, ease: i + 1 < list.length ? list[i + 1].ease : undefined })).reverse();
52
+ keys[ch] = rev.map((x) => (x.ease && x.ease !== "ease-in-out" ? { t: x.t, v: x.v, ease: FLIP[x.ease] || x.ease } : { t: x.t, v: x.v }));
53
+ }
54
+ return { id, name, group: "Out", anchor: "out", length: p.length, keys, ...(p.props ? { props: { ...p.props } } : {}) };
55
+ }
56
+
57
+ const IN = [
58
+ { id: "fade-in", name: "Fade in", length: 0.5, keys: { opacity: [k(0, 0), k(0.5, 1)] } },
59
+ { id: "pop-in", name: "Pop in", length: 0.4, keys: { scale: [k(0, 0), k(0.25, 1.12, "ease-out"), k(0.4, 1)], opacity: [k(0, 0), k(0.12, 1)] } },
60
+ { id: "slide-up-in", name: "Slide up", length: 0.6, keys: { y: [k(0, 120), k(0.6, 0, "ease-out")], opacity: [k(0, 0), k(0.3, 1)] } },
61
+ { id: "slide-down-in", name: "Slide down", length: 0.6, keys: { y: [k(0, -120), k(0.6, 0, "ease-out")], opacity: [k(0, 0), k(0.3, 1)] } },
62
+ { id: "slide-left-in", name: "Slide in from the right", length: 0.6, keys: { x: [k(0, 400), k(0.6, 0, "ease-out")], opacity: [k(0, 0), k(0.3, 1)] } },
63
+ { id: "slide-right-in", name: "Slide in from the left", length: 0.6, keys: { x: [k(0, -400), k(0.6, 0, "ease-out")], opacity: [k(0, 0), k(0.3, 1)] } },
64
+ { id: "drop-in", name: "Drop and bounce", length: 0.8, keys: { y: [k(0, -500), k(0.45, 0, "ease-in"), k(0.6, -40, "ease-out"), k(0.8, 0, "ease-in")], opacity: [k(0, 0), k(0.1, 1)] } },
65
+ { id: "zoom-in", name: "Zoom in", length: 0.6, keys: { scale: [k(0, 1.6), k(0.6, 1, "ease-out")], opacity: [k(0, 0), k(0.35, 1)], blur: [k(0, 14), k(0.6, 0, "ease-out")] } },
66
+ { id: "spin-in", name: "Spin in", length: 0.7, keys: { rotation: [k(0, -180), k(0.7, 0, "ease-out")], scale: [k(0, 0), k(0.7, 1, "ease-out")], opacity: [k(0, 0), k(0.2, 1)] } },
67
+ { id: "whip-in", name: "Whip in", length: 0.45, keys: { x: [k(0, -1400), k(0.45, 0, "ease-out")] }, props: { motionBlur: 180 } },
68
+ { id: "rise-blur", name: "Rise and focus", length: 0.8, keys: { y: [k(0, 40), k(0.8, 0, "ease-out")], blur: [k(0, 16), k(0.8, 0, "ease-out")], opacity: [k(0, 0), k(0.5, 1)] } },
69
+ { id: "draw-on", name: "Draw on (shapes)", length: 1, keys: { trimEnd: [k(0, -100), k(1, 0, "ease-out")] } },
70
+ ].map((p) => ({ ...p, group: "In", anchor: "in" }));
71
+ const OUT = [
72
+ mirror(IN[0], "fade-out", "Fade out"),
73
+ mirror(IN[1], "pop-out", "Pop out"),
74
+ mirror(IN[3], "slide-up-out", "Slide out up"),
75
+ mirror(IN[2], "slide-down-out", "Slide out down"),
76
+ mirror(IN[5], "slide-left-out", "Slide out to the left"),
77
+ mirror(IN[4], "slide-right-out", "Slide out to the right"),
78
+ mirror(IN[7], "zoom-out", "Zoom out"),
79
+ mirror(IN[8], "spin-out", "Spin out"),
80
+ { ...mirror(IN[9], "whip-out", "Whip out"), keys: { x: [k(0, 0), k(0.45, 1400, "ease-in")] } },
81
+ mirror(IN[10], "sink-blur", "Sink and blur"),
82
+ mirror(IN[11], "draw-off", "Draw off (shapes)"),
83
+ ];
84
+ const AT = [
85
+ { id: "pulse", name: "Pulse", length: 0.4, keys: { scale: [k(0, 1), k(0.15, 1.12, "ease-out"), k(0.4, 1)] } },
86
+ { id: "shake", name: "Shake", length: 0.35, keys: { x: [k(0, 0), k(0.05, -18), k(0.1, 16), k(0.15, -12), k(0.2, 8), k(0.27, -4), k(0.35, 0)] } },
87
+ { id: "wobble", name: "Wobble", length: 0.5, keys: { rotation: [k(0, 0), k(0.12, -8, "ease-out"), k(0.3, 6), k(0.5, 0)] } },
88
+ { id: "bounce", name: "Bounce", length: 0.65, keys: { y: [k(0, 0), k(0.2, -60, "ease-out"), k(0.4, 0, "ease-in"), k(0.52, -18, "ease-out"), k(0.65, 0, "ease-in")] } },
89
+ { id: "flash", name: "Flash", length: 0.4, keys: { brightness: [k(0, 0), k(0.06, 80), k(0.4, 0, "ease-out")] } },
90
+ ].map((p) => ({ ...p, group: "Emphasis", anchor: "at" }));
91
+ /* Loops are generated for the clip's length, a whole number of cycles. */
92
+ const cycles = (dur, period) => Math.max(1, Math.round(dur / period));
93
+ /** Keys stepping through `values` over and over, `steps` per cycle, ending where they began. */
94
+ const cycle = (dur, period, values) => {
95
+ const n = cycles(dur, period) * values.length, out = [];
96
+ for (let i = 0; i <= n; i++) out.push(k(r4(i * dur / n), values[i % values.length]));
97
+ return out;
98
+ };
99
+ const LOOP = [
100
+ { id: "float", name: "Float", gen: (dur) => ({ y: cycle(dur, 2.4, [0, -14]) }) },
101
+ { id: "breathe", name: "Breathe", gen: (dur) => ({ scale: cycle(dur, 1.2, [1, 1.06]) }) },
102
+ { id: "sway", name: "Sway", gen: (dur) => ({ rotation: cycle(dur, 2.4, [0, -6, 0, 6]) }) },
103
+ { id: "spin", name: "Spin", gen: (dur) => ({ rotation: [k(0, 0), k(r4(dur), 360 * cycles(dur, 4), "linear")] }) },
104
+ { id: "wiggle", name: "Wiggle", gen: (dur) => {
105
+ // a fixed pseudo-random walk, so the same clip wiggles the same way every time
106
+ let s = 7;
107
+ const rnd = () => ((s = (s * 16807) % 2147483647) / 2147483647) * 2 - 1;
108
+ const n = Math.max(2, Math.round(dur / 0.12)), x = [k(0, 0)], y = [k(0, 0)];
109
+ for (let i = 1; i < n; i++) { const t = r4(i * dur / n); x.push(k(t, r4(rnd() * 10))); y.push(k(t, r4(rnd() * 10))); }
110
+ x.push(k(r4(dur), 0)); y.push(k(r4(dur), 0));
111
+ return { x, y };
112
+ } },
113
+ ].map((p) => ({ ...p, group: "Loop", anchor: "whole" }));
114
+ const PRESETS = [...IN, ...OUT, ...AT, ...LOOP];
115
+
116
+ /** A preset, validated. strict: throw on bad input (agent / saved file). */
117
+ function normalizePreset(p, strict = false) {
118
+ const bad = (m) => { if (strict) throw new Error(m); return null; };
119
+ if (!isObj(p)) return bad("a preset is an object like {name, anchor, length, keys:{channel:[{t, v}]}}");
120
+ const name = typeof p.name === "string" && p.name.trim() ? p.name.trim().slice(0, 60) : null;
121
+ const id = slug(p.id || name);
122
+ if (!id) return bad("a preset needs a name");
123
+ const anchor = p.anchor || "in";
124
+ if (!ANCHORS.includes(anchor)) return bad(`preset ${id}: anchor is ${ANCHORS.join(" | ")}`);
125
+ const length = anchor === "whole" ? 1 : p.length;
126
+ if (!isNum(length) || length <= 0 || length > 60) return bad(`preset ${id}: length is seconds, more than 0`);
127
+ if (!isObj(p.keys) || !Object.keys(p.keys).length) return bad(`preset ${id}: keys is {channel: [{t, v, ease?}, …]}`);
128
+ const keys = {};
129
+ for (const [ch, list] of Object.entries(p.keys)) {
130
+ if (!CHANNELS.includes(ch)) return bad(`preset ${id}: ${ch} can't be animated by a preset (${CHANNELS.join(", ")})`);
131
+ if (!Array.isArray(list) || !list.length) return bad(`preset ${id}: keys.${ch} must be a list of {t, v, ease?}`);
132
+ const out = [];
133
+ for (const x of list) {
134
+ if (!isObj(x) || !isNum(x.t) || !isNum(x.v) || x.t < 0 || x.t > length + EPS) return bad(`preset ${id}: keys.${ch} times run 0…${anchor === "whole" ? "1 (fractions of the clip)" : length}`);
135
+ if (x.ease != null && !EASES.includes(x.ease)) return bad(`preset ${id}: ease is ${EASES.join(" | ")}`);
136
+ out.push(x.ease && x.ease !== "ease-in-out" ? k(r4(x.t), r4(x.v), x.ease) : k(r4(x.t), r4(x.v)));
137
+ }
138
+ keys[ch] = out.sort((a, b) => a.t - b.t);
139
+ }
140
+ const props = {};
141
+ for (const [key, v] of Object.entries(isObj(p.props) ? p.props : {})) {
142
+ if (!PRESET_PROPS.includes(key) || !isNum(v)) return bad(`preset ${id}: props may set ${PRESET_PROPS.join(", ")}`);
143
+ props[key] = v;
144
+ }
145
+ const group = typeof p.group === "string" && p.group.trim() ? p.group.trim().slice(0, 30) : "Saved";
146
+ return { id, name: name || id, group, anchor, ...(anchor === "whole" ? {} : { length: r4(length) }), keys, ...(Object.keys(props).length ? { props } : {}) };
147
+ }
148
+ /** Find a preset by id or name among the built-ins and `saved`. */
149
+ function findPreset(ref, saved = []) {
150
+ if (isObj(ref)) return normalizePreset(ref, true);
151
+ const want = slug(ref);
152
+ return [...PRESETS, ...saved].find((p) => p.id === want || slug(p.name) === want) || null;
153
+ }
154
+
155
+ /** A channel's value at clip time t (keys interpolated linearly — enough to find a resting value). */
156
+ function channelAt(c, ch, t) {
157
+ const list = c.keyframes && c.keyframes[ch];
158
+ if (Array.isArray(list) && list.length) {
159
+ if (t <= list[0].t) return list[0].v;
160
+ for (let i = 1; i < list.length; i++) {
161
+ const a = list[i - 1], b = list[i];
162
+ if (t <= b.t) return b.t - a.t > EPS ? a.v + (b.v - a.v) * (t - a.t) / (b.t - a.t) : b.v;
163
+ }
164
+ return list[list.length - 1].v;
165
+ }
166
+ const v = c.props && c.props[ch];
167
+ return isNum(v) ? v : (REST[ch] ?? 0);
168
+ }
169
+
170
+ /** Put preset `p` on clip `c` (mutates it). opts.length stretches it, opts.at (clip time) places an emphasis.
171
+ * Keys already in the window it covers are replaced; the rest stay. Returns the channels it keyed. */
172
+ function applyPreset(c, p, opts = {}) {
173
+ const D = c.duration;
174
+ if (!(D > 0)) throw new Error("the clip has no length");
175
+ let w0 = 0, w1 = D, keys;
176
+ if (p.anchor === "whole") {
177
+ keys = p.gen ? p.gen(D) : Object.fromEntries(Object.entries(p.keys).map(([ch, l]) => [ch, l.map((x) => ({ ...x, t: x.t * D }))]));
178
+ } else {
179
+ const L = Math.min(D, isNum(opts.length) && opts.length > 0 ? opts.length : p.length), s = L / p.length;
180
+ if (p.anchor === "in") w1 = L;
181
+ else if (p.anchor === "out") w0 = D - L;
182
+ else { w0 = clamp(isNum(opts.at) ? opts.at : 0, 0, D - L); w1 = w0 + L; }
183
+ keys = Object.fromEntries(Object.entries(p.keys).map(([ch, l]) => [ch, l.map((x) => ({ ...x, t: w0 + x.t * s }))]));
184
+ }
185
+ c.keyframes = c.keyframes || {};
186
+ for (const [ch, list] of Object.entries(keys)) {
187
+ // the value the animation settles on (or starts from): what the clip shows at that edge of the window today
188
+ const base = p.anchor === "in" ? channelAt(c, ch, w1) : p.anchor === "whole" ? channelAt(c, ch, 0) : channelAt(c, ch, w0);
189
+ const keep = p.anchor === "whole" ? [] : (c.keyframes[ch] || []).filter((x) => x.t < w0 - EPS || x.t > w1 + EPS);
190
+ const add = list.map((x) => {
191
+ const v = MULTIPLY.has(ch) ? base * x.v : base + x.v;
192
+ return x.ease && x.ease !== "ease-in-out" ? k(r4(x.t), r4(v), x.ease) : k(r4(x.t), r4(v));
193
+ });
194
+ c.keyframes[ch] = [...keep, ...add].sort((a, b) => a.t - b.t);
195
+ }
196
+ if (p.props) { c.props = c.props || {}; Object.assign(c.props, p.props); }
197
+ return Object.keys(keys);
198
+ }
199
+
200
+ /** A preset made from a clip's own keyframes between clip times from and to. */
201
+ function presetFromClip(c, { name, anchor = "in", from = 0, to = c.duration } = {}) {
202
+ if (!ANCHORS.includes(anchor)) throw new Error(`anchor is ${ANCHORS.join(" | ")}`);
203
+ if (!(to - from > EPS)) throw new Error("the window to save is empty");
204
+ const L = to - from, keys = {};
205
+ for (const ch of CHANNELS) {
206
+ const list = (c.keyframes && c.keyframes[ch] || []).filter((x) => x.t >= from - EPS && x.t <= to + EPS);
207
+ if (!list.length) continue;
208
+ const base = anchor === "in" ? channelAt(c, ch, to) : anchor === "whole" ? (isNum(c.props && c.props[ch]) ? c.props[ch] : (REST[ch] ?? 0)) : channelAt(c, ch, from);
209
+ keys[ch] = list.map((x) => {
210
+ const v = MULTIPLY.has(ch) ? (Math.abs(base) > EPS ? x.v / base : x.v) : x.v - base;
211
+ const t = anchor === "whole" ? (x.t - from) / L : x.t - from;
212
+ return x.ease && x.ease !== "ease-in-out" ? k(r4(t), r4(v), x.ease) : k(r4(t), r4(v));
213
+ });
214
+ }
215
+ if (!Object.keys(keys).length) throw new Error("the clip has no keyframes a preset can carry there (" + CHANNELS.join(", ") + ")");
216
+ const props = {};
217
+ for (const key of PRESET_PROPS) if (isNum(c.props && c.props[key]) && c.props[key] > 0) props[key] = c.props[key];
218
+ return normalizePreset({ name: name || c.name || "Preset", anchor, length: L, keys, props }, true);
219
+ }
220
+
221
+ /* ── Templates ── */
222
+ const FIELD_TYPES = ["text", "color", "media"];
223
+ const COLOR = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
224
+ const fade = (d) => ({ type: "fade", duration: d });
225
+ const text = (id, track, start, duration, props, extra = {}) =>
226
+ ({ id, kind: "text", mediaId: null, track, start, in: 0, duration, name: extra.name || id, props, ...extra.clip });
227
+ const shape = (id, track, start, duration, props, extra = {}) =>
228
+ ({ id, kind: "shape", mediaId: null, track, start, in: 0, duration, name: extra.name || id, props, ...extra.clip });
229
+ const LOGO_MEDIA = { id: "tm_logo", name: "logo-placeholder.svg", kind: "svg", src: "/library/elements/logo-placeholder.svg" };
230
+ const logo = (track, start, duration, extra = {}) =>
231
+ ({ id: "logo", kind: "svg", mediaId: LOGO_MEDIA.id, track, start, in: 0, duration, name: "Logo", props: { fit: "contain", scale: 0.22, ...extra.props }, ...extra.clip });
232
+ const TRACKS3 = [{ id: "V3", kind: "video" }, { id: "V2", kind: "video" }, { id: "V1", kind: "video" }];
233
+ const TRACKS4 = [{ id: "V4", kind: "video" }, ...TRACKS3];
234
+
235
+ const TEMPLATES = [
236
+ /* ── Lower thirds (1920×1080, the bottom-left of the frame) ── */
237
+ { id: "lower-third-bar", name: "Lower third — Bar", group: "Lower thirds", width: 1920, height: 1080, duration: 5, hold: 4.5, tracks: TRACKS3,
238
+ clips: [
239
+ shape("bar", "V1", 0, 5, { x: -840, y: 360, shapes: [
240
+ { type: "rect", name: "Panel", x: 340, w: 680, h: 140, radius: 6, fill: "#111827e6", keys: [{ t: 0, x: 0, w: 0 }, { t: 0.5, x: 340, w: 680, ease: "ease-out" }] },
241
+ { type: "rect", name: "Accent", x: 5, w: 10, h: 140, fill: "#ffd166", keys: [{ t: 0, h: 0 }, { t: 0.35, h: 140, ease: "ease-out" }] }] },
242
+ { name: "Bar", clip: { transitionOut: fade(0.4) } }),
243
+ text("name", "V2", 0.25, 4.75, { text: "Ava Moreno", font: "Archivo Black", fontSize: 54, color: "#ffffff", align: "left", boxW: 620, boxH: 140, x: -480, y: 335, textShadow: 0, textAnim: "rise-mask" },
244
+ { name: "Name", clip: { transitionOut: fade(0.4) } }),
245
+ text("title", "V3", 0.45, 4.55, { text: "Director of Photography", font: "Inter", fontSize: 30, bold: false, color: "#d1d5db", align: "left", boxW: 620, boxH: 80, x: -480, y: 394, textShadow: 0, textAnim: "clip-reveal" },
246
+ { name: "Title", clip: { transitionOut: fade(0.4) } }),
247
+ ],
248
+ fields: [
249
+ { key: "name", label: "Name", type: "text", targets: ["name:props.text"] },
250
+ { key: "title", label: "Title", type: "text", targets: ["title:props.text"] },
251
+ { key: "accent", label: "Accent", type: "color", targets: ["bar:props.shapes.1.fill"] },
252
+ { key: "panel", label: "Panel", type: "color", targets: ["bar:props.shapes.0.fill"] },
253
+ ] },
254
+ { id: "lower-third-underline", name: "Lower third — Underline", group: "Lower thirds", width: 1920, height: 1080, duration: 5, hold: 4.5, tracks: TRACKS3,
255
+ clips: [
256
+ text("name", "V3", 0, 5, { text: "AVA MORENO", font: "Montserrat", fontSize: 60, weight: 800, color: "#ffffff", align: "left", boxW: 900, boxH: 156, x: -390, y: 318, letterSpacing: 2, textShadow: 10, textAnim: "letter-pop", wordRate: 0.04 },
257
+ { name: "Name", clip: { transitionOut: fade(0.35) } }),
258
+ shape("rule", "V2", 0.2, 4.8, { x: -840, y: 362, shapes: [
259
+ { type: "line", name: "Line", points: [[0, 0], [560, 0]], stroke: "#22d3ee", strokeWidth: 6, lineCap: "round", keys: [{ t: 0, trimEnd: 0 }, { t: 0.7, trimEnd: 100, ease: "ease-out" }] }] },
260
+ { name: "Underline", clip: { transitionOut: fade(0.35) } }),
261
+ text("title", "V1", 0.6, 4.4, { text: "Director of Photography", font: "DM Sans", fontSize: 32, bold: false, color: "#e5e7eb", align: "left", boxW: 900, boxH: 84, x: -390, y: 402, textShadow: 8, textAnim: "rise-mask" },
262
+ { name: "Title", clip: { transitionOut: fade(0.35) } }),
263
+ ],
264
+ fields: [
265
+ { key: "name", label: "Name", type: "text", targets: ["name:props.text"] },
266
+ { key: "title", label: "Title", type: "text", targets: ["title:props.text"] },
267
+ { key: "accent", label: "Accent", type: "color", targets: ["rule:props.shapes.0.stroke"] },
268
+ ] },
269
+ { id: "lower-third-boxes", name: "Lower third — Boxes", group: "Lower thirds", width: 1920, height: 1080, duration: 5, hold: 4.5, tracks: TRACKS4,
270
+ clips: [
271
+ shape("nameBox", "V1", 0, 5, { x: -530, y: 330, motionBlur: 180, shapes: [{ type: "rect", name: "Box", w: 620, h: 78, radius: 4, fill: "#ffd166" }] },
272
+ { name: "Name box", clip: { keyframes: { x: [{ t: 0, v: -1500 }, { t: 0.45, v: -530, ease: "ease-out" }, { t: 4.55, v: -530 }, { t: 5, v: -1500, ease: "ease-in" }] } } }),
273
+ shape("titleBox", "V2", 0.12, 4.88, { x: -530, y: 396, motionBlur: 180, shapes: [{ type: "rect", name: "Box", w: 620, h: 54, radius: 4, fill: "#111827eb" }] },
274
+ { name: "Title box", clip: { keyframes: { x: [{ t: 0, v: -1500 }, { t: 0.45, v: -530, ease: "ease-out" }, { t: 4.4, v: -530 }, { t: 4.88, v: -1500, ease: "ease-in" }] } } }),
275
+ text("name", "V3", 0, 5, { text: "Ava Moreno", font: "Poppins", fontSize: 44, weight: 700, color: "#111111", align: "left", boxW: 580, boxH: 78, textShadow: 0, motionBlur: 180 },
276
+ { name: "Name", clip: { parent: "nameBox" } }),
277
+ text("title", "V4", 0.12, 4.88, { text: "Director of Photography", font: "Poppins", fontSize: 27, bold: false, weight: 400, color: "#ffffff", align: "left", boxW: 580, boxH: 54, textShadow: 0, motionBlur: 180 },
278
+ { name: "Title", clip: { parent: "titleBox" } }),
279
+ ],
280
+ fields: [
281
+ { key: "name", label: "Name", type: "text", targets: ["name:props.text"] },
282
+ { key: "title", label: "Title", type: "text", targets: ["title:props.text"] },
283
+ { key: "accent", label: "Accent", type: "color", targets: ["nameBox:props.shapes.0.fill"] },
284
+ { key: "box", label: "Title box", type: "color", targets: ["titleBox:props.shapes.0.fill"] },
285
+ ] },
286
+
287
+ /* ── Logo animations (1920×1080, transparent) ── */
288
+ { id: "logo-ring", name: "Logo — Ring reveal", group: "Logo animations", width: 1920, height: 1080, duration: 4, hold: 3.5, tracks: TRACKS3, media: [LOGO_MEDIA],
289
+ clips: [
290
+ shape("ring", "V1", 0, 4, { y: -40, shapes: [
291
+ { type: "ellipse", name: "Ring", w: 330, h: 330, rotation: -90, fill: "none", stroke: "#ffd166", strokeWidth: 10, keys: [{ t: 0, trimEnd: 0 }, { t: 0.9, trimEnd: 100, ease: "ease-out" }] }] },
292
+ { name: "Ring", clip: { transitionOut: fade(0.5) } }),
293
+ logo("V2", 0.35, 3.65, { props: { y: -40, scale: 0.2 }, clip: { keyframes: { scale: [{ t: 0, v: 0 }, { t: 0.35, v: 0.23, ease: "ease-out" }, { t: 0.5, v: 0.2 }], opacity: [{ t: 0, v: 0 }, { t: 0.15, v: 1 }] }, transitionOut: fade(0.5) } }),
294
+ text("brand", "V3", 0.9, 3.1, { text: "YOUR BRAND", font: "Bebas Neue", fontSize: 72, color: "#ffffff", y: 200, letterSpacing: 14, textShadow: 0, textAnim: "rise-mask" },
295
+ { name: "Brand", clip: { transitionOut: fade(0.5) } }),
296
+ ],
297
+ fields: [
298
+ { key: "logo", label: "Logo", type: "media", targets: ["logo:mediaId"] },
299
+ { key: "brand", label: "Brand", type: "text", targets: ["brand:props.text"] },
300
+ { key: "accent", label: "Accent", type: "color", targets: ["ring:props.shapes.0.stroke"] },
301
+ ] },
302
+ { id: "logo-slam", name: "Logo — Slam", group: "Logo animations", width: 1920, height: 1080, duration: 3.5, hold: 3, tracks: TRACKS3, media: [LOGO_MEDIA],
303
+ clips: [
304
+ shape("burst", "V1", 0.35, 0.7, { shapes: [{ type: "group", name: "Burst", scale: 0.5, items:
305
+ [0, 45, 90, 135, 180, 225, 270, 315].map((r) => ({ type: "line", rotation: r, points: [[190, 0], [300, 0]], stroke: "#ff7ac8", strokeWidth: 8, lineCap: "round" })),
306
+ keys: [{ t: 0, scale: 0.5, opacity: 1 }, { t: 0.6, scale: 1.25, opacity: 0, ease: "ease-out" }] }] }, { name: "Burst" }),
307
+ logo("V2", 0, 3.5, { props: { motionBlur: 180 }, clip: {
308
+ keyframes: { scale: [{ t: 0, v: 1.4 }, { t: 0.35, v: 0.22, ease: "ease-in" }], opacity: [{ t: 0, v: 0 }, { t: 0.2, v: 1 }], blur: [{ t: 0, v: 18 }, { t: 0.35, v: 0, ease: "ease-in" }], shake: [{ t: 0.35, v: 0 }, { t: 0.4, v: 22 }, { t: 0.8, v: 0, ease: "ease-out" }] },
309
+ transitionOut: fade(0.5) } }),
310
+ ],
311
+ fields: [
312
+ { key: "logo", label: "Logo", type: "media", targets: ["logo:mediaId"] },
313
+ { key: "accent", label: "Burst", type: "color", targets: [0, 1, 2, 3, 4, 5, 6, 7].map((i) => `burst:props.shapes.0.items.${i}.stroke`) },
314
+ ] },
315
+ { id: "logo-lines", name: "Logo — Lines", group: "Logo animations", width: 1920, height: 1080, duration: 4, hold: 3.5, tracks: TRACKS3, media: [LOGO_MEDIA],
316
+ clips: [
317
+ shape("lines", "V1", 0, 4, { shapes: [
318
+ { type: "line", name: "Top", y: -170, points: [[-300, 0], [300, 0]], stroke: "#ffffff", strokeWidth: 3, trimStart: 50, trimEnd: 50, keys: [{ t: 0, trimStart: 50, trimEnd: 50 }, { t: 0.8, trimStart: 0, trimEnd: 100, ease: "ease-out" }] },
319
+ { type: "line", name: "Bottom", y: 150, points: [[-300, 0], [300, 0]], stroke: "#ffffff", strokeWidth: 3, trimStart: 50, trimEnd: 50, keys: [{ t: 0, trimStart: 50, trimEnd: 50 }, { t: 0.8, trimStart: 0, trimEnd: 100, ease: "ease-out" }] }] },
320
+ { name: "Lines", clip: { transitionOut: fade(0.5) } }),
321
+ logo("V2", 0.4, 3.6, { props: { y: -20, scale: 0.18 }, clip: { keyframes: { y: [{ t: 0, v: 20 }, { t: 0.6, v: -20, ease: "ease-out" }], opacity: [{ t: 0, v: 0 }, { t: 0.5, v: 1 }] }, transitionOut: fade(0.5) } }),
322
+ text("tagline", "V3", 1, 3, { text: "Made with care", font: "Playfair Display", fontSize: 40, italic: true, bold: false, color: "#ffffff", y: 205, textShadow: 0, textAnim: "typewriter" },
323
+ { name: "Tagline", clip: { transitionOut: fade(0.5) } }),
324
+ ],
325
+ fields: [
326
+ { key: "logo", label: "Logo", type: "media", targets: ["logo:mediaId"] },
327
+ { key: "tagline", label: "Tagline", type: "text", targets: ["tagline:props.text"] },
328
+ { key: "lines", label: "Lines", type: "color", targets: ["lines:props.shapes.0.stroke", "lines:props.shapes.1.stroke"] },
329
+ ] },
330
+
331
+ /* ── Social sizes ── */
332
+ { id: "social-story", name: "Story — Headline (9:16)", group: "Social", width: 1080, height: 1920, duration: 6, hold: 5.4, background: "#14112b", tracks: TRACKS4,
333
+ clips: [
334
+ shape("blobs", "V1", 0, 6, { shapes: [
335
+ { type: "ellipse", name: "Big", x: 330, y: -620, w: 720, h: 720, fill: "#7b6cff", keys: [{ t: 0, scale: 0 }, { t: 0.7, scale: 1, ease: "ease-out" }] },
336
+ { type: "ellipse", name: "Small", x: -380, y: 700, w: 420, h: 420, fill: "#ff7ac8", keys: [{ t: 0.2, scale: 0 }, { t: 0.9, scale: 1, ease: "ease-out" }] }] },
337
+ { name: "Shapes", clip: { transitionOut: fade(0.6) } }),
338
+ text("headline", "V2", 0.4, 5.6, { text: "Big news is here", font: "Anton", fontSize: 150, color: "#ffffff", boxW: 900, boxH: 640, y: -120, lineHeight: 1.05, uppercase: true, textShadow: 0, textAnim: "word-pop", wordRate: 0.12 },
339
+ { name: "Headline", clip: { transitionOut: fade(0.6) } }),
340
+ text("subline", "V3", 1.1, 4.9, { text: "Everything you need to know, in one minute", font: "DM Sans", fontSize: 56, bold: false, color: "#d9d6ff", boxW: 860, boxH: 200, y: 260, textShadow: 0, textAnim: "rise-mask" },
341
+ { name: "Subline", clip: { transitionOut: fade(0.6) } }),
342
+ text("button", "V4", 1.7, 4.3, { text: "Read more", font: "Poppins", fontSize: 44, weight: 700, color: "#14112b", bgColor: "#ffd166", bgOpacity: 1, y: 560, textShadow: 0 },
343
+ { name: "Button", clip: { keyframes: { scale: [{ t: 0, v: 0 }, { t: 0.25, v: 1.12, ease: "ease-out" }, { t: 0.4, v: 1 }] }, transitionOut: fade(0.6) } }),
344
+ ],
345
+ fields: [
346
+ { key: "headline", label: "Headline", type: "text", targets: ["headline:props.text"] },
347
+ { key: "subline", label: "Subline", type: "text", targets: ["subline:props.text"] },
348
+ { key: "button", label: "Button", type: "text", targets: ["button:props.text"] },
349
+ { key: "accent", label: "Accent", type: "color", targets: ["blobs:props.shapes.0.fill"] },
350
+ { key: "background", label: "Background", type: "color", targets: ["comp:background"] },
351
+ ] },
352
+ { id: "social-quote", name: "Post — Quote (1:1)", group: "Social", width: 1080, height: 1080, duration: 6, hold: 5.4, background: "#0f172a", tracks: TRACKS3,
353
+ clips: [
354
+ text("mark", "V1", 0, 6, { text: "“", font: "Playfair Display", fontSize: 360, color: "#ffd166", x: -340, y: -260, textShadow: 0 },
355
+ { name: "Quote mark", clip: { keyframes: { scale: [{ t: 0, v: 0 }, { t: 0.3, v: 1.1, ease: "ease-out" }, { t: 0.45, v: 1 }] }, transitionOut: fade(0.6) } }),
356
+ text("quote", "V2", 0.35, 5.65, { text: "The best way to predict the future is to make it.", font: "Playfair Display", fontSize: 64, bold: false, color: "#ffffff", boxW: 840, boxH: 480, y: 10, lineHeight: 1.25, textShadow: 0, textAnim: "word-slide", wordRate: 0.1 },
357
+ { name: "Quote", clip: { transitionOut: fade(0.6) } }),
358
+ text("author", "V3", 1.8, 4.2, { text: "— A. Writer", font: "DM Sans", fontSize: 38, bold: false, color: "#94a3b8", y: 300, letterSpacing: 2, textShadow: 0, textAnim: "rise-mask" },
359
+ { name: "Author", clip: { transitionOut: fade(0.6) } }),
360
+ ],
361
+ fields: [
362
+ { key: "quote", label: "Quote", type: "text", targets: ["quote:props.text"] },
363
+ { key: "author", label: "Author", type: "text", targets: ["author:props.text"] },
364
+ { key: "accent", label: "Accent", type: "color", targets: ["mark:props.color"] },
365
+ { key: "background", label: "Background", type: "color", targets: ["comp:background"] },
366
+ ] },
367
+ { id: "social-end-card", name: "End card — Follow (16:9)", group: "Social", width: 1920, height: 1080, duration: 6, hold: 5.4, background: "#0b0b12", tracks: TRACKS3,
368
+ clips: [
369
+ text("heading", "V1", 0, 6, { text: "Thanks for watching", font: "Montserrat", fontSize: 96, weight: 800, color: "#ffffff", y: -110, textShadow: 0, textAnim: "rise-mask" },
370
+ { name: "Heading", clip: { transitionOut: fade(0.6) } }),
371
+ shape("pill", "V2", 0.6, 5.4, { y: 90, shapes: [{ type: "rect", name: "Pill", w: 520, h: 110, radius: 55, fill: "#ff4d6d" }] },
372
+ { name: "Pill", clip: { keyframes: { scale: [{ t: 0, v: 0 }, { t: 0.3, v: 1.1, ease: "ease-out" }, { t: 0.45, v: 1 }] }, transitionOut: fade(0.6) } }),
373
+ text("button", "V3", 0.75, 5.25, { text: "Follow for more", font: "Poppins", fontSize: 44, weight: 700, color: "#ffffff", y: 90, textShadow: 0 },
374
+ { name: "Button", clip: { keyframes: { opacity: [{ t: 0, v: 0 }, { t: 0.25, v: 1 }] }, transitionOut: fade(0.6) } }),
375
+ ],
376
+ fields: [
377
+ { key: "heading", label: "Heading", type: "text", targets: ["heading:props.text"] },
378
+ { key: "button", label: "Button", type: "text", targets: ["button:props.text"] },
379
+ { key: "accent", label: "Button colour", type: "color", targets: ["pill:props.shapes.0.fill"] },
380
+ { key: "background", label: "Background", type: "color", targets: ["comp:background"] },
381
+ ] },
382
+ ];
383
+
384
+ /** Read a target ("clip:path" or "comp:background") inside a comp; returns {obj, key} to read or write. */
385
+ function resolveTarget(comp, target) {
386
+ const i = String(target).indexOf(":");
387
+ if (i < 1) return null;
388
+ const who = target.slice(0, i), path = target.slice(i + 1).split(".");
389
+ let obj = who === "comp" ? comp : (comp.clips || []).find((c) => c.id === who);
390
+ if (!obj || !path.length || path.some((s) => !s || s === "__proto__" || s === "constructor" || s === "prototype")) return null;
391
+ for (const seg of path.slice(0, -1)) {
392
+ obj = obj[seg];
393
+ if (obj == null || typeof obj !== "object") return null;
394
+ }
395
+ return { obj, key: path[path.length - 1] };
396
+ }
397
+ /** A template, validated. strict: throw on bad input (agent / saved file). */
398
+ function normalizeTemplate(t, strict = false) {
399
+ const bad = (m) => { if (strict) throw new Error(m); return null; };
400
+ if (!isObj(t)) return bad("a template is an object like {name, width, height, duration, clips, fields}");
401
+ const name = typeof t.name === "string" && t.name.trim() ? t.name.trim().slice(0, 80) : null;
402
+ const id = slug(t.id || name);
403
+ if (!id) return bad("a template needs a name");
404
+ for (const key of ["width", "height"]) if (!Number.isInteger(t[key]) || t[key] < 16 || t[key] > 8192) return bad(`template ${id}: ${key} must be a whole number of px (16…8192)`);
405
+ if (!isNum(t.duration) || t.duration <= 0) return bad(`template ${id}: duration must be seconds > 0`);
406
+ const hold = isNum(t.hold) ? clamp(t.hold, 0, t.duration) : t.duration;
407
+ if (!Array.isArray(t.clips) || !t.clips.length) return bad(`template ${id}: clips must be a list`);
408
+ const ids = new Set();
409
+ for (const c of t.clips) {
410
+ if (!isObj(c) || typeof c.id !== "string" || !c.track || !isNum(c.start) || !isNum(c.duration)) return bad(`template ${id}: every clip needs id, track, numeric start and duration`);
411
+ if (ids.has(c.id)) return bad(`template ${id}: two clips have the id ${c.id}`);
412
+ ids.add(c.id);
413
+ }
414
+ const media = [];
415
+ for (const m of Array.isArray(t.media) ? t.media : []) {
416
+ if (!isObj(m) || typeof m.id !== "string" || typeof m.src !== "string" || !/^\/(library|media)\//.test(m.src) || !["image", "svg", "video", "audio"].includes(m.kind))
417
+ return bad(`template ${id}: media entries are {id, name, kind, src:"/library/…" | "/media/…"}`);
418
+ media.push({ id: m.id, name: typeof m.name === "string" ? m.name : m.src.split("/").pop(), kind: m.kind, src: m.src });
419
+ }
420
+ const fields = [], keys = new Set();
421
+ const probe = { ...t, clips: t.clips };
422
+ for (const f of Array.isArray(t.fields) ? t.fields : []) {
423
+ if (!isObj(f) || !slug(f.key)) return bad(`template ${id}: a field needs a key`);
424
+ const key = slug(f.key);
425
+ if (keys.has(key)) return bad(`template ${id}: two fields are called ${key}`);
426
+ if (!FIELD_TYPES.includes(f.type)) return bad(`template ${id}: field ${key} type is ${FIELD_TYPES.join(" | ")}`);
427
+ if (!Array.isArray(f.targets) || !f.targets.length || f.targets.some((x) => typeof x !== "string" || !resolveTarget(probe, x)))
428
+ return bad(`template ${id}: field ${key} targets are "<clip id>:<path>" (e.g. "name:props.text") or "comp:background", and must exist`);
429
+ keys.add(key);
430
+ fields.push({ key, label: typeof f.label === "string" && f.label.trim() ? f.label.trim().slice(0, 40) : key, type: f.type, targets: f.targets.slice(0, 32) });
431
+ }
432
+ return {
433
+ id, name: name || id, group: typeof t.group === "string" && t.group.trim() ? t.group.trim().slice(0, 30) : "Saved",
434
+ width: t.width, height: t.height, duration: r4(t.duration), hold: r4(hold),
435
+ ...(typeof t.background === "string" ? { background: t.background } : {}),
436
+ ...(Array.isArray(t.tracks) ? { tracks: clone(t.tracks) } : {}),
437
+ clips: clone(t.clips), ...(media.length ? { media } : {}), fields,
438
+ };
439
+ }
440
+ function findTemplate(ref, saved = []) {
441
+ if (isObj(ref)) return normalizeTemplate(ref, true);
442
+ const want = slug(ref);
443
+ return [...TEMPLATES, ...saved].find((t) => t.id === want || slug(t.name) === want) || null;
444
+ }
445
+
446
+ /** Write one field's value into a template comp (mutates it). doc: for media fields. */
447
+ function setField(comp, key, value, doc = {}) {
448
+ const tpl = comp.template;
449
+ const f = tpl && (tpl.fields || []).find((x) => x.key === key);
450
+ if (!f) throw new Error(`comp ${comp.id} has no field ${key}${tpl ? ` (fields: ${(tpl.fields || []).map((x) => x.key).join(", ")})` : " — it isn't a template"}`);
451
+ if (f.type === "text") {
452
+ if (typeof value !== "string") throw new Error(`field ${key} is text`);
453
+ value = value.slice(0, 2000);
454
+ } else if (f.type === "color") {
455
+ if (typeof value !== "string" || !COLOR.test(value)) throw new Error(`field ${key} is a colour like "#ffd166"`);
456
+ } else {
457
+ const m = (doc.media || []).find((x) => x.id === value);
458
+ if (!m) throw new Error(`field ${key} takes a media id from the project`);
459
+ if (!["image", "svg", "video"].includes(m.kind)) throw new Error(`field ${key} needs a picture (image, svg or video), not ${m.kind}`);
460
+ }
461
+ for (const target of f.targets) {
462
+ const r = resolveTarget(comp, target);
463
+ if (!r) continue;
464
+ r.obj[r.key] = value;
465
+ if (f.type === "media") r.obj.kind = (doc.media || []).find((x) => x.id === value).kind; // an svg logo replaced by a png becomes an image clip
466
+ }
467
+ f.value = value;
468
+ }
469
+
470
+ /** Read a field's current value from the comp itself. */
471
+ function fieldValue(comp, f) {
472
+ for (const target of f.targets) { const r = resolveTarget(comp, target); if (r && r.obj[r.key] != null) return r.obj[r.key]; }
473
+ return f.value ?? null;
474
+ }
475
+
476
+ /** Shift every key after clip time `after` by `delta` (clip keyframes and shape item keys). */
477
+ function shiftKeysAfter(c, after, delta) {
478
+ for (const list of Object.values(c.keyframes || {})) if (Array.isArray(list)) for (const x of list) if (x.t > after + EPS) x.t = r4(x.t + delta);
479
+ const walk = (items) => { for (const it of items || []) { for (const x of it.keys || []) if (x.t > after + EPS) x.t = r4(x.t + delta); walk(it.items); } };
480
+ walk(c.props && c.props.shapes);
481
+ }
482
+ /** Change a template comp's length: what happens after its hold point keeps its distance from the end. */
483
+ function retime(comp, duration) {
484
+ const hold = comp.template && isNum(comp.template.hold) ? comp.template.hold : comp.duration;
485
+ // the hold point can move back only to where the entrance has finished: the last key or clip start before it
486
+ let settled = 0;
487
+ const note = (t) => { if (t <= hold + EPS) settled = Math.max(settled, t); };
488
+ for (const c of comp.clips || []) {
489
+ note(c.start);
490
+ for (const list of Object.values(c.keyframes || {})) if (Array.isArray(list)) for (const x of list) note(c.start + x.t);
491
+ const walk = (items) => { for (const it of items || []) { for (const x of it.keys || []) note(c.start + x.t); walk(it.items); } };
492
+ walk(c.props && c.props.shapes);
493
+ }
494
+ const min = r4(Math.max(0.5, settled + 0.25 + (comp.duration - hold)));
495
+ if (!isNum(duration) || duration < min) throw new Error(`comp ${comp.id} can't be shorter than ${min}s (its animation needs that long)`);
496
+ const delta = duration - comp.duration;
497
+ if (Math.abs(delta) < EPS) return comp;
498
+ for (const c of comp.clips || []) {
499
+ if (c.start >= hold - EPS) c.start = r4(c.start + delta);
500
+ else if (c.start + c.duration > hold + EPS) { c.duration = r4(c.duration + delta); shiftKeysAfter(c, hold - c.start, delta); }
501
+ }
502
+ if (comp.template) comp.template.hold = r4(hold + delta);
503
+ comp.duration = r4(duration);
504
+ return comp;
505
+ }
506
+
507
+ const rid = (p) => p + Math.random().toString(36).slice(2, 9);
508
+ /** Place template `tpl` in doc: a new comp (added to doc.comps), any media it needs (added to doc.media,
509
+ * reusing entries with the same src) and a comp clip, returned (not added). */
510
+ function instantiate(doc, tpl, { compId, clipId, name, start = 0, track = "V2", duration, fields = {} } = {}) {
511
+ const t = normalizeTemplate(tpl, true);
512
+ doc.media = doc.media || [];
513
+ doc.comps = doc.comps || [];
514
+ const mediaMap = {};
515
+ for (const m of t.media || []) {
516
+ let have = doc.media.find((x) => x.src === m.src);
517
+ if (!have) { have = { id: rid("m_"), name: m.name, kind: m.kind, src: m.src }; doc.media.push(have); }
518
+ mediaMap[m.id] = have.id;
519
+ }
520
+ const clips = t.clips.map((c) => (c.mediaId && mediaMap[c.mediaId] ? { ...c, mediaId: mediaMap[c.mediaId] } : c));
521
+ const id = compId || rid("tpl_");
522
+ if (doc.comps.some((c) => c.id === id)) throw new Error(`a comp ${id} already exists`);
523
+ const n = doc.comps.filter((c) => c.template && c.template.id === t.id).length + 1;
524
+ const comp = {
525
+ id, name: (name || (n > 1 ? `${t.name} ${n}` : t.name)).slice(0, 80), width: t.width, height: t.height, duration: t.duration,
526
+ ...(t.background ? { background: t.background } : {}), ...(t.tracks ? { tracks: t.tracks } : {}), clips,
527
+ template: { id: t.id, hold: t.hold, fields: t.fields.map((f) => ({ ...f, targets: f.targets.slice() })) },
528
+ };
529
+ for (const f of comp.template.fields) f.value = fieldValue(comp, f);
530
+ for (const [key, v] of Object.entries(fields || {})) setField(comp, slug(key), v, doc);
531
+ if (duration != null) retime(comp, duration);
532
+ doc.comps.push(comp);
533
+ const clip = { id: clipId || rid("c_"), kind: "comp", compId: id, mediaId: null, track, start: r4(Math.max(0, start)), in: 0, duration: comp.duration, name: comp.name, props: { fit: "contain" } };
534
+ return { comp, clip };
535
+ }
536
+
537
+ /** A template made from a comp. fields: as in a template, or left out to offer every title's text and every picture. */
538
+ function templateFromComp(comp, { name, group = "Saved", fields, hold, media = [] } = {}) {
539
+ const auto = [];
540
+ const seen = new Set();
541
+ for (const c of comp.clips || []) {
542
+ const label = (c.name || c.id).slice(0, 40);
543
+ let key = slug(label) || slug(c.id);
544
+ while (seen.has(key)) key += "-2";
545
+ if (c.kind === "text") { seen.add(key); auto.push({ key, label, type: "text", targets: [`${c.id}:props.text`] }); }
546
+ else if ((c.kind === "image" || c.kind === "svg") && c.mediaId) { seen.add(key); auto.push({ key, label, type: "media", targets: [`${c.id}:mediaId`] }); }
547
+ }
548
+ const own = comp.template && comp.template.fields;
549
+ const used = new Set((comp.clips || []).map((c) => c.mediaId).filter(Boolean));
550
+ return normalizeTemplate({
551
+ name: name || comp.name, group, width: comp.width, height: comp.height, duration: comp.duration,
552
+ hold: isNum(hold) ? hold : comp.template && isNum(comp.template.hold) ? comp.template.hold : comp.duration,
553
+ background: comp.background, tracks: comp.tracks, clips: comp.clips,
554
+ media: media.filter((m) => used.has(m.id)),
555
+ fields: fields || (own ? own.map(({ key, label, type, targets }) => ({ key, label, type, targets })) : auto),
556
+ }, true);
557
+ }
558
+
559
+ /** A preset / template about to be saved, renamed if it would take a built-in's id (it couldn't be found then). */
560
+ function asSaved(item, kind) {
561
+ const builtIn = kind === "presets" ? PRESETS : TEMPLATES;
562
+ if (!builtIn.some((x) => x.id === item.id || slug(x.name) === item.id)) return item;
563
+ const name = `${item.name} (saved)`.slice(0, kind === "presets" ? 60 : 80);
564
+ return { ...item, id: slug(name), name };
565
+ }
566
+
567
+ /** One line per preset / template, for docs and agent errors. */
568
+ const presetLine = (p) => `${p.id} (${p.group.toLowerCase()}${p.length ? `, ${p.length}s` : ""})`;
569
+ const templateLine = (t) => `${t.id} ${t.width}x${t.height} ${t.duration}s [${t.fields.map((f) => f.key).join(", ")}]`;
570
+
571
+ return {
572
+ PRESETS, TEMPLATES, CHANNELS, MULTIPLY, EASES, ANCHORS, FIELD_TYPES,
573
+ slug, normalizePreset, findPreset, channelAt, applyPreset, presetFromClip, presetLine,
574
+ normalizeTemplate, findTemplate, resolveTarget, setField, fieldValue, retime, instantiate, templateFromComp, templateLine, asSaved,
575
+ };
576
+ });