frameyard 0.4.0 → 0.5.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 +46 -0
- package/GLOSSARY.md +4 -0
- package/README.md +16 -9
- package/dist/cli.js +7 -11
- package/dist/cli.js.map +1 -1
- package/dist/composition/adapter.d.ts +30 -0
- package/dist/composition/adapter.d.ts.map +1 -0
- package/dist/composition/adapter.js +2 -0
- package/dist/composition/adapter.js.map +1 -0
- package/dist/composition/animation.d.ts +19 -0
- package/dist/composition/animation.d.ts.map +1 -0
- package/dist/composition/animation.js +55 -0
- package/dist/composition/animation.js.map +1 -0
- package/dist/composition/attributes.d.ts +7 -0
- package/dist/composition/attributes.d.ts.map +1 -0
- package/dist/composition/attributes.js +24 -0
- package/dist/composition/attributes.js.map +1 -0
- package/dist/composition/descriptor.d.ts +30 -0
- package/dist/composition/descriptor.d.ts.map +1 -0
- package/dist/composition/descriptor.js +188 -0
- package/dist/composition/descriptor.js.map +1 -0
- package/dist/composition/diagnostics.d.ts +12 -0
- package/dist/composition/diagnostics.d.ts.map +1 -0
- package/dist/composition/diagnostics.js +38 -0
- package/dist/composition/diagnostics.js.map +1 -0
- package/dist/composition/index.d.ts +12 -0
- package/dist/composition/index.d.ts.map +1 -0
- package/dist/composition/index.js +7 -0
- package/dist/composition/index.js.map +1 -0
- package/dist/composition/media.d.ts +27 -0
- package/dist/composition/media.d.ts.map +1 -0
- package/dist/composition/media.js +162 -0
- package/dist/composition/media.js.map +1 -0
- package/dist/composition/paths.d.ts +5 -0
- package/dist/composition/paths.d.ts.map +1 -0
- package/dist/composition/paths.js +16 -0
- package/dist/composition/paths.js.map +1 -0
- package/dist/composition/transcripts.d.ts +19 -0
- package/dist/composition/transcripts.d.ts.map +1 -0
- package/dist/composition/transcripts.js +57 -0
- package/dist/composition/transcripts.js.map +1 -0
- package/dist/composition/walk.d.ts +10 -0
- package/dist/composition/walk.d.ts.map +1 -0
- package/dist/composition/walk.js +32 -0
- package/dist/composition/walk.js.map +1 -0
- package/dist/contracts.d.ts +2 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/html/document-adapter.d.ts +5 -0
- package/dist/html/document-adapter.d.ts.map +1 -0
- package/dist/html/document-adapter.js +17 -0
- package/dist/html/document-adapter.js.map +1 -0
- package/dist/html/parse.d.ts +2 -6
- package/dist/html/parse.d.ts.map +1 -1
- package/dist/html/parse.js.map +1 -1
- package/dist/html/validate.d.ts.map +1 -1
- package/dist/html/validate.js +92 -375
- package/dist/html/validate.js.map +1 -1
- package/dist/preview/main.d.ts.map +1 -1
- package/dist/preview/main.js +4 -2
- package/dist/preview/main.js.map +1 -1
- package/dist/renderer/browser.d.ts.map +1 -1
- package/dist/renderer/browser.js +32 -5
- package/dist/renderer/browser.js.map +1 -1
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +18 -7
- package/dist/renderer/index.js.map +1 -1
- package/dist/runtime/document-adapter.d.ts +4 -0
- package/dist/runtime/document-adapter.d.ts.map +1 -0
- package/dist/runtime/document-adapter.js +19 -0
- package/dist/runtime/document-adapter.js.map +1 -0
- package/dist/runtime/host.d.ts +2 -0
- package/dist/runtime/host.d.ts.map +1 -1
- package/dist/runtime/host.js +78 -183
- package/dist/runtime/host.js.map +1 -1
- package/dist/runtime/media.d.ts.map +1 -1
- package/dist/runtime/media.js +19 -50
- package/dist/runtime/media.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +10 -5
- package/dist/scaffold.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/adr/0006-seek-embedded-media-from-the-frame-clock.md +4 -0
- package/docs/adr/0008-share-the-composition-document-grammar.md +18 -0
- package/docs/adr/0009-composite-production-frames-in-software.md +37 -0
- package/docs/v0.1-design.md +5 -4
- package/examples/demo/README.md +1 -1
- package/examples/video/README.md +61 -0
- package/examples/video/assets/clip.mp4 +0 -0
- package/examples/video/assets/clip.webm +0 -0
- package/examples/video/compositions/video-mp4.html +362 -0
- package/examples/video/compositions/video.html +362 -0
- package/examples/video/props/video.json +8 -0
- package/examples/video/scripts/make-clip.sh +109 -0
- package/examples/video/video.config.js +6 -0
- package/examples/webgl/README.md +30 -0
- package/examples/webgl/compositions/webgl.controller.js +200 -0
- package/examples/webgl/compositions/webgl.html +215 -0
- package/examples/webgl/props/webgl.json +6 -0
- package/examples/webgl/video.config.js +5 -0
- package/package.json +4 -2
- package/skills/frameyard/SKILL.md +3 -2
- package/skills/frameyard/references/json-results.md +2 -2
package/dist/html/validate.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { KNOWN_DATA_FY_ATTRIBUTES, isAnimationAttribute, isLocalRelative, isMediaAttribute, isRemote, readAnimationDeclarations, readCompositionDescriptor, readMediaDeclarations, readTranscriptDeclarations, } from "../composition/index.js";
|
|
4
|
+
import { mergeProps, } from "../contracts.js";
|
|
4
5
|
import { resolveBindingPath, suggestBindingPath, validateBindingPath } from "../bindings.js";
|
|
5
6
|
import { errorMessage, isFrameyardError } from "../errors.js";
|
|
6
7
|
import { parseTranscript } from "../transcript.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
8
|
+
import { createParse5Adapter } from "./document-adapter.js";
|
|
9
|
+
import { findElements, getAttribute, getAttributeLocation, getDocumentDoctype, getElementByTagName, getElementLocation, getNodeLocation, hasAttribute, isDescendantOf, isElement, isTemplate, isTextNode, parseHtml, walk, } from "./parse.js";
|
|
9
10
|
const VOID_ELEMENTS = new Set([
|
|
10
11
|
"area",
|
|
11
12
|
"base",
|
|
@@ -24,27 +25,6 @@ const VOID_ELEMENTS = new Set([
|
|
|
24
25
|
]);
|
|
25
26
|
const NON_VISUAL_ELEMENTS = new Set(["script", "style", "link", "meta", "template", "noscript", "title"]);
|
|
26
27
|
const NON_ANIMATED_ELEMENTS = new Set(["audio", "fy-transcript", "script", "style", "link", "meta", "template"]);
|
|
27
|
-
const MEDIA_ATTRIBUTES = new Set([
|
|
28
|
-
"data-fy-start",
|
|
29
|
-
"data-fy-trim",
|
|
30
|
-
"data-fy-duration",
|
|
31
|
-
"data-fy-volume",
|
|
32
|
-
"data-fy-audio",
|
|
33
|
-
]);
|
|
34
|
-
const KNOWN_BINDING_ATTRIBUTES = [
|
|
35
|
-
"data-fy",
|
|
36
|
-
"data-fy-text",
|
|
37
|
-
"data-fy-if",
|
|
38
|
-
"data-fy-repeat",
|
|
39
|
-
"data-fy-anim-start",
|
|
40
|
-
"data-fy-anim-duration",
|
|
41
|
-
"data-fy-anim-easing",
|
|
42
|
-
"data-fy-start",
|
|
43
|
-
"data-fy-trim",
|
|
44
|
-
"data-fy-duration",
|
|
45
|
-
"data-fy-volume",
|
|
46
|
-
"data-fy-audio",
|
|
47
|
-
];
|
|
48
28
|
class HtmlCompositionValidator {
|
|
49
29
|
diagnostics = [];
|
|
50
30
|
projectRoot;
|
|
@@ -57,9 +37,9 @@ class HtmlCompositionValidator {
|
|
|
57
37
|
metadata = null;
|
|
58
38
|
controller = null;
|
|
59
39
|
documentValid = true;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
40
|
+
fixedFactsValid = false;
|
|
41
|
+
overridesValid = false;
|
|
42
|
+
controllerFileValid = true;
|
|
63
43
|
root = null;
|
|
64
44
|
audioClips = [];
|
|
65
45
|
embeddedAudioClips = [];
|
|
@@ -74,10 +54,19 @@ class HtmlCompositionValidator {
|
|
|
74
54
|
}
|
|
75
55
|
async run() {
|
|
76
56
|
this.validateDoctype();
|
|
77
|
-
this.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
57
|
+
const adapter = createParse5Adapter(this.document);
|
|
58
|
+
const read = readCompositionDescriptor(adapter, {
|
|
59
|
+
path: this.entry,
|
|
60
|
+
resolvePath: (raw) => this.resolveControllerPath(raw),
|
|
61
|
+
});
|
|
62
|
+
this.diagnostics.push(...read.diagnostics);
|
|
63
|
+
this.fixedFactsValid = read.valid;
|
|
64
|
+
const readRoot = read.root;
|
|
65
|
+
this.root = readRoot !== null && isElement(readRoot) ? readRoot : null;
|
|
66
|
+
this.metadata = read.descriptor.metadata;
|
|
67
|
+
this.defaultPropsValue = read.descriptor.defaultProps;
|
|
68
|
+
this.controller = read.descriptor.controller;
|
|
69
|
+
this.mergeOverrides();
|
|
81
70
|
await this.validateControllerFile();
|
|
82
71
|
this.validateScripts();
|
|
83
72
|
this.validateUnsupportedElements();
|
|
@@ -86,12 +75,32 @@ class HtmlCompositionValidator {
|
|
|
86
75
|
if (this.root) {
|
|
87
76
|
this.validateBindings(this.root, 0);
|
|
88
77
|
}
|
|
89
|
-
|
|
90
|
-
|
|
78
|
+
const animation = readAnimationDeclarations(adapter, {
|
|
79
|
+
path: this.entry,
|
|
80
|
+
includeTemplateContent: true,
|
|
81
|
+
});
|
|
82
|
+
this.diagnostics.push(...animation.diagnostics);
|
|
83
|
+
this.validateAnimationTargets(animation.entries);
|
|
84
|
+
const media = readMediaDeclarations(adapter, {
|
|
85
|
+
path: this.entry,
|
|
86
|
+
includeTemplateContent: true,
|
|
87
|
+
props: this.props,
|
|
88
|
+
});
|
|
89
|
+
this.diagnostics.push(...media.diagnostics);
|
|
90
|
+
await this.validateMediaFiles(media.audio, media.embedded);
|
|
91
|
+
const transcripts = readTranscriptDeclarations(adapter, {
|
|
92
|
+
path: this.entry,
|
|
93
|
+
includeTemplateContent: true,
|
|
94
|
+
props: this.props,
|
|
95
|
+
});
|
|
96
|
+
this.diagnostics.push(...transcripts.diagnostics);
|
|
97
|
+
await this.validateTranscriptFiles(transcripts.declarations);
|
|
91
98
|
await this.validateOrdinaryAssets();
|
|
92
|
-
const
|
|
99
|
+
const metadata = this.metadata;
|
|
100
|
+
const root = this.root;
|
|
101
|
+
const info = metadata && root && this.fixedFactsValid && this.overridesValid && this.controllerFileValid && this.documentValid
|
|
93
102
|
? {
|
|
94
|
-
metadata
|
|
103
|
+
metadata,
|
|
95
104
|
defaultProps: this.defaultPropsValue,
|
|
96
105
|
controller: this.controller,
|
|
97
106
|
audioClips: Object.freeze([...this.audioClips]),
|
|
@@ -195,179 +204,38 @@ class HtmlCompositionValidator {
|
|
|
195
204
|
});
|
|
196
205
|
}
|
|
197
206
|
}
|
|
198
|
-
|
|
199
|
-
const roots = findElements(this.document, (element) => element.tagName === "fy-composition");
|
|
200
|
-
if (roots.length === 0) {
|
|
201
|
-
this.error("COMPOSITION_INVALID", "Composition document must contain one <fy-composition> root.");
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
for (const extra of roots.slice(1)) {
|
|
205
|
-
this.error("COMPOSITION_INVALID", "Composition document must contain exactly one <fy-composition> root.", this.elementDetails(extra));
|
|
206
|
-
}
|
|
207
|
-
const root = roots[0];
|
|
208
|
-
this.root = root;
|
|
209
|
-
let valid = roots.length === 1;
|
|
210
|
-
const body = getElementByTagName(this.document, "body");
|
|
211
|
-
if (!body || !isDescendantOf(root, body)) {
|
|
212
|
-
valid = false;
|
|
213
|
-
this.error("COMPOSITION_INVALID", "The <fy-composition> root must be inside <body>.", this.elementDetails(root));
|
|
214
|
-
}
|
|
215
|
-
if (this.isInsideTemplate(root)) {
|
|
216
|
-
valid = false;
|
|
217
|
-
this.error("COMPOSITION_INVALID", "The <fy-composition> root must not be inside a <template>.", this.elementDetails(root));
|
|
218
|
-
}
|
|
219
|
-
this.rootValid = valid;
|
|
220
|
-
}
|
|
221
|
-
isInsideTemplate(node) {
|
|
222
|
-
let current = "parentNode" in node ? node.parentNode : null;
|
|
223
|
-
while (current) {
|
|
224
|
-
if (isElement(current) && current.tagName === "template") {
|
|
225
|
-
return true;
|
|
226
|
-
}
|
|
227
|
-
current = "parentNode" in current ? current.parentNode : null;
|
|
228
|
-
}
|
|
229
|
-
return false;
|
|
230
|
-
}
|
|
231
|
-
validateMetadata() {
|
|
232
|
-
const root = this.root;
|
|
233
|
-
if (!root) {
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
const width = this.readPositiveInteger(root, "width");
|
|
237
|
-
const height = this.readPositiveInteger(root, "height");
|
|
238
|
-
const fps = this.readPositiveNumber(root, "fps");
|
|
239
|
-
const durationInFrames = this.readPositiveInteger(root, "duration-in-frames");
|
|
240
|
-
if (width !== null && height !== null && fps !== null && durationInFrames !== null) {
|
|
241
|
-
this.metadata = { width, height, fps, durationInFrames };
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
readPositiveInteger(element, attribute) {
|
|
245
|
-
const raw = getAttribute(element, attribute);
|
|
246
|
-
if (raw === undefined) {
|
|
247
|
-
this.error("COMPOSITION_INVALID", `Missing required "${attribute}" attribute on <fy-composition>.`, this.attributeDetails(element, attribute));
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
const value = Number(raw);
|
|
251
|
-
if (!/^\d+$/.test(raw.trim()) || !Number.isInteger(value) || value <= 0) {
|
|
252
|
-
this.error("COMPOSITION_INVALID", `"${attribute}" must be a positive integer; received "${raw}".`, this.attributeDetails(element, attribute, { value: raw }));
|
|
253
|
-
return null;
|
|
254
|
-
}
|
|
255
|
-
return value;
|
|
256
|
-
}
|
|
257
|
-
readPositiveNumber(element, attribute) {
|
|
258
|
-
const raw = getAttribute(element, attribute);
|
|
259
|
-
if (raw === undefined) {
|
|
260
|
-
this.error("COMPOSITION_INVALID", `Missing required "${attribute}" attribute on <fy-composition>.`, this.attributeDetails(element, attribute));
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
const value = Number(raw);
|
|
264
|
-
if (!Number.isFinite(value) || value <= 0) {
|
|
265
|
-
this.error("COMPOSITION_INVALID", `"${attribute}" must be a positive number; received "${raw}".`, this.attributeDetails(element, attribute, { value: raw }));
|
|
266
|
-
return null;
|
|
267
|
-
}
|
|
268
|
-
return value;
|
|
269
|
-
}
|
|
270
|
-
async validateDefaultProps() {
|
|
271
|
-
const blocks = findElements(this.document, (element) => element.tagName === "script" &&
|
|
272
|
-
getAttribute(element, "type")?.toLowerCase() === "application/json" &&
|
|
273
|
-
getAttribute(element, "data-fy") === "props");
|
|
274
|
-
if (blocks.length > 1) {
|
|
275
|
-
this.defaultsValid = false;
|
|
276
|
-
for (const extra of blocks.slice(1)) {
|
|
277
|
-
this.error("PROPS_INVALID", 'A composition may contain at most one data-fy="props" block.', this.elementDetails(extra));
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
const block = blocks[0];
|
|
281
|
-
let defaults = Object.freeze({});
|
|
282
|
-
if (block) {
|
|
283
|
-
if (this.isInsideTemplate(block)) {
|
|
284
|
-
this.defaultsValid = false;
|
|
285
|
-
this.error("PROPS_INVALID", 'The data-fy="props" block must not be inside a <template>.', this.elementDetails(block));
|
|
286
|
-
}
|
|
287
|
-
try {
|
|
288
|
-
const parsed = JSON.parse(getTextContent(block));
|
|
289
|
-
if (!isPlainObject(parsed)) {
|
|
290
|
-
throw new Error('The data-fy="props" block must contain a JSON object.');
|
|
291
|
-
}
|
|
292
|
-
assertJsonSerializable(parsed, "defaultProps");
|
|
293
|
-
defaults = mergeProps(parsed, undefined);
|
|
294
|
-
}
|
|
295
|
-
catch (error) {
|
|
296
|
-
this.defaultsValid = false;
|
|
297
|
-
this.error("PROPS_INVALID", `Invalid default props: ${errorMessage(error)}`, this.elementDetails(block));
|
|
298
|
-
}
|
|
299
|
-
}
|
|
207
|
+
mergeOverrides() {
|
|
300
208
|
try {
|
|
301
|
-
this.props = mergeProps(
|
|
209
|
+
this.props = mergeProps(this.defaultPropsValue, this.propsOverrides);
|
|
210
|
+
this.overridesValid = true;
|
|
302
211
|
}
|
|
303
212
|
catch (error) {
|
|
304
|
-
this.
|
|
213
|
+
this.overridesValid = false;
|
|
305
214
|
this.error("PROPS_INVALID", `Invalid props: ${errorMessage(error)}`);
|
|
306
|
-
this.props =
|
|
307
|
-
}
|
|
308
|
-
if (this.defaultsValid) {
|
|
309
|
-
this.defaultPropsValue = defaults;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
validateControllerDeclaration() {
|
|
313
|
-
const blocks = findElements(this.document, (element) => element.tagName === "script" && getAttribute(element, "type")?.toLowerCase() === "text/fy-controller");
|
|
314
|
-
if (blocks.length > 1) {
|
|
315
|
-
this.controllerValid = false;
|
|
316
|
-
for (const extra of blocks.slice(1)) {
|
|
317
|
-
this.error("CONTROLLER_INVALID", "A composition may declare at most one controller.", this.elementDetails(extra));
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
const block = blocks[0];
|
|
321
|
-
if (!block) {
|
|
322
|
-
return;
|
|
215
|
+
this.props = this.defaultPropsValue;
|
|
323
216
|
}
|
|
324
|
-
if (this.isInsideTemplate(block)) {
|
|
325
|
-
this.controllerValid = false;
|
|
326
|
-
this.error("CONTROLLER_INVALID", "The controller block must not be inside a <template>.", this.elementDetails(block));
|
|
327
|
-
}
|
|
328
|
-
const src = getAttribute(block, "src");
|
|
329
|
-
const source = getTextContent(block);
|
|
330
|
-
const hasSource = source.trim().length > 0;
|
|
331
|
-
if (src !== undefined && hasSource) {
|
|
332
|
-
this.controllerValid = false;
|
|
333
|
-
this.error("CONTROLLER_INVALID", "A controller must be either inline or external, not both.", this.attributeDetails(block, "src"));
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
if (src === undefined && !hasSource) {
|
|
337
|
-
this.controllerValid = false;
|
|
338
|
-
this.error("CONTROLLER_INVALID", "A controller must contain inline source or reference an external module with src.", this.elementDetails(block));
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
if (src !== undefined) {
|
|
342
|
-
this.controller = {
|
|
343
|
-
kind: "external",
|
|
344
|
-
src,
|
|
345
|
-
...this.locationDetails(getAttributeLocation(block, "src")),
|
|
346
|
-
};
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
const textNode = block.childNodes[0];
|
|
350
|
-
const location = textNode ? getNodeLocation(textNode) : getElementLocation(block);
|
|
351
|
-
this.controller = {
|
|
352
|
-
kind: "inline",
|
|
353
|
-
source,
|
|
354
|
-
...this.locationDetails(location),
|
|
355
|
-
};
|
|
356
217
|
}
|
|
357
218
|
async validateControllerFile() {
|
|
358
|
-
|
|
219
|
+
const controller = this.controller;
|
|
220
|
+
if (!controller ||
|
|
221
|
+
controller.kind !== "external" ||
|
|
222
|
+
controller.rawSrc === undefined ||
|
|
223
|
+
controller.src === undefined) {
|
|
359
224
|
return;
|
|
360
225
|
}
|
|
361
226
|
const details = {
|
|
362
227
|
path: this.entry,
|
|
363
|
-
...(
|
|
228
|
+
...(controller.line !== undefined ? { line: controller.line, column: controller.column } : {}),
|
|
364
229
|
};
|
|
365
|
-
const resolved = await this.resolveLocalFile(
|
|
230
|
+
const resolved = await this.resolveLocalFile(controller.rawSrc, this.compositionDir, "CONTROLLER_INVALID", "Controller", details);
|
|
366
231
|
if (!resolved) {
|
|
367
|
-
this.
|
|
232
|
+
this.controllerFileValid = false;
|
|
368
233
|
return;
|
|
369
234
|
}
|
|
370
|
-
this.controller = { ...
|
|
235
|
+
this.controller = { ...controller, src: resolved.relative };
|
|
236
|
+
}
|
|
237
|
+
resolveControllerPath(raw) {
|
|
238
|
+
return path.relative(this.projectRoot, path.resolve(this.compositionDir, raw)).split(path.sep).join("/");
|
|
371
239
|
}
|
|
372
240
|
validateScripts() {
|
|
373
241
|
for (const element of findElements(this.document, (candidate) => candidate.tagName === "script")) {
|
|
@@ -531,22 +399,11 @@ class HtmlCompositionValidator {
|
|
|
531
399
|
this.validateClassBinding(element, name, attribute.value, inRepeat);
|
|
532
400
|
continue;
|
|
533
401
|
}
|
|
534
|
-
if (name
|
|
535
|
-
this.validateAnimationDuration(element, name, attribute.value);
|
|
536
|
-
continue;
|
|
537
|
-
}
|
|
538
|
-
if (name === "data-fy-anim-easing") {
|
|
539
|
-
if (attribute.value.trim().length === 0) {
|
|
540
|
-
this.error("ANIMATION_INVALID", "data-fy-anim-easing must not be empty.", this.attributeDetails(element, name));
|
|
541
|
-
}
|
|
542
|
-
continue;
|
|
543
|
-
}
|
|
544
|
-
if (MEDIA_ATTRIBUTES.has(name)) {
|
|
545
|
-
this.validateMediaAttribute(element, name, attribute.value);
|
|
402
|
+
if (isAnimationAttribute(name) || isMediaAttribute(name)) {
|
|
546
403
|
continue;
|
|
547
404
|
}
|
|
548
405
|
if (name.startsWith("data-fy-")) {
|
|
549
|
-
this.error("BINDING_INVALID", `Unknown Frameyard attribute "${name}". Known attributes: ${
|
|
406
|
+
this.error("BINDING_INVALID", `Unknown Frameyard attribute "${name}". Known attributes: ${KNOWN_DATA_FY_ATTRIBUTES.join(", ")}.`, this.attributeDetails(element, name, { value: attribute.value }));
|
|
550
407
|
}
|
|
551
408
|
}
|
|
552
409
|
const children = isTemplate(element) ? element.content.childNodes : element.childNodes;
|
|
@@ -610,195 +467,61 @@ class HtmlCompositionValidator {
|
|
|
610
467
|
checkValueType: "class",
|
|
611
468
|
});
|
|
612
469
|
}
|
|
613
|
-
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
Number.isInteger(frames) &&
|
|
617
|
-
(name === "data-fy-anim-start" ? frames >= 0 : frames > 0);
|
|
618
|
-
if (!valid) {
|
|
619
|
-
this.error("ANIMATION_INVALID", `${name} must be a ${name === "data-fy-anim-start" ? "non-negative" : "positive"} integer frame count.`, this.attributeDetails(element, name, { value }));
|
|
620
|
-
}
|
|
621
|
-
if (NON_ANIMATED_ELEMENTS.has(element.tagName)) {
|
|
622
|
-
this.warning("ANIMATION_NOT_FOUND", `${name} has no visual animation target on <${element.tagName}>.`, this.attributeDetails(element, name));
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
validateMediaAttribute(element, name, value) {
|
|
626
|
-
if (element.tagName !== "audio" && element.tagName !== "video") {
|
|
627
|
-
this.error("MEDIA_INVALID", `${name} is only valid on <audio> or <video>.`, this.attributeDetails(element, name));
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
if (name === "data-fy-audio") {
|
|
631
|
-
if (element.tagName !== "video" || value !== "off") {
|
|
632
|
-
this.error("MEDIA_INVALID", 'data-fy-audio is only valid on <video> with the value "off".', this.attributeDetails(element, name, { value }));
|
|
633
|
-
}
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
if (name === "data-fy-start") {
|
|
637
|
-
const frames = Number(value);
|
|
638
|
-
if (!/^\d+$/.test(value.trim()) || !Number.isInteger(frames) || frames < 0) {
|
|
639
|
-
this.error("MEDIA_INVALID", "data-fy-start must be a non-negative integer frame.", this.attributeDetails(element, name, { value }));
|
|
640
|
-
}
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
if (name === "data-fy-trim") {
|
|
644
|
-
const seconds = Number(value);
|
|
645
|
-
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
646
|
-
this.error("MEDIA_INVALID", "data-fy-trim must be a non-negative number of seconds.", this.attributeDetails(element, name, { value }));
|
|
647
|
-
}
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
if (name === "data-fy-duration") {
|
|
651
|
-
const seconds = Number(value);
|
|
652
|
-
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
653
|
-
this.error("MEDIA_INVALID", "data-fy-duration must be a positive number of seconds.", this.attributeDetails(element, name, { value }));
|
|
654
|
-
}
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
const volume = Number(value);
|
|
658
|
-
if (!Number.isFinite(volume) || volume < 0 || volume > 1) {
|
|
659
|
-
this.error("MEDIA_INVALID", "data-fy-volume must be between 0 and 1.", this.attributeDetails(element, name, { value }));
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
async validateMedia() {
|
|
663
|
-
const media = findElements(this.document, (element) => element.tagName === "audio" || element.tagName === "video");
|
|
664
|
-
for (const element of media) {
|
|
665
|
-
const literal = getAttribute(element, "src");
|
|
666
|
-
const bound = getAttribute(element, "data-fy-attr-src");
|
|
667
|
-
if (literal !== undefined && bound !== undefined) {
|
|
668
|
-
this.error("MEDIA_INVALID", `Use either src or data-fy-attr-src on <${element.tagName}>, not both.`, this.elementDetails(element));
|
|
470
|
+
validateAnimationTargets(entries) {
|
|
471
|
+
for (const entry of entries) {
|
|
472
|
+
if (!isElement(entry.element)) {
|
|
669
473
|
continue;
|
|
670
474
|
}
|
|
671
|
-
|
|
672
|
-
|
|
475
|
+
const tag = entry.element.tagName;
|
|
476
|
+
if (!NON_ANIMATED_ELEMENTS.has(tag)) {
|
|
673
477
|
continue;
|
|
674
478
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
source = literal;
|
|
678
|
-
}
|
|
679
|
-
else if (bound !== undefined) {
|
|
680
|
-
const resolved = this.resolveMediaBinding(bound);
|
|
681
|
-
if (resolved === null) {
|
|
479
|
+
for (const attribute of entry.attributes) {
|
|
480
|
+
if (attribute !== "data-fy-anim-start" && attribute !== "data-fy-anim-duration") {
|
|
682
481
|
continue;
|
|
683
482
|
}
|
|
684
|
-
|
|
685
|
-
}
|
|
686
|
-
if (source === undefined) {
|
|
687
|
-
continue;
|
|
688
|
-
}
|
|
689
|
-
const file = await this.resolveLocalFile(source, this.projectRoot, "MEDIA_INVALID", "Media", this.elementDetails(element));
|
|
690
|
-
if (!file) {
|
|
691
|
-
continue;
|
|
692
|
-
}
|
|
693
|
-
const startFrame = this.readMediaInteger(element, "data-fy-start", 0);
|
|
694
|
-
const trimStart = this.readMediaNumber(element, "data-fy-trim", 0);
|
|
695
|
-
const trimDuration = this.readOptionalMediaNumber(element, "data-fy-duration");
|
|
696
|
-
const volume = hasAttribute(element, "muted") ? 0 : this.readMediaNumber(element, "data-fy-volume", 1);
|
|
697
|
-
if (startFrame === undefined || trimStart === undefined || volume === undefined) {
|
|
698
|
-
continue;
|
|
699
|
-
}
|
|
700
|
-
try {
|
|
701
|
-
const clip = normalizeAudioClip({
|
|
702
|
-
src: file.relative,
|
|
703
|
-
startFrame,
|
|
704
|
-
trimStart,
|
|
705
|
-
...(trimDuration !== undefined ? { trimDuration } : {}),
|
|
706
|
-
volume,
|
|
707
|
-
}, this.audioClips.length + this.embeddedAudioClips.length);
|
|
708
|
-
if (element.tagName === "audio") {
|
|
709
|
-
this.audioClips.push(clip);
|
|
710
|
-
}
|
|
711
|
-
else if (!hasAttribute(element, "muted") && getAttribute(element, "data-fy-audio") !== "off") {
|
|
712
|
-
this.embeddedAudioClips.push(clip);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
catch (error) {
|
|
716
|
-
this.error(errorCode(error, "MEDIA_INVALID"), errorMessage(error), this.elementDetails(element));
|
|
483
|
+
this.warning("ANIMATION_NOT_FOUND", `${attribute} has no visual animation target on <${tag}>.`, this.attributeDetails(entry.element, attribute));
|
|
717
484
|
}
|
|
718
485
|
}
|
|
719
486
|
}
|
|
720
|
-
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
const resolved = resolveBindingPath({ props: this.props }, path);
|
|
726
|
-
if (!resolved.found) {
|
|
727
|
-
return null;
|
|
728
|
-
}
|
|
729
|
-
if (typeof resolved.value !== "string" || resolved.value.length === 0) {
|
|
730
|
-
this.error("MEDIA_INVALID", `Media binding "${path}" must resolve to a non-empty local path.`, { value: path });
|
|
731
|
-
return null;
|
|
732
|
-
}
|
|
733
|
-
return resolved.value;
|
|
734
|
-
}
|
|
735
|
-
readMediaInteger(element, attribute, fallback) {
|
|
736
|
-
const raw = getAttribute(element, attribute);
|
|
737
|
-
if (raw === undefined) {
|
|
738
|
-
return fallback;
|
|
487
|
+
async validateMediaFiles(audio, embedded) {
|
|
488
|
+
for (const declaration of audio) {
|
|
489
|
+
if (await this.mediaFileExists(declaration)) {
|
|
490
|
+
this.audioClips.push(declaration.clip);
|
|
491
|
+
}
|
|
739
492
|
}
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
const raw = getAttribute(element, attribute);
|
|
745
|
-
if (raw === undefined) {
|
|
746
|
-
return fallback;
|
|
493
|
+
for (const declaration of embedded) {
|
|
494
|
+
if (await this.mediaFileExists(declaration)) {
|
|
495
|
+
this.embeddedAudioClips.push(declaration.clip);
|
|
496
|
+
}
|
|
747
497
|
}
|
|
748
|
-
const value = Number(raw);
|
|
749
|
-
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
750
498
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
return undefined;
|
|
499
|
+
async mediaFileExists(declaration) {
|
|
500
|
+
if (!isElement(declaration.element)) {
|
|
501
|
+
return false;
|
|
755
502
|
}
|
|
756
|
-
const
|
|
757
|
-
return
|
|
503
|
+
const file = await this.resolveLocalFile(declaration.clip.src, this.projectRoot, "MEDIA_INVALID", "Media", this.elementDetails(declaration.element));
|
|
504
|
+
return file !== null;
|
|
758
505
|
}
|
|
759
|
-
async
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
const literal = getAttribute(element, "src");
|
|
763
|
-
const bound = getAttribute(element, "data-fy-attr-src");
|
|
764
|
-
if (literal !== undefined && bound !== undefined) {
|
|
765
|
-
this.error("TRANSCRIPT_INVALID", "Use either src or data-fy-attr-src on <fy-transcript>, not both.", this.elementDetails(element));
|
|
766
|
-
continue;
|
|
767
|
-
}
|
|
768
|
-
if (literal === undefined && bound === undefined) {
|
|
769
|
-
this.error("TRANSCRIPT_INVALID", "<fy-transcript> requires a src attribute or data-fy-attr-src binding.", this.elementDetails(element));
|
|
506
|
+
async validateTranscriptFiles(declarations) {
|
|
507
|
+
for (const declaration of declarations) {
|
|
508
|
+
if (!isElement(declaration.element)) {
|
|
770
509
|
continue;
|
|
771
510
|
}
|
|
772
|
-
|
|
773
|
-
if (literal !== undefined) {
|
|
774
|
-
source = literal;
|
|
775
|
-
}
|
|
776
|
-
else if (bound !== undefined) {
|
|
777
|
-
const resolved = this.resolveMediaBinding(bound);
|
|
778
|
-
if (resolved === null) {
|
|
779
|
-
continue;
|
|
780
|
-
}
|
|
781
|
-
source = resolved;
|
|
782
|
-
}
|
|
783
|
-
if (source === undefined) {
|
|
784
|
-
continue;
|
|
785
|
-
}
|
|
786
|
-
const file = await this.resolveLocalFile(source, this.projectRoot, "TRANSCRIPT_INVALID", "Transcript", this.elementDetails(element));
|
|
511
|
+
const file = await this.resolveLocalFile(declaration.clip.src, this.projectRoot, "TRANSCRIPT_INVALID", "Transcript", this.elementDetails(declaration.element));
|
|
787
512
|
if (!file) {
|
|
788
513
|
continue;
|
|
789
514
|
}
|
|
790
|
-
|
|
515
|
+
this.transcriptClips.push(declaration.clip);
|
|
791
516
|
try {
|
|
792
|
-
const clip = normalizeTranscriptClip({ src: file.relative, words }, this.transcriptClips.length);
|
|
793
|
-
this.transcriptClips.push(clip);
|
|
794
517
|
const content = await readFile(file.absolute, "utf8");
|
|
795
518
|
const parsed = JSON.parse(content);
|
|
796
|
-
parseTranscript(parsed, { requireWords: words });
|
|
519
|
+
parseTranscript(parsed, { requireWords: declaration.clip.words });
|
|
797
520
|
}
|
|
798
521
|
catch (error) {
|
|
799
522
|
this.error(errorCode(error, "TRANSCRIPT_INVALID"), errorMessage(error), {
|
|
800
523
|
path: file.relative,
|
|
801
|
-
...this.elementDetails(element),
|
|
524
|
+
...this.elementDetails(declaration.element),
|
|
802
525
|
});
|
|
803
526
|
}
|
|
804
527
|
}
|
|
@@ -907,12 +630,6 @@ export async function validateHtmlComposition(options) {
|
|
|
907
630
|
const validator = new HtmlCompositionValidator({ ...options, projectRoot }, document);
|
|
908
631
|
return validator.run();
|
|
909
632
|
}
|
|
910
|
-
function isRemote(value) {
|
|
911
|
-
return SCHEME.test(value) || value.startsWith("//");
|
|
912
|
-
}
|
|
913
|
-
function isLocalRelative(value) {
|
|
914
|
-
return !SCHEME.test(value) && !value.startsWith("/") && !value.includes("\\") && !value.split("/").includes("..");
|
|
915
|
-
}
|
|
916
633
|
function describeValue(value) {
|
|
917
634
|
if (value === null) {
|
|
918
635
|
return "null";
|