greenlight-motion 0.0.0-stage → 0.1.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/CONTRIBUTING.md +17 -0
- package/LICENSE +177 -0
- package/NOTICE +23 -0
- package/README.md +98 -2
- package/THIRD_PARTY_LICENSES.md +1170 -0
- package/cli/apply.mjs +298 -0
- package/cli/glvideo.mjs +56 -0
- package/cli/index.mjs +601 -0
- package/dist/assets/bakeWorker-CvP5R8Rq.js +1 -0
- package/dist/assets/depthWorker-oN8vEaQm.js +1 -0
- package/dist/assets/imageVectorWorker-CGPQgtEd.js +1 -0
- package/dist/bundle-inputs.json +590 -0
- package/dist/greenlight-motion.js +22447 -0
- package/dist/greenlight-motion.mjs +111352 -0
- package/examples/starter/project.json +183 -0
- package/legal/PRO_CONTENT_TERMS.md +41 -0
- package/legal/README.md +24 -0
- package/legal/THIRD_PARTY_ASSETS.md +19 -0
- package/legal/TRADEMARKS.md +24 -0
- package/package.json +59 -4
- package/schema/capabilities.json +1 -0
- package/skill/SKILL.md +235 -0
- package/skill/references/plugins/effect-runtime-contract.md +220 -0
- package/skill/references/plugins/starter-effect-passes.js +99 -0
- package/skill/references/plugins/starter-effect-tsl.js +46 -0
- package/skill/references/plugins/starter-transition.js +89 -0
- package/skill/references/plugins/transition-runtime-contract.md +271 -0
- package/skill/references/video-3d.md +236 -0
- package/skill/references/video-backgrounds.md +147 -0
- package/skill/references/video-camera.md +208 -0
- package/skill/references/video-effects.md +1391 -0
- package/skill/references/video-grid.md +232 -0
- package/skill/references/video-keyframes.md +172 -0
- package/skill/references/video-particles.md +352 -0
- package/skill/references/video-paths.md +150 -0
- package/skill/references/video-projects.md +362 -0
- package/skill/references/video-shapes.md +147 -0
- package/skill/references/video-text-kinetic.md +447 -0
- package/skill/references/video-text-presets.md +1102 -0
- package/skill/references/video-text.md +342 -0
- package/skill/references/video-transitions.md +57 -0
package/cli/apply.mjs
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// greenlight-motion apply — atomic, validated edit batches for a project (PLANS/GREENLIGHT_MOTION_ENGINE_PLAN.md,
|
|
2
|
+
// Part 4 / Stage 4). An agent writes WHAT to change as a list of edits; every edit is checked against the project
|
|
3
|
+
// (the thing it names exists, a value keeps its type, ids stay unique, references still resolve), and either the
|
|
4
|
+
// whole batch applies or nothing does. Errors name the edit, the selector and a fix.
|
|
5
|
+
//
|
|
6
|
+
// { "op": "set", "item": "it1", "path": "textContent", "value": "Launch day" }
|
|
7
|
+
// { "op": "set", "clip": "c2", "path": "url", "value": "assets/photo.png" }
|
|
8
|
+
// { "op": "set", "channel": "ch1", "path": "visible", "value": false }
|
|
9
|
+
// { "op": "set", "project": true, "path": "videoSize.w", "value": 1080 }
|
|
10
|
+
// { "op": "unset", "item": "it1", "path": "propertyKeyframes.opacity" }
|
|
11
|
+
// { "op": "keyframe", "item": "it1", "track": "translateX", "at": 50, "value": 120, "easing": "Power2.easeOut" }
|
|
12
|
+
// { "op": "add", "to": "clips", "value": { "id": "c9", "mediaType": "image", "url": "assets/a.png", … } }
|
|
13
|
+
// { "op": "add", "to": "channels", "value": { "id": "ch9", "items": [ … ] }, "at": 0 }
|
|
14
|
+
// { "op": "add", "to": { "channel": "ch1" }, "value": { "id": "it9", "clipId": "c9", … } } (an item)
|
|
15
|
+
// { "op": "add", "to": { "item": "it1", "list": "effects" }, "value": { "id": "bloom", "eid": "fx-1" } }
|
|
16
|
+
// { "op": "add", "to": "sounds", "value": { "sfx": "standard-swoosh", "on": "it1", "at": "peak" } }
|
|
17
|
+
// { "op": "remove", "item": "it9" } | { "channel": "ch9" } | { "clip": "c9" } | { "sound": 0 }
|
|
18
|
+
// { "op": "remove", "effect": "fx-1", "item": "it1" }
|
|
19
|
+
// { "op": "move", "channel": "ch9", "to": 0 } (stack order: 0 = bottom)
|
|
20
|
+
//
|
|
21
|
+
// Items are found in every channel, nested sequences and grids included. `path` is a dot path; a number segment
|
|
22
|
+
// indexes an array. Pure: no files, no browser — the CLI reads and writes project.json around it.
|
|
23
|
+
|
|
24
|
+
export class EditError extends Error {
|
|
25
|
+
constructor(index, message, hint) {
|
|
26
|
+
super(`edit ${index + 1}: ${message}`);
|
|
27
|
+
this.index = index;
|
|
28
|
+
this.hint = hint || '';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
33
|
+
const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
|
|
34
|
+
|
|
35
|
+
// every channel list in the project: the top level and each sequence / grid clip's own
|
|
36
|
+
function channelLists(project) {
|
|
37
|
+
const lists = [{ owner: null, channels: project.channels || [] }];
|
|
38
|
+
for (const clip of project.clips || []) walkClip(clip, lists);
|
|
39
|
+
return lists;
|
|
40
|
+
}
|
|
41
|
+
function walkClip(clip, lists) {
|
|
42
|
+
const seq = clip?.sequence;
|
|
43
|
+
if (!seq) return;
|
|
44
|
+
lists.push({ owner: clip.id, channels: seq.channels || [] });
|
|
45
|
+
for (const inner of seq.clips || []) walkClip(inner, lists);
|
|
46
|
+
}
|
|
47
|
+
function allClips(project, out = []) {
|
|
48
|
+
for (const clip of project.clips || []) {
|
|
49
|
+
out.push(clip);
|
|
50
|
+
const walk = (c) => { for (const inner of c?.sequence?.clips || []) { out.push(inner); walk(inner); } };
|
|
51
|
+
walk(clip);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function allItems(project) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const { channels } of channelLists(project)) {
|
|
58
|
+
for (const ch of channels) for (const it of ch.items || []) out.push({ item: it, channel: ch });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
const ids = (list) => list.map((x) => x.id).filter(Boolean);
|
|
63
|
+
const near = (want, have) => {
|
|
64
|
+
const pick = have.filter((h) => h.toLowerCase().includes(String(want).toLowerCase().slice(0, 3)));
|
|
65
|
+
return (pick.length ? pick : have).slice(0, 12).join(', ') || 'none';
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function findItem(project, id, i) {
|
|
69
|
+
const hit = allItems(project).find((x) => x.item.id === id);
|
|
70
|
+
if (!hit) throw new EditError(i, `there is no item "${id}"`, `items: ${near(id, allItems(project).map((x) => x.item.id))}`);
|
|
71
|
+
return hit;
|
|
72
|
+
}
|
|
73
|
+
function findChannel(project, id, i) {
|
|
74
|
+
for (const { channels } of channelLists(project)) {
|
|
75
|
+
const ch = channels.find((c) => c.id === id);
|
|
76
|
+
if (ch) return { channel: ch, list: channels };
|
|
77
|
+
}
|
|
78
|
+
throw new EditError(i, `there is no channel "${id}"`, `channels: ${near(id, channelLists(project).flatMap((l) => ids(l.channels)))}`);
|
|
79
|
+
}
|
|
80
|
+
function findClip(project, id, i) {
|
|
81
|
+
const clip = allClips(project).find((c) => c.id === id);
|
|
82
|
+
if (!clip) throw new EditError(i, `there is no clip "${id}"`, `clips: ${near(id, ids(allClips(project)))}`);
|
|
83
|
+
return clip;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// the object an edit addresses
|
|
87
|
+
function holder(project, e, i) {
|
|
88
|
+
if (e.item != null) return findItem(project, e.item, i).item;
|
|
89
|
+
if (e.clip != null) return findClip(project, e.clip, i);
|
|
90
|
+
if (e.channel != null) return findChannel(project, e.channel, i).channel;
|
|
91
|
+
if (e.sound != null) {
|
|
92
|
+
const s = (project.sounds || [])[Number(e.sound)];
|
|
93
|
+
if (!s) throw new EditError(i, `there is no sound ${e.sound}`, `the project has ${(project.sounds || []).length} sound cue(s), numbered from 0`);
|
|
94
|
+
return s;
|
|
95
|
+
}
|
|
96
|
+
if (e.project) return project;
|
|
97
|
+
throw new EditError(i, 'the edit names nothing to change', 'add one of: "item", "clip", "channel", "sound" or "project": true');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const splitPath = (p, i) => {
|
|
101
|
+
if (typeof p !== 'string' || !p.trim()) throw new EditError(i, 'missing "path"', 'a dot path inside the target, e.g. "textContent" or "propertyKeyframes.opacity"');
|
|
102
|
+
return p.split('.').map((s) => (/^\d+$/.test(s) ? Number(s) : s));
|
|
103
|
+
};
|
|
104
|
+
const FORBIDDEN = new Set(['__proto__', 'prototype', 'constructor']);
|
|
105
|
+
|
|
106
|
+
function setPath(obj, segs, value, i, label) {
|
|
107
|
+
let cur = obj;
|
|
108
|
+
for (let k = 0; k < segs.length - 1; k += 1) {
|
|
109
|
+
const s = segs[k];
|
|
110
|
+
if (FORBIDDEN.has(s)) throw new EditError(i, `"${s}" is not a valid path segment`);
|
|
111
|
+
if (cur[s] == null) {
|
|
112
|
+
if (typeof s === 'number') throw new EditError(i, `${label}: index ${s} does not exist`, `the list has ${Array.isArray(cur) ? cur.length : 0} entries`);
|
|
113
|
+
cur[s] = typeof segs[k + 1] === 'number' ? [] : {};
|
|
114
|
+
} else if (typeof cur[s] !== 'object') {
|
|
115
|
+
throw new EditError(i, `${label}: "${segs.slice(0, k + 1).join('.')}" is a ${kind(cur[s])}, not an object`, 'set the whole value one level up instead');
|
|
116
|
+
}
|
|
117
|
+
cur = cur[s];
|
|
118
|
+
}
|
|
119
|
+
const last = segs[segs.length - 1];
|
|
120
|
+
if (FORBIDDEN.has(last)) throw new EditError(i, `"${last}" is not a valid path segment`);
|
|
121
|
+
const old = cur[last];
|
|
122
|
+
// a value keeps its kind: a number stays a number, a string a string (null/undefined may become anything)
|
|
123
|
+
if (old != null && value != null && kind(old) !== kind(value)) {
|
|
124
|
+
throw new EditError(i, `${label}: "${segs.join('.')}" holds a ${kind(old)}, the edit gives a ${kind(value)}`,
|
|
125
|
+
kind(old) === 'number' ? 'give a number (no quotes, no unit)' : `give a ${kind(old)}`);
|
|
126
|
+
}
|
|
127
|
+
cur[last] = value;
|
|
128
|
+
}
|
|
129
|
+
function unsetPath(obj, segs, i, label) {
|
|
130
|
+
let cur = obj;
|
|
131
|
+
for (let k = 0; k < segs.length - 1; k += 1) {
|
|
132
|
+
cur = cur?.[segs[k]];
|
|
133
|
+
if (cur == null) return; // nothing there: already unset
|
|
134
|
+
}
|
|
135
|
+
if (Array.isArray(cur) && typeof segs[segs.length - 1] === 'number') cur.splice(segs[segs.length - 1], 1);
|
|
136
|
+
else if (isObj(cur)) delete cur[segs[segs.length - 1]];
|
|
137
|
+
else throw new EditError(i, `${label}: cannot unset inside a ${kind(cur)}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function addEdit(project, e, i) {
|
|
141
|
+
const v = e.value;
|
|
142
|
+
if (!isObj(v)) throw new EditError(i, '"value" must be an object', 'the new clip / channel / item / effect / sound');
|
|
143
|
+
const at = (list) => (Number.isInteger(e.at) ? Math.max(0, Math.min(list.length, e.at)) : list.length);
|
|
144
|
+
if (e.to === 'clips') {
|
|
145
|
+
if (!v.id) throw new EditError(i, 'a new clip needs an "id"');
|
|
146
|
+
if (allClips(project).some((c) => c.id === v.id)) throw new EditError(i, `a clip "${v.id}" already exists`, 'pick a new id');
|
|
147
|
+
project.clips = project.clips || [];
|
|
148
|
+
project.clips.splice(at(project.clips), 0, v);
|
|
149
|
+
} else if (e.to === 'channels') {
|
|
150
|
+
if (!v.id) throw new EditError(i, 'a new channel needs an "id"');
|
|
151
|
+
if (channelLists(project).some((l) => l.channels.some((c) => c.id === v.id))) throw new EditError(i, `a channel "${v.id}" already exists`, 'pick a new id');
|
|
152
|
+
if (v.items != null && !Array.isArray(v.items)) throw new EditError(i, 'a channel\'s "items" is a list');
|
|
153
|
+
project.channels = project.channels || [];
|
|
154
|
+
project.channels.splice(at(project.channels), 0, { name: v.id, visible: true, items: [], ...v });
|
|
155
|
+
} else if (e.to === 'sounds') {
|
|
156
|
+
project.sounds = project.sounds || [];
|
|
157
|
+
project.sounds.splice(at(project.sounds), 0, v);
|
|
158
|
+
} else if (isObj(e.to) && e.to.channel != null) {
|
|
159
|
+
if (!v.id) throw new EditError(i, 'a new item needs an "id"');
|
|
160
|
+
if (allItems(project).some((x) => x.item.id === v.id)) throw new EditError(i, `an item "${v.id}" already exists`, 'pick a new id');
|
|
161
|
+
const { channel } = findChannel(project, e.to.channel, i);
|
|
162
|
+
channel.items = channel.items || [];
|
|
163
|
+
channel.items.splice(at(channel.items), 0, v);
|
|
164
|
+
} else if (isObj(e.to) && e.to.item != null) {
|
|
165
|
+
const list = e.to.list || 'effects';
|
|
166
|
+
if (!['effects', 'layerEffects'].includes(list)) throw new EditError(i, `items keep effects in "effects" or "layerEffects", not "${list}"`);
|
|
167
|
+
if (!v.id) throw new EditError(i, 'an effect needs its type "id"', 'see: npx greenlight-motion schema effects');
|
|
168
|
+
const { item } = findItem(project, e.to.item, i);
|
|
169
|
+
item[list] = item[list] || [];
|
|
170
|
+
if (v.eid && [...(item.effects || []), ...(item.layerEffects || [])].some((x) => x?.eid === v.eid)) {
|
|
171
|
+
throw new EditError(i, `item "${item.id}" already has an effect "${v.eid}"`, 'effect "eid"s are unique per item');
|
|
172
|
+
}
|
|
173
|
+
item[list].splice(at(item[list]), 0, v);
|
|
174
|
+
} else {
|
|
175
|
+
throw new EditError(i, 'unknown "to"', '"clips", "channels", "sounds", { "channel": id } or { "item": id, "list": "effects" | "layerEffects" }');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function removeEdit(project, e, i) {
|
|
180
|
+
if (e.effect != null) {
|
|
181
|
+
const { item } = findItem(project, e.item, i);
|
|
182
|
+
for (const list of ['effects', 'layerEffects']) {
|
|
183
|
+
const k = (item[list] || []).findIndex((x) => x?.eid === e.effect);
|
|
184
|
+
if (k >= 0) { item[list].splice(k, 1); return; }
|
|
185
|
+
}
|
|
186
|
+
throw new EditError(i, `item "${item.id}" has no effect "${e.effect}"`,
|
|
187
|
+
`its effects: ${[...(item.effects || []), ...(item.layerEffects || [])].map((x) => x?.eid).filter(Boolean).join(', ') || 'none'}`);
|
|
188
|
+
}
|
|
189
|
+
if (e.item != null) {
|
|
190
|
+
const { channel } = findItem(project, e.item, i);
|
|
191
|
+
channel.items = channel.items.filter((x) => x.id !== e.item);
|
|
192
|
+
} else if (e.channel != null) {
|
|
193
|
+
const { list, channel } = findChannel(project, e.channel, i);
|
|
194
|
+
list.splice(list.indexOf(channel), 1);
|
|
195
|
+
} else if (e.clip != null) {
|
|
196
|
+
const clip = findClip(project, e.clip, i);
|
|
197
|
+
const users = allItems(project).filter((x) => x.item.clipId === clip.id).map((x) => x.item.id);
|
|
198
|
+
if (users.length) throw new EditError(i, `clip "${clip.id}" is still used by ${users.join(', ')}`, 'remove or re-point those items first, in the same batch');
|
|
199
|
+
const drop = (list) => {
|
|
200
|
+
const k = list.indexOf(clip);
|
|
201
|
+
if (k >= 0) { list.splice(k, 1); return true; }
|
|
202
|
+
return list.some((c) => c?.sequence?.clips && drop(c.sequence.clips));
|
|
203
|
+
};
|
|
204
|
+
drop(project.clips || []);
|
|
205
|
+
} else if (e.sound != null) {
|
|
206
|
+
holder(project, e, i);
|
|
207
|
+
project.sounds.splice(Number(e.sound), 1);
|
|
208
|
+
} else {
|
|
209
|
+
throw new EditError(i, 'the edit names nothing to remove', '"item", "channel", "clip", "sound" or "effect" (+ "item")');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function keyframeEdit(project, e, i) {
|
|
214
|
+
const { item } = findItem(project, e.item, i);
|
|
215
|
+
if (typeof e.track !== 'string' || !e.track) throw new EditError(i, 'missing "track"', 'e.g. "translateX", "opacity", "rotate", "scale", "fx:<eid>:<param>"');
|
|
216
|
+
const pos = Number(e.at);
|
|
217
|
+
if (!Number.isFinite(pos) || pos < 0 || pos > 100) throw new EditError(i, '"at" is the position in the layer, 0–100 (%)', 'convert a time t (s) as 100 × (t − layer start) / layer length');
|
|
218
|
+
if (e.value == null) throw new EditError(i, 'missing "value"');
|
|
219
|
+
item.propertyKeyframes = item.propertyKeyframes || {};
|
|
220
|
+
const track = item.propertyKeyframes[e.track] = item.propertyKeyframes[e.track] || [];
|
|
221
|
+
const key = { position: pos, value: String(e.value), easing: e.easing || 'Linear' };
|
|
222
|
+
const k = track.findIndex((x) => Math.abs(Number(x.position) - pos) < 1e-6);
|
|
223
|
+
if (k >= 0) track[k] = { ...track[k], ...key };
|
|
224
|
+
else track.push(key);
|
|
225
|
+
track.sort((a, b) => Number(a.position) - Number(b.position));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function moveEdit(project, e, i) {
|
|
229
|
+
const { list, channel } = findChannel(project, e.channel, i);
|
|
230
|
+
const to = Number(e.to);
|
|
231
|
+
if (!Number.isInteger(to)) throw new EditError(i, '"to" is the new stack position (0 = bottom)');
|
|
232
|
+
list.splice(list.indexOf(channel), 1);
|
|
233
|
+
list.splice(Math.max(0, Math.min(list.length, to)), 0, channel);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// what must still hold after the whole batch
|
|
237
|
+
function problems(project) {
|
|
238
|
+
const out = [];
|
|
239
|
+
const clipIds = new Set(ids(allClips(project)));
|
|
240
|
+
const items = allItems(project);
|
|
241
|
+
const seen = new Map();
|
|
242
|
+
for (const { item, channel } of items) {
|
|
243
|
+
if (!item.id) out.push({ message: `an item in channel "${channel.id}" has no id`, hint: 'give every item an "id"' });
|
|
244
|
+
else if (seen.has(item.id)) out.push({ message: `two items are called "${item.id}"`, hint: 'item ids must be unique' });
|
|
245
|
+
seen.set(item.id, true);
|
|
246
|
+
if (item.clipId != null && !clipIds.has(item.clipId)) {
|
|
247
|
+
out.push({ message: `item "${item.id}" plays clip "${item.clipId}", which does not exist`, hint: `clips: ${near(item.clipId, [...clipIds])}` });
|
|
248
|
+
}
|
|
249
|
+
const parent = item.keyframeParenting?.parentItemId;
|
|
250
|
+
if (parent && !items.some((x) => x.item.id === parent)) {
|
|
251
|
+
out.push({ message: `item "${item.id}" follows parent "${parent}", which does not exist`, hint: 'unset keyframeParenting or add the parent' });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const chanIds = channelLists(project).flatMap((l) => ids(l.channels));
|
|
255
|
+
const dup = chanIds.find((c, k) => chanIds.indexOf(c) !== k);
|
|
256
|
+
if (dup) out.push({ message: `two channels are called "${dup}"`, hint: 'channel ids must be unique' });
|
|
257
|
+
(project.sounds || []).forEach((s, k) => {
|
|
258
|
+
if (s?.on && !seen.has(s.on)) out.push({ message: `sound ${k} is anchored on item "${s.on}", which does not exist`, hint: 'point "on" at an item id or use "time"' });
|
|
259
|
+
});
|
|
260
|
+
for (const f of project.template?.fields || []) {
|
|
261
|
+
for (const t of f.targets || []) {
|
|
262
|
+
if (t.itemId && !seen.has(t.itemId)) out.push({ message: `template field "${f.key}" targets item "${t.itemId}", which does not exist`, hint: 'fix the target or remove the field' });
|
|
263
|
+
if (t.clipId && !clipIds.has(t.clipId)) out.push({ message: `template field "${f.key}" targets clip "${t.clipId}", which does not exist`, hint: 'fix the target or remove the field' });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Apply `edits` to a COPY of `project`. Returns { project, summary } or throws EditError (the first bad edit, or the
|
|
271
|
+
* references the batch would break — then `.problems` lists them all). The input project is never changed.
|
|
272
|
+
*/
|
|
273
|
+
export function applyEdits(project, edits) {
|
|
274
|
+
if (!Array.isArray(edits)) throw new EditError(-1, 'the edits must be a JSON list', '[{ "op": "set", … }, …]');
|
|
275
|
+
const next = structuredClone(project);
|
|
276
|
+
const summary = [];
|
|
277
|
+
edits.forEach((e, i) => {
|
|
278
|
+
if (!isObj(e)) throw new EditError(i, 'an edit is an object');
|
|
279
|
+
const label = e.item != null ? `item "${e.item}"` : e.clip != null ? `clip "${e.clip}"` : e.channel != null ? `channel "${e.channel}"` : e.sound != null ? `sound ${e.sound}` : 'project';
|
|
280
|
+
switch (e.op) {
|
|
281
|
+
case 'set': setPath(holder(next, e, i), splitPath(e.path, i), e.value, i, label); break;
|
|
282
|
+
case 'unset': unsetPath(holder(next, e, i), splitPath(e.path, i), i, label); break;
|
|
283
|
+
case 'add': addEdit(next, e, i); break;
|
|
284
|
+
case 'remove': removeEdit(next, e, i); break;
|
|
285
|
+
case 'keyframe': keyframeEdit(next, e, i); break;
|
|
286
|
+
case 'move': moveEdit(next, e, i); break;
|
|
287
|
+
default: throw new EditError(i, `unknown op "${e.op}"`, 'set | unset | add | remove | keyframe | move');
|
|
288
|
+
}
|
|
289
|
+
summary.push(`${e.op} ${e.path ? `${label}.${e.path}` : e.to ? `→ ${typeof e.to === 'string' ? e.to : JSON.stringify(e.to)}` : label}`);
|
|
290
|
+
});
|
|
291
|
+
const left = problems(next);
|
|
292
|
+
if (left.length) {
|
|
293
|
+
const err = new EditError(edits.length - 1, `the batch would leave ${left.length} broken reference(s): ${left[0].message}`, left[0].hint);
|
|
294
|
+
err.problems = left;
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
return { project: next, summary };
|
|
298
|
+
}
|
package/cli/glvideo.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// .glvideo — a project folder in one zip: glvideo.json (what the file is), project.json, assets/<each file>. GreenLight
|
|
2
|
+
// Dash opens it on a double-click (a new video project with its files). The engine page's "Save for GreenLight Dash"
|
|
3
|
+
// writes the same layout (frontend/src/motionEngine/pageEdits.js glvideoFile). Entries are stored, not compressed:
|
|
4
|
+
// the files are pictures and sounds, and the project is small.
|
|
5
|
+
|
|
6
|
+
let crcTable = null;
|
|
7
|
+
function crc32(buf) {
|
|
8
|
+
if (!crcTable) {
|
|
9
|
+
crcTable = new Uint32Array(256);
|
|
10
|
+
for (let n = 0; n < 256; n += 1) {
|
|
11
|
+
let c = n;
|
|
12
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
13
|
+
crcTable[n] = c >>> 0;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
let c = 0xffffffff;
|
|
17
|
+
for (let i = 0; i < buf.length; i += 1) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
18
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** files: [{ name, data: Buffer | string }] → the zip's bytes (a Buffer). */
|
|
22
|
+
export function zipStore(files) {
|
|
23
|
+
const parts = [];
|
|
24
|
+
const central = [];
|
|
25
|
+
let offset = 0;
|
|
26
|
+
for (const f of files) {
|
|
27
|
+
const name = Buffer.from(f.name, 'utf8');
|
|
28
|
+
const data = Buffer.isBuffer(f.data) ? f.data : Buffer.from(String(f.data), 'utf8');
|
|
29
|
+
const crc = crc32(data);
|
|
30
|
+
const local = Buffer.alloc(30);
|
|
31
|
+
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); // UTF-8 names
|
|
32
|
+
local.writeUInt32LE(crc, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22);
|
|
33
|
+
local.writeUInt16LE(name.length, 26);
|
|
34
|
+
parts.push(local, name, data);
|
|
35
|
+
const cd = Buffer.alloc(46);
|
|
36
|
+
cd.writeUInt32LE(0x02014b50, 0); cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6); cd.writeUInt16LE(0x0800, 8);
|
|
37
|
+
cd.writeUInt32LE(crc, 16); cd.writeUInt32LE(data.length, 20); cd.writeUInt32LE(data.length, 24);
|
|
38
|
+
cd.writeUInt16LE(name.length, 28); cd.writeUInt32LE(offset, 42);
|
|
39
|
+
central.push(cd, name);
|
|
40
|
+
offset += 30 + name.length + data.length;
|
|
41
|
+
}
|
|
42
|
+
const cdSize = central.reduce((a, p) => a + p.length, 0);
|
|
43
|
+
const end = Buffer.alloc(22);
|
|
44
|
+
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10);
|
|
45
|
+
end.writeUInt32LE(cdSize, 12); end.writeUInt32LE(offset, 16);
|
|
46
|
+
return Buffer.concat([...parts, ...central, end]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A .glvideo of PROJECT with the files REL → bytes (paths under assets/, as in a project folder). */
|
|
50
|
+
export function glvideoBytes({ title, project, files }) {
|
|
51
|
+
return zipStore([
|
|
52
|
+
{ name: 'glvideo.json', data: JSON.stringify({ format: 'glvideo', version: 1, title: title || project?.name || '' }) },
|
|
53
|
+
{ name: 'project.json', data: JSON.stringify(project) },
|
|
54
|
+
...files.map(({ rel, data }) => ({ name: `assets/${rel}`, data })),
|
|
55
|
+
]);
|
|
56
|
+
}
|