frameyard 0.2.0 → 0.4.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/CHANGELOG.md +102 -0
- package/GLOSSARY.md +58 -2
- package/README.md +185 -95
- package/dist/bindings.d.ts +42 -0
- package/dist/bindings.d.ts.map +1 -0
- package/dist/bindings.js +144 -0
- package/dist/bindings.js.map +1 -0
- package/dist/cli.js +37 -14
- package/dist/cli.js.map +1 -1
- package/dist/contracts.d.ts +73 -47
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +14 -159
- package/dist/contracts.js.map +1 -1
- package/dist/errors.d.ts +15 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +8 -0
- package/dist/errors.js.map +1 -1
- package/dist/html/index.d.ts +5 -0
- package/dist/html/index.d.ts.map +1 -0
- package/dist/html/index.js +3 -0
- package/dist/html/index.js.map +1 -0
- package/dist/html/parse.d.ts +30 -0
- package/dist/html/parse.d.ts.map +1 -0
- package/dist/html/parse.js +80 -0
- package/dist/html/parse.js.map +1 -0
- package/dist/html/validate.d.ts +14 -0
- package/dist/html/validate.d.ts.map +1 -0
- package/dist/html/validate.js +931 -0
- package/dist/html/validate.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/preview/main.d.ts +12 -3
- package/dist/preview/main.d.ts.map +1 -1
- package/dist/preview/main.js +208 -85
- package/dist/preview/main.js.map +1 -1
- package/dist/project.d.ts +2 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +4 -0
- package/dist/project.js.map +1 -1
- package/dist/renderer/audio.d.ts +5 -1
- package/dist/renderer/audio.d.ts.map +1 -1
- package/dist/renderer/audio.js +4 -0
- package/dist/renderer/audio.js.map +1 -1
- package/dist/renderer/browser.d.ts +12 -1
- package/dist/renderer/browser.d.ts.map +1 -1
- package/dist/renderer/browser.js +69 -11
- package/dist/renderer/browser.js.map +1 -1
- package/dist/renderer/index.d.ts +30 -5
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +204 -26
- package/dist/renderer/index.js.map +1 -1
- package/dist/runtime/bindings.d.ts +11 -0
- package/dist/runtime/bindings.d.ts.map +1 -0
- package/dist/runtime/bindings.js +164 -0
- package/dist/runtime/bindings.js.map +1 -0
- package/dist/runtime/diagnostics.d.ts +4 -0
- package/dist/runtime/diagnostics.d.ts.map +1 -0
- package/dist/runtime/diagnostics.js +107 -0
- package/dist/runtime/diagnostics.js.map +1 -0
- package/dist/runtime/host.d.ts +12 -0
- package/dist/runtime/host.d.ts.map +1 -0
- package/dist/runtime/host.js +701 -0
- package/dist/runtime/host.js.map +1 -0
- package/dist/runtime/index.d.ts +0 -26
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +0 -286
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/media.d.ts +16 -0
- package/dist/runtime/media.d.ts.map +1 -0
- package/dist/runtime/media.js +307 -0
- package/dist/runtime/media.js.map +1 -0
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +169 -78
- package/dist/scaffold.js.map +1 -1
- package/dist/server/composition-html.d.ts +20 -0
- package/dist/server/composition-html.d.ts.map +1 -0
- package/dist/server/composition-html.js +45 -0
- package/dist/server/composition-html.js.map +1 -0
- package/dist/server/preview-html.d.ts.map +1 -1
- package/dist/server/preview-html.js +19 -14
- package/dist/server/preview-html.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +94 -6
- package/dist/server/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/adr/0001-keep-composition-authoring-web-native.md +2 -0
- package/docs/adr/0003-render-browser-frames-offline.md +2 -0
- package/docs/adr/0005-keep-parallel-rendering-opt-in-and-frame-ordered.md +3 -0
- package/docs/adr/0006-seek-embedded-media-from-the-frame-clock.md +11 -0
- package/docs/adr/0007-make-html-documents-the-composition-unit.md +31 -0
- package/docs/migration-0.4.md +279 -0
- package/docs/v0.1-design.md +82 -57
- package/examples/demo/README.md +7 -3
- package/examples/demo/compositions/demo.controller.js +120 -0
- package/examples/demo/compositions/demo.html +253 -0
- package/examples/demo/video.config.js +1 -1
- package/package.json +9 -3
- package/skills/frameyard/README.md +4 -0
- package/skills/frameyard/SKILL.md +41 -11
- package/skills/frameyard/references/audio-assets.md +25 -14
- package/skills/frameyard/references/bindings.md +73 -0
- package/skills/frameyard/references/common-mistakes.md +59 -0
- package/skills/frameyard/references/controllers.md +126 -0
- package/skills/frameyard/references/deterministic-authoring.md +160 -41
- package/skills/frameyard/references/diagnostics.md +90 -0
- package/skills/frameyard/references/failure-recovery.md +38 -12
- package/skills/frameyard/references/json-results.md +43 -8
- package/skills/frameyard/references/subtitles-karaoke.md +10 -18
- package/examples/demo/compositions/demo.js +0 -206
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { isPlainObject, mergeProps, normalizeAudioClip, normalizeTranscriptClip, } from "../contracts.js";
|
|
2
|
+
import { errorMessage, FrameyardError, isFrameyardError } from "../errors.js";
|
|
3
|
+
import { applyBindings, applyPropVariables } from "./bindings.js";
|
|
4
|
+
import { frameToMilliseconds } from "./clock.js";
|
|
5
|
+
import { withDiagnosticsAsync, withDiagnosticsSync } from "./diagnostics.js";
|
|
6
|
+
import { describeMediaAudio, prepareMedia, releaseMedia, settleMedia } from "./media.js";
|
|
7
|
+
const FRAME_STATE_PROPERTIES = [
|
|
8
|
+
"--fy-frame",
|
|
9
|
+
"--fy-time",
|
|
10
|
+
"--fy-progress",
|
|
11
|
+
"--fy-fps",
|
|
12
|
+
"--fy-duration",
|
|
13
|
+
"--fy-duration-frames",
|
|
14
|
+
];
|
|
15
|
+
class HostRuntime {
|
|
16
|
+
metadata;
|
|
17
|
+
defaultProps;
|
|
18
|
+
controller;
|
|
19
|
+
root;
|
|
20
|
+
snapshot;
|
|
21
|
+
controllerModule;
|
|
22
|
+
manual;
|
|
23
|
+
readyPromise;
|
|
24
|
+
resolveReady;
|
|
25
|
+
readyResolved = false;
|
|
26
|
+
diagnosticsList = [];
|
|
27
|
+
diagnosticKeys = new Set();
|
|
28
|
+
props;
|
|
29
|
+
currentFrame = 0;
|
|
30
|
+
loaded = false;
|
|
31
|
+
mounted = false;
|
|
32
|
+
disposed = false;
|
|
33
|
+
audio = Object.freeze([]);
|
|
34
|
+
embedded = Object.freeze([]);
|
|
35
|
+
transcripts = Object.freeze([]);
|
|
36
|
+
loadQueue = Promise.resolve();
|
|
37
|
+
settleLoop = null;
|
|
38
|
+
settleWaiters = [];
|
|
39
|
+
diagnosticSink = (message) => {
|
|
40
|
+
this.addDiagnostic({ severity: "warning", code: "DETERMINISM_WARNING", message });
|
|
41
|
+
};
|
|
42
|
+
constructor(options) {
|
|
43
|
+
this.root = options.root;
|
|
44
|
+
this.metadata = options.metadata;
|
|
45
|
+
this.defaultProps = options.defaultProps;
|
|
46
|
+
this.controllerModule = options.controllerModule;
|
|
47
|
+
this.manual = options.manual;
|
|
48
|
+
this.props = options.defaultProps;
|
|
49
|
+
this.controller = options.declaration
|
|
50
|
+
? options.declaration.kind === "inline"
|
|
51
|
+
? { kind: "inline" }
|
|
52
|
+
: { kind: "external", src: options.declaration.src ?? "" }
|
|
53
|
+
: null;
|
|
54
|
+
this.snapshot = options.root.cloneNode(true);
|
|
55
|
+
this.readyPromise = new Promise((resolve) => {
|
|
56
|
+
this.resolveReady = resolve;
|
|
57
|
+
});
|
|
58
|
+
this.load = this.load.bind(this);
|
|
59
|
+
this.seek = this.seek.bind(this);
|
|
60
|
+
this.settle = this.settle.bind(this);
|
|
61
|
+
this.dispose = this.dispose.bind(this);
|
|
62
|
+
this.hideRoot();
|
|
63
|
+
this.pauseAllAnimations();
|
|
64
|
+
}
|
|
65
|
+
get ready() {
|
|
66
|
+
return this.readyPromise;
|
|
67
|
+
}
|
|
68
|
+
get audioClips() {
|
|
69
|
+
return this.audio;
|
|
70
|
+
}
|
|
71
|
+
get embeddedAudioClips() {
|
|
72
|
+
return this.embedded;
|
|
73
|
+
}
|
|
74
|
+
get transcriptClips() {
|
|
75
|
+
return this.transcripts;
|
|
76
|
+
}
|
|
77
|
+
get diagnostics() {
|
|
78
|
+
return Object.freeze([...this.diagnosticsList]);
|
|
79
|
+
}
|
|
80
|
+
get frame() {
|
|
81
|
+
return this.currentFrame;
|
|
82
|
+
}
|
|
83
|
+
markReady() {
|
|
84
|
+
if (this.readyResolved) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.readyResolved = true;
|
|
88
|
+
this.resolveReady();
|
|
89
|
+
document.dispatchEvent(new CustomEvent("fy-ready", { detail: { manual: this.manual } }));
|
|
90
|
+
}
|
|
91
|
+
initialLoad() {
|
|
92
|
+
return this.loadNow({});
|
|
93
|
+
}
|
|
94
|
+
load(options = {}) {
|
|
95
|
+
const task = this.readyPromise.then(() => {
|
|
96
|
+
const run = this.loadQueue.then(() => this.loadNow(options), () => this.loadNow(options));
|
|
97
|
+
this.loadQueue = run.then(() => undefined, () => undefined);
|
|
98
|
+
return run;
|
|
99
|
+
});
|
|
100
|
+
return task.catch((error) => {
|
|
101
|
+
throw toHostError(error);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
seek(frame) {
|
|
105
|
+
try {
|
|
106
|
+
if (this.disposed) {
|
|
107
|
+
throw new FrameyardError("COMPOSITION_DISPOSED", "Cannot seek a disposed composition host.");
|
|
108
|
+
}
|
|
109
|
+
if (!this.loaded) {
|
|
110
|
+
throw new FrameyardError("COMPOSITION_NOT_READY", "Cannot seek before the composition has loaded.");
|
|
111
|
+
}
|
|
112
|
+
this.seekNow(frame);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
this.addError(error, Number.isInteger(frame) ? frame : null);
|
|
116
|
+
throw toHostError(error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
settle() {
|
|
120
|
+
if (this.disposed) {
|
|
121
|
+
return Promise.reject(toHostError(new FrameyardError("COMPOSITION_DISPOSED", "Cannot settle a disposed composition host.")));
|
|
122
|
+
}
|
|
123
|
+
if (!this.loaded) {
|
|
124
|
+
return Promise.reject(toHostError(new FrameyardError("COMPOSITION_NOT_READY", "Cannot settle before the composition has loaded.")));
|
|
125
|
+
}
|
|
126
|
+
const promise = new Promise((resolve, reject) => {
|
|
127
|
+
this.settleWaiters.push({ resolve, reject, frame: this.currentFrame });
|
|
128
|
+
this.settleLoop ??= this.runSettleLoop();
|
|
129
|
+
});
|
|
130
|
+
return promise.catch((error) => {
|
|
131
|
+
this.addError(error, this.currentFrame);
|
|
132
|
+
throw toHostError(error);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async dispose() {
|
|
136
|
+
if (this.disposed) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.disposed = true;
|
|
140
|
+
const error = new FrameyardError("COMPOSITION_DISPOSED", "Composition host was disposed.");
|
|
141
|
+
for (const waiter of this.settleWaiters) {
|
|
142
|
+
waiter.reject(error);
|
|
143
|
+
}
|
|
144
|
+
this.settleWaiters = [];
|
|
145
|
+
await this.disposeCurrent();
|
|
146
|
+
this.loaded = false;
|
|
147
|
+
this.currentFrame = 0;
|
|
148
|
+
this.audio = Object.freeze([]);
|
|
149
|
+
this.embedded = Object.freeze([]);
|
|
150
|
+
this.transcripts = Object.freeze([]);
|
|
151
|
+
this.root.replaceChildren();
|
|
152
|
+
this.hideRoot();
|
|
153
|
+
this.clearFrameState();
|
|
154
|
+
}
|
|
155
|
+
async loadNow(options) {
|
|
156
|
+
if (this.disposed) {
|
|
157
|
+
throw new FrameyardError("COMPOSITION_DISPOSED", "Cannot load a disposed composition host.");
|
|
158
|
+
}
|
|
159
|
+
this.clearDiagnostics();
|
|
160
|
+
let props;
|
|
161
|
+
try {
|
|
162
|
+
props = resolveHostProps(this.defaultProps, options.props, this.controllerModule?.validateProps);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
this.addError(error, null);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
await this.disposeCurrent();
|
|
170
|
+
this.assertActive();
|
|
171
|
+
this.restoreSnapshot();
|
|
172
|
+
this.hideRoot();
|
|
173
|
+
this.props = props;
|
|
174
|
+
applyPropVariables(this.root, props);
|
|
175
|
+
setFrameState(this.metadata, 0);
|
|
176
|
+
applyBindings({
|
|
177
|
+
root: this.root,
|
|
178
|
+
props,
|
|
179
|
+
onDiagnostic: (diagnostic) => this.addDiagnostic(diagnostic),
|
|
180
|
+
});
|
|
181
|
+
applyAnimationSchedule(this.root, this.metadata.fps);
|
|
182
|
+
if (this.controllerModule?.mount) {
|
|
183
|
+
await withDiagnosticsAsync("Composition mount()", this.diagnosticSink, () => this.controllerModule?.mount?.({ root: this.root, props }));
|
|
184
|
+
}
|
|
185
|
+
this.assertActive();
|
|
186
|
+
this.mounted = true;
|
|
187
|
+
await waitForAssets(this.root);
|
|
188
|
+
this.assertActive();
|
|
189
|
+
await prepareMedia(this.root);
|
|
190
|
+
this.assertActive();
|
|
191
|
+
this.audio = collectAudioClips(this.root, (diagnostic) => this.addDiagnostic(diagnostic));
|
|
192
|
+
this.embedded = describeMediaAudio(this.root);
|
|
193
|
+
this.transcripts = collectTranscriptClips(this.root, (diagnostic) => this.addDiagnostic(diagnostic));
|
|
194
|
+
checkAnimationDiagnostics(this.root, (diagnostic) => this.addDiagnostic(diagnostic));
|
|
195
|
+
this.seekNow(0);
|
|
196
|
+
await settleMedia(this.root, 0, this.metadata.fps);
|
|
197
|
+
this.assertActive();
|
|
198
|
+
this.loaded = true;
|
|
199
|
+
this.showRoot();
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
this.loaded = false;
|
|
203
|
+
this.hideRoot();
|
|
204
|
+
this.addError(error, null);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
assertActive() {
|
|
209
|
+
if (this.disposed) {
|
|
210
|
+
throw new FrameyardError("COMPOSITION_DISPOSED", "Composition host was disposed during load.");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async disposeCurrent() {
|
|
214
|
+
if (this.mounted && this.controllerModule?.dispose) {
|
|
215
|
+
try {
|
|
216
|
+
await withDiagnosticsAsync("Composition dispose()", this.diagnosticSink, () => this.controllerModule?.dispose?.());
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
this.addError(error, null);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
this.mounted = false;
|
|
223
|
+
releaseMedia(this.root);
|
|
224
|
+
this.loaded = false;
|
|
225
|
+
}
|
|
226
|
+
restoreSnapshot() {
|
|
227
|
+
releaseMedia(this.root);
|
|
228
|
+
this.root.replaceChildren(...Array.from(this.snapshot.childNodes, (child) => child.cloneNode(true)));
|
|
229
|
+
for (const attribute of Array.from(this.root.attributes)) {
|
|
230
|
+
this.root.removeAttribute(attribute.name);
|
|
231
|
+
}
|
|
232
|
+
for (const attribute of Array.from(this.snapshot.attributes)) {
|
|
233
|
+
this.root.setAttribute(attribute.name, attribute.value);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
seekNow(frame) {
|
|
237
|
+
if (!Number.isInteger(frame) || frame < 0 || frame >= this.metadata.durationInFrames) {
|
|
238
|
+
throw new FrameyardError("FRAME_OUT_OF_RANGE", `Frame ${frame} is outside the valid range 0..${this.metadata.durationInFrames - 1}.`);
|
|
239
|
+
}
|
|
240
|
+
setFrameState(this.metadata, frame);
|
|
241
|
+
if (this.controllerModule?.update) {
|
|
242
|
+
const result = withDiagnosticsSync("Composition update()", frame, this.diagnosticSink, () => this.controllerModule?.update?.({ frame, props: this.props, root: this.root }));
|
|
243
|
+
if (isThenable(result)) {
|
|
244
|
+
throw new FrameyardError("COMPOSITION_UPDATE_ASYNC", `Composition update() must be synchronous, but it returned a promise at frame ${frame}.`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
seekAnimations(this.root, frame, this.metadata.fps, () => {
|
|
248
|
+
this.addDiagnostic({
|
|
249
|
+
severity: "warning",
|
|
250
|
+
code: "DETERMINISM_WARNING",
|
|
251
|
+
message: "Composition uses CSS transitions; derive visual styles from frame and props instead.",
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
this.currentFrame = frame;
|
|
255
|
+
}
|
|
256
|
+
async runSettleLoop() {
|
|
257
|
+
try {
|
|
258
|
+
while (this.settleWaiters.length > 0) {
|
|
259
|
+
const waiters = this.settleWaiters;
|
|
260
|
+
this.settleWaiters = [];
|
|
261
|
+
const frame = waiters[waiters.length - 1]?.frame ?? this.currentFrame;
|
|
262
|
+
try {
|
|
263
|
+
await settleMedia(this.root, frame, this.metadata.fps);
|
|
264
|
+
for (const waiter of waiters) {
|
|
265
|
+
waiter.resolve();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
for (const waiter of waiters) {
|
|
270
|
+
waiter.reject(error);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
this.settleLoop = null;
|
|
277
|
+
if (this.settleWaiters.length > 0) {
|
|
278
|
+
this.settleLoop = this.runSettleLoop();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
addDiagnostic(diagnostic) {
|
|
283
|
+
const key = JSON.stringify([diagnostic.severity, diagnostic.code, diagnostic.message, diagnostic.details ?? null]);
|
|
284
|
+
if (this.diagnosticKeys.has(key)) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
this.diagnosticKeys.add(key);
|
|
288
|
+
this.diagnosticsList.push(diagnostic);
|
|
289
|
+
document.dispatchEvent(new CustomEvent("fy-diagnostic", { detail: diagnostic }));
|
|
290
|
+
}
|
|
291
|
+
addError(error, frame) {
|
|
292
|
+
const code = isFrameyardError(error) ? error.code : "COMPOSITION_ERROR";
|
|
293
|
+
this.addDiagnostic({
|
|
294
|
+
severity: "error",
|
|
295
|
+
code,
|
|
296
|
+
message: errorMessage(error),
|
|
297
|
+
...(frame !== null ? { details: { frame } } : {}),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
clearDiagnostics() {
|
|
301
|
+
this.diagnosticsList.length = 0;
|
|
302
|
+
this.diagnosticKeys.clear();
|
|
303
|
+
}
|
|
304
|
+
hideRoot() {
|
|
305
|
+
this.root.setAttribute("data-fy-pending", "");
|
|
306
|
+
}
|
|
307
|
+
showRoot() {
|
|
308
|
+
this.root.removeAttribute("data-fy-pending");
|
|
309
|
+
}
|
|
310
|
+
pauseAllAnimations() {
|
|
311
|
+
for (const animation of this.root.getAnimations({ subtree: true })) {
|
|
312
|
+
try {
|
|
313
|
+
animation.pause();
|
|
314
|
+
animation.currentTime = 0;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// Best-effort: hidden content should not animate in real time.
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
clearFrameState() {
|
|
322
|
+
for (const property of FRAME_STATE_PROPERTIES) {
|
|
323
|
+
document.documentElement.style.removeProperty(property);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function resolveHostProps(defaults, overrides, validateProps) {
|
|
328
|
+
const merged = mergeProps(defaults, overrides);
|
|
329
|
+
if (!validateProps) {
|
|
330
|
+
return merged;
|
|
331
|
+
}
|
|
332
|
+
let errors;
|
|
333
|
+
try {
|
|
334
|
+
errors = validateProps(merged);
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
throw new FrameyardError("PROPS_INVALID", `Props validation failed: ${errorMessage(error)}`, { cause: error });
|
|
338
|
+
}
|
|
339
|
+
if (errors !== undefined) {
|
|
340
|
+
if (!Array.isArray(errors) || errors.some((item) => typeof item !== "string")) {
|
|
341
|
+
throw new FrameyardError("PROPS_INVALID", "validateProps() must return void or an array of strings.");
|
|
342
|
+
}
|
|
343
|
+
if (errors.length > 0) {
|
|
344
|
+
throw new FrameyardError("PROPS_INVALID", `Invalid props: ${errors.join("; ")}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return merged;
|
|
348
|
+
}
|
|
349
|
+
async function waitForDom() {
|
|
350
|
+
if (document.readyState !== "loading") {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
await new Promise((resolve) => {
|
|
354
|
+
document.addEventListener("DOMContentLoaded", () => resolve(), { once: true });
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
function readMetadata(root) {
|
|
358
|
+
const width = readPositiveInteger(root, "width");
|
|
359
|
+
const height = readPositiveInteger(root, "height");
|
|
360
|
+
const fps = readPositiveNumber(root, "fps");
|
|
361
|
+
const durationInFrames = readPositiveInteger(root, "duration-in-frames");
|
|
362
|
+
return { width, height, fps, durationInFrames };
|
|
363
|
+
}
|
|
364
|
+
function readPositiveInteger(root, attribute) {
|
|
365
|
+
const raw = root.getAttribute(attribute);
|
|
366
|
+
if (raw === null) {
|
|
367
|
+
throw new FrameyardError("COMPOSITION_INVALID", `Missing required "${attribute}" attribute on <fy-composition>.`);
|
|
368
|
+
}
|
|
369
|
+
const value = Number(raw);
|
|
370
|
+
if (!/^\d+$/.test(raw.trim()) || !Number.isInteger(value) || value <= 0) {
|
|
371
|
+
throw new FrameyardError("COMPOSITION_INVALID", `"${attribute}" must be a positive integer; received "${raw}".`);
|
|
372
|
+
}
|
|
373
|
+
return value;
|
|
374
|
+
}
|
|
375
|
+
function readPositiveNumber(root, attribute) {
|
|
376
|
+
const raw = root.getAttribute(attribute);
|
|
377
|
+
if (raw === null) {
|
|
378
|
+
throw new FrameyardError("COMPOSITION_INVALID", `Missing required "${attribute}" attribute on <fy-composition>.`);
|
|
379
|
+
}
|
|
380
|
+
const value = Number(raw);
|
|
381
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
382
|
+
throw new FrameyardError("COMPOSITION_INVALID", `"${attribute}" must be a positive number; received "${raw}".`);
|
|
383
|
+
}
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
function readDefaultProps() {
|
|
387
|
+
const blocks = document.querySelectorAll('script[type="application/json"][data-fy="props"]');
|
|
388
|
+
if (blocks.length > 1) {
|
|
389
|
+
throw new FrameyardError("PROPS_INVALID", 'A composition may contain at most one data-fy="props" block.');
|
|
390
|
+
}
|
|
391
|
+
const block = blocks[0];
|
|
392
|
+
if (!block) {
|
|
393
|
+
return Object.freeze({});
|
|
394
|
+
}
|
|
395
|
+
let parsed;
|
|
396
|
+
try {
|
|
397
|
+
parsed = JSON.parse(block.textContent ?? "");
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
throw new FrameyardError("PROPS_INVALID", `Invalid default props JSON: ${errorMessage(error)}`);
|
|
401
|
+
}
|
|
402
|
+
if (!isPlainObject(parsed)) {
|
|
403
|
+
throw new FrameyardError("PROPS_INVALID", 'The data-fy="props" block must contain a JSON object.');
|
|
404
|
+
}
|
|
405
|
+
return mergeProps(parsed, undefined);
|
|
406
|
+
}
|
|
407
|
+
function readControllerDeclaration() {
|
|
408
|
+
const blocks = document.querySelectorAll('script[type="text/fy-controller"]');
|
|
409
|
+
if (blocks.length > 1) {
|
|
410
|
+
throw new FrameyardError("CONTROLLER_INVALID", "A composition may declare at most one controller.");
|
|
411
|
+
}
|
|
412
|
+
const block = blocks[0];
|
|
413
|
+
if (!block) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
const src = block.getAttribute("src");
|
|
417
|
+
const source = block.textContent ?? "";
|
|
418
|
+
if (src !== null && source.trim().length > 0) {
|
|
419
|
+
throw new FrameyardError("CONTROLLER_INVALID", "A controller must be either inline or external, not both.");
|
|
420
|
+
}
|
|
421
|
+
if (src === null && source.trim().length === 0) {
|
|
422
|
+
throw new FrameyardError("CONTROLLER_INVALID", "A controller must contain inline source or reference an external module with src.");
|
|
423
|
+
}
|
|
424
|
+
if (src !== null) {
|
|
425
|
+
return { kind: "external", src };
|
|
426
|
+
}
|
|
427
|
+
return { kind: "inline", source };
|
|
428
|
+
}
|
|
429
|
+
async function importController(declaration) {
|
|
430
|
+
if (!declaration) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const url = declaration.kind === "external"
|
|
434
|
+
? new URL(declaration.src ?? "", document.baseURI).href
|
|
435
|
+
: new URL("?__fy-controller", document.baseURI).href;
|
|
436
|
+
let module;
|
|
437
|
+
try {
|
|
438
|
+
module = (await import(url));
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
throw new FrameyardError("CONTROLLER_INVALID", `Could not load controller ${url}: ${errorMessage(error)}`, {
|
|
442
|
+
cause: error,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (!isPlainObject(module.default)) {
|
|
446
|
+
throw new FrameyardError("CONTROLLER_INVALID", `Controller ${url} must default-export an object.`);
|
|
447
|
+
}
|
|
448
|
+
const controller = module.default;
|
|
449
|
+
for (const key of ["mount", "update", "dispose", "validateProps"]) {
|
|
450
|
+
if (controller[key] !== undefined && typeof controller[key] !== "function") {
|
|
451
|
+
throw new FrameyardError("CONTROLLER_INVALID", `Controller ${key} must be a function when provided.`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (!controller.mount && !controller.update && !controller.validateProps) {
|
|
455
|
+
throw new FrameyardError("CONTROLLER_INVALID", "Controller must export at least one of mount(), update(), or validateProps().");
|
|
456
|
+
}
|
|
457
|
+
if (controller.update && isAsyncFunction(controller.update)) {
|
|
458
|
+
throw new FrameyardError("CONTROLLER_INVALID", "Controller update() must be synchronous; finish asynchronous work in mount().");
|
|
459
|
+
}
|
|
460
|
+
return controller;
|
|
461
|
+
}
|
|
462
|
+
function isThenable(value) {
|
|
463
|
+
return ((typeof value === "object" || typeof value === "function") &&
|
|
464
|
+
value !== null &&
|
|
465
|
+
typeof value.then === "function");
|
|
466
|
+
}
|
|
467
|
+
function isAsyncFunction(value) {
|
|
468
|
+
return typeof value === "function" && Object.prototype.toString.call(value) === "[object AsyncFunction]";
|
|
469
|
+
}
|
|
470
|
+
function toHostError(error) {
|
|
471
|
+
if (isFrameyardError(error)) {
|
|
472
|
+
return new Error(`[${error.code}] ${error.message}`);
|
|
473
|
+
}
|
|
474
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
475
|
+
}
|
|
476
|
+
async function waitForAssets(root) {
|
|
477
|
+
await document.fonts.ready;
|
|
478
|
+
const images = Array.from(root.querySelectorAll("img"));
|
|
479
|
+
await Promise.all(images.map(async (image) => {
|
|
480
|
+
if (!image.currentSrc && !image.src) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
await image.decode();
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
throw new FrameyardError("ASSET_NOT_FOUND", `Could not load image asset "${image.currentSrc || image.src}".`, {
|
|
488
|
+
cause: error,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}));
|
|
492
|
+
}
|
|
493
|
+
function animationElements(root) {
|
|
494
|
+
const selector = "[data-fy-anim-start], [data-fy-anim-duration], [data-fy-anim-easing]";
|
|
495
|
+
return [...(root.matches(selector) ? [root] : []), ...Array.from(root.querySelectorAll(selector))];
|
|
496
|
+
}
|
|
497
|
+
function applyAnimationSchedule(root, fps) {
|
|
498
|
+
for (const element of animationElements(root)) {
|
|
499
|
+
const style = element instanceof HTMLElement || element instanceof SVGElement ? element.style : null;
|
|
500
|
+
if (!style) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const start = element.getAttribute("data-fy-anim-start");
|
|
504
|
+
const duration = element.getAttribute("data-fy-anim-duration");
|
|
505
|
+
const easing = element.getAttribute("data-fy-anim-easing");
|
|
506
|
+
if (start !== null) {
|
|
507
|
+
const frames = Number(start);
|
|
508
|
+
if (!Number.isInteger(frames) || frames < 0) {
|
|
509
|
+
throw new FrameyardError("ANIMATION_INVALID", "data-fy-anim-start must be a non-negative integer frame.");
|
|
510
|
+
}
|
|
511
|
+
style.setProperty("animation-delay", `${frames / fps}s`, "important");
|
|
512
|
+
}
|
|
513
|
+
if (duration !== null) {
|
|
514
|
+
const frames = Number(duration);
|
|
515
|
+
if (!Number.isInteger(frames) || frames <= 0) {
|
|
516
|
+
throw new FrameyardError("ANIMATION_INVALID", "data-fy-anim-duration must be a positive integer frame.");
|
|
517
|
+
}
|
|
518
|
+
style.setProperty("animation-duration", `${frames / fps}s`, "important");
|
|
519
|
+
}
|
|
520
|
+
if (easing !== null) {
|
|
521
|
+
if (easing.trim().length === 0) {
|
|
522
|
+
throw new FrameyardError("ANIMATION_INVALID", "data-fy-anim-easing must not be empty.");
|
|
523
|
+
}
|
|
524
|
+
style.setProperty("animation-timing-function", easing, "important");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function checkAnimationDiagnostics(root, sink) {
|
|
529
|
+
for (const element of animationElements(root)) {
|
|
530
|
+
const animations = element.getAnimations();
|
|
531
|
+
if (animations.length === 0) {
|
|
532
|
+
sink({
|
|
533
|
+
severity: "warning",
|
|
534
|
+
code: "ANIMATION_NOT_FOUND",
|
|
535
|
+
message: "data-fy-anim-* is present but the element has no CSS animation.",
|
|
536
|
+
details: { element: element.tagName.toLowerCase() },
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
else if (animations.length > 1) {
|
|
540
|
+
sink({
|
|
541
|
+
severity: "warning",
|
|
542
|
+
code: "ANIMATION_MULTIPLE",
|
|
543
|
+
message: "data-fy-anim-* applies one schedule to every animation on the element.",
|
|
544
|
+
details: { element: element.tagName.toLowerCase() },
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function seekAnimations(root, frame, fps, onTransition) {
|
|
550
|
+
if (typeof root.getAnimations !== "function") {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const time = frameToMilliseconds(frame, fps);
|
|
554
|
+
let sawTransition = false;
|
|
555
|
+
for (const animation of root.getAnimations({ subtree: true })) {
|
|
556
|
+
if (typeof CSSTransition !== "undefined" && animation instanceof CSSTransition) {
|
|
557
|
+
sawTransition = true;
|
|
558
|
+
}
|
|
559
|
+
try {
|
|
560
|
+
animation.pause();
|
|
561
|
+
animation.currentTime = time;
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
throw new FrameyardError("COMPOSITION_ERROR", `A CSS or Web Animation could not be seeked to frame ${frame}: ${errorMessage(error)}`, { cause: error });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (sawTransition) {
|
|
568
|
+
onTransition();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function setFrameState(metadata, frame) {
|
|
572
|
+
const root = document.documentElement;
|
|
573
|
+
const progress = metadata.durationInFrames <= 1 ? 0 : frame / (metadata.durationInFrames - 1);
|
|
574
|
+
root.style.setProperty("--fy-frame", String(frame));
|
|
575
|
+
root.style.setProperty("--fy-time", String(frame / metadata.fps));
|
|
576
|
+
root.style.setProperty("--fy-progress", String(progress));
|
|
577
|
+
root.style.setProperty("--fy-fps", String(metadata.fps));
|
|
578
|
+
root.style.setProperty("--fy-duration", String(metadata.durationInFrames / metadata.fps));
|
|
579
|
+
root.style.setProperty("--fy-duration-frames", String(metadata.durationInFrames));
|
|
580
|
+
}
|
|
581
|
+
function collectAudioClips(root, onDiagnostic) {
|
|
582
|
+
const clips = [];
|
|
583
|
+
for (const element of Array.from(root.querySelectorAll("audio"))) {
|
|
584
|
+
const src = element.getAttribute("src");
|
|
585
|
+
if (src === null || src.length === 0) {
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const startFrame = readMediaInteger(element, "data-fy-start", 0);
|
|
589
|
+
const trimStart = readMediaNumber(element, "data-fy-trim", 0);
|
|
590
|
+
const trimDuration = readOptionalMediaNumber(element, "data-fy-duration");
|
|
591
|
+
const volume = element.hasAttribute("muted") ? 0 : readMediaNumber(element, "data-fy-volume", 1);
|
|
592
|
+
if (startFrame === undefined || trimStart === undefined || volume === undefined) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
clips.push(normalizeAudioClip({
|
|
597
|
+
src,
|
|
598
|
+
startFrame,
|
|
599
|
+
trimStart,
|
|
600
|
+
...(trimDuration !== undefined ? { trimDuration } : {}),
|
|
601
|
+
volume,
|
|
602
|
+
}, clips.length));
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
onDiagnostic({
|
|
606
|
+
severity: "warning",
|
|
607
|
+
code: isFrameyardError(error) ? error.code : "MEDIA_INVALID",
|
|
608
|
+
message: errorMessage(error),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return Object.freeze(clips);
|
|
613
|
+
}
|
|
614
|
+
function collectTranscriptClips(root, onDiagnostic) {
|
|
615
|
+
const clips = [];
|
|
616
|
+
for (const element of Array.from(root.querySelectorAll("fy-transcript"))) {
|
|
617
|
+
const src = element.getAttribute("src");
|
|
618
|
+
if (src === null || src.length === 0) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
clips.push(normalizeTranscriptClip({ src, words: element.hasAttribute("words") }, clips.length));
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
onDiagnostic({
|
|
626
|
+
severity: "warning",
|
|
627
|
+
code: isFrameyardError(error) ? error.code : "TRANSCRIPT_INVALID",
|
|
628
|
+
message: errorMessage(error),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return Object.freeze(clips);
|
|
633
|
+
}
|
|
634
|
+
function readMediaInteger(element, attribute, fallback) {
|
|
635
|
+
const raw = element.getAttribute(attribute);
|
|
636
|
+
if (raw === null) {
|
|
637
|
+
return fallback;
|
|
638
|
+
}
|
|
639
|
+
const value = Number(raw);
|
|
640
|
+
return /^\d+$/.test(raw.trim()) && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
641
|
+
}
|
|
642
|
+
function readMediaNumber(element, attribute, fallback) {
|
|
643
|
+
const raw = element.getAttribute(attribute);
|
|
644
|
+
if (raw === null) {
|
|
645
|
+
return fallback;
|
|
646
|
+
}
|
|
647
|
+
const value = Number(raw);
|
|
648
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
649
|
+
}
|
|
650
|
+
function readOptionalMediaNumber(element, attribute) {
|
|
651
|
+
const raw = element.getAttribute(attribute);
|
|
652
|
+
if (raw === null) {
|
|
653
|
+
return undefined;
|
|
654
|
+
}
|
|
655
|
+
const value = Number(raw);
|
|
656
|
+
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
657
|
+
}
|
|
658
|
+
export async function createFrameyardHost() {
|
|
659
|
+
await waitForDom();
|
|
660
|
+
const root = document.querySelector("fy-composition");
|
|
661
|
+
if (!(root instanceof HTMLElement)) {
|
|
662
|
+
throw new FrameyardError("COMPOSITION_INVALID", "Composition document must contain one <fy-composition> root.");
|
|
663
|
+
}
|
|
664
|
+
const metadata = readMetadata(root);
|
|
665
|
+
const defaultProps = readDefaultProps();
|
|
666
|
+
const declaration = readControllerDeclaration();
|
|
667
|
+
const controllerModule = await importController(declaration);
|
|
668
|
+
const manual = new URLSearchParams(window.location.search).has("__fy-manual");
|
|
669
|
+
const host = new HostRuntime({
|
|
670
|
+
root,
|
|
671
|
+
metadata,
|
|
672
|
+
defaultProps,
|
|
673
|
+
declaration,
|
|
674
|
+
controllerModule,
|
|
675
|
+
manual,
|
|
676
|
+
});
|
|
677
|
+
window.__fy = host;
|
|
678
|
+
if (manual) {
|
|
679
|
+
host.markReady();
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
try {
|
|
683
|
+
await host.initialLoad();
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
// The host records the diagnostic; ready still resolves so callers can inspect it.
|
|
687
|
+
}
|
|
688
|
+
host.markReady();
|
|
689
|
+
}
|
|
690
|
+
return host;
|
|
691
|
+
}
|
|
692
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
693
|
+
void createFrameyardHost().catch((error) => {
|
|
694
|
+
const code = isFrameyardError(error) ? error.code : "COMPOSITION_ERROR";
|
|
695
|
+
const message = errorMessage(error);
|
|
696
|
+
window.__fyError = { code, message };
|
|
697
|
+
console.error(`[frameyard] ${message}`);
|
|
698
|
+
document.dispatchEvent(new CustomEvent("fy-error", { detail: { code, message } }));
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
//# sourceMappingURL=host.js.map
|