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.
Files changed (104) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/GLOSSARY.md +4 -0
  3. package/README.md +16 -9
  4. package/dist/cli.js +7 -11
  5. package/dist/cli.js.map +1 -1
  6. package/dist/composition/adapter.d.ts +30 -0
  7. package/dist/composition/adapter.d.ts.map +1 -0
  8. package/dist/composition/adapter.js +2 -0
  9. package/dist/composition/adapter.js.map +1 -0
  10. package/dist/composition/animation.d.ts +19 -0
  11. package/dist/composition/animation.d.ts.map +1 -0
  12. package/dist/composition/animation.js +55 -0
  13. package/dist/composition/animation.js.map +1 -0
  14. package/dist/composition/attributes.d.ts +7 -0
  15. package/dist/composition/attributes.d.ts.map +1 -0
  16. package/dist/composition/attributes.js +24 -0
  17. package/dist/composition/attributes.js.map +1 -0
  18. package/dist/composition/descriptor.d.ts +30 -0
  19. package/dist/composition/descriptor.d.ts.map +1 -0
  20. package/dist/composition/descriptor.js +188 -0
  21. package/dist/composition/descriptor.js.map +1 -0
  22. package/dist/composition/diagnostics.d.ts +12 -0
  23. package/dist/composition/diagnostics.d.ts.map +1 -0
  24. package/dist/composition/diagnostics.js +38 -0
  25. package/dist/composition/diagnostics.js.map +1 -0
  26. package/dist/composition/index.d.ts +12 -0
  27. package/dist/composition/index.d.ts.map +1 -0
  28. package/dist/composition/index.js +7 -0
  29. package/dist/composition/index.js.map +1 -0
  30. package/dist/composition/media.d.ts +27 -0
  31. package/dist/composition/media.d.ts.map +1 -0
  32. package/dist/composition/media.js +162 -0
  33. package/dist/composition/media.js.map +1 -0
  34. package/dist/composition/paths.d.ts +5 -0
  35. package/dist/composition/paths.d.ts.map +1 -0
  36. package/dist/composition/paths.js +16 -0
  37. package/dist/composition/paths.js.map +1 -0
  38. package/dist/composition/transcripts.d.ts +19 -0
  39. package/dist/composition/transcripts.d.ts.map +1 -0
  40. package/dist/composition/transcripts.js +57 -0
  41. package/dist/composition/transcripts.js.map +1 -0
  42. package/dist/composition/walk.d.ts +10 -0
  43. package/dist/composition/walk.d.ts.map +1 -0
  44. package/dist/composition/walk.js +32 -0
  45. package/dist/composition/walk.js.map +1 -0
  46. package/dist/contracts.d.ts +2 -0
  47. package/dist/contracts.d.ts.map +1 -1
  48. package/dist/contracts.js.map +1 -1
  49. package/dist/html/document-adapter.d.ts +5 -0
  50. package/dist/html/document-adapter.d.ts.map +1 -0
  51. package/dist/html/document-adapter.js +17 -0
  52. package/dist/html/document-adapter.js.map +1 -0
  53. package/dist/html/parse.d.ts +2 -6
  54. package/dist/html/parse.d.ts.map +1 -1
  55. package/dist/html/parse.js.map +1 -1
  56. package/dist/html/validate.d.ts.map +1 -1
  57. package/dist/html/validate.js +92 -375
  58. package/dist/html/validate.js.map +1 -1
  59. package/dist/preview/main.d.ts.map +1 -1
  60. package/dist/preview/main.js +4 -2
  61. package/dist/preview/main.js.map +1 -1
  62. package/dist/renderer/browser.d.ts.map +1 -1
  63. package/dist/renderer/browser.js +32 -5
  64. package/dist/renderer/browser.js.map +1 -1
  65. package/dist/renderer/index.d.ts.map +1 -1
  66. package/dist/renderer/index.js +18 -7
  67. package/dist/renderer/index.js.map +1 -1
  68. package/dist/runtime/document-adapter.d.ts +4 -0
  69. package/dist/runtime/document-adapter.d.ts.map +1 -0
  70. package/dist/runtime/document-adapter.js +19 -0
  71. package/dist/runtime/document-adapter.js.map +1 -0
  72. package/dist/runtime/host.d.ts +2 -0
  73. package/dist/runtime/host.d.ts.map +1 -1
  74. package/dist/runtime/host.js +78 -183
  75. package/dist/runtime/host.js.map +1 -1
  76. package/dist/runtime/media.d.ts.map +1 -1
  77. package/dist/runtime/media.js +19 -50
  78. package/dist/runtime/media.js.map +1 -1
  79. package/dist/scaffold.d.ts.map +1 -1
  80. package/dist/scaffold.js +10 -5
  81. package/dist/scaffold.js.map +1 -1
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/docs/adr/0006-seek-embedded-media-from-the-frame-clock.md +4 -0
  85. package/docs/adr/0008-share-the-composition-document-grammar.md +18 -0
  86. package/docs/adr/0009-composite-production-frames-in-software.md +37 -0
  87. package/docs/v0.1-design.md +5 -4
  88. package/examples/demo/README.md +1 -1
  89. package/examples/video/README.md +61 -0
  90. package/examples/video/assets/clip.mp4 +0 -0
  91. package/examples/video/assets/clip.webm +0 -0
  92. package/examples/video/compositions/video-mp4.html +362 -0
  93. package/examples/video/compositions/video.html +362 -0
  94. package/examples/video/props/video.json +8 -0
  95. package/examples/video/scripts/make-clip.sh +109 -0
  96. package/examples/video/video.config.js +6 -0
  97. package/examples/webgl/README.md +30 -0
  98. package/examples/webgl/compositions/webgl.controller.js +200 -0
  99. package/examples/webgl/compositions/webgl.html +215 -0
  100. package/examples/webgl/props/webgl.json +6 -0
  101. package/examples/webgl/video.config.js +5 -0
  102. package/package.json +4 -2
  103. package/skills/frameyard/SKILL.md +3 -2
  104. package/skills/frameyard/references/json-results.md +2 -2
@@ -1,11 +1,12 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { assertJsonSerializable, isPlainObject, mergeProps, normalizeAudioClip, normalizeTranscriptClip, } from "../contracts.js";
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 { findElements, getAttribute, getAttributeLocation, getDocumentDoctype, getElementByTagName, getElementLocation, getNodeLocation, getTextContent, hasAttribute, isDescendantOf, isElement, isTemplate, isTextNode, parseHtml, walk, } from "./parse.js";
8
- const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
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
- defaultsValid = true;
61
- controllerValid = true;
62
- rootValid = false;
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.findRoot();
78
- this.validateMetadata();
79
- await this.validateDefaultProps();
80
- this.validateControllerDeclaration();
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
- await this.validateMedia();
90
- await this.validateTranscripts();
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 info = this.metadata && this.root && this.rootValid && this.documentValid && this.defaultsValid && this.controllerValid
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: this.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
- findRoot() {
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(defaults, this.propsOverrides);
209
+ this.props = mergeProps(this.defaultPropsValue, this.propsOverrides);
210
+ this.overridesValid = true;
302
211
  }
303
212
  catch (error) {
304
- this.defaultsValid = false;
213
+ this.overridesValid = false;
305
214
  this.error("PROPS_INVALID", `Invalid props: ${errorMessage(error)}`);
306
- this.props = defaults;
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
- if (!this.controller || this.controller.kind !== "external" || this.controller.src === undefined) {
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
- ...(this.controller.line !== undefined ? { line: this.controller.line, column: this.controller.column } : {}),
228
+ ...(controller.line !== undefined ? { line: controller.line, column: controller.column } : {}),
364
229
  };
365
- const resolved = await this.resolveLocalFile(this.controller.src, this.compositionDir, "CONTROLLER_INVALID", "Controller", details);
230
+ const resolved = await this.resolveLocalFile(controller.rawSrc, this.compositionDir, "CONTROLLER_INVALID", "Controller", details);
366
231
  if (!resolved) {
367
- this.controllerValid = false;
232
+ this.controllerFileValid = false;
368
233
  return;
369
234
  }
370
- this.controller = { ...this.controller, src: resolved.relative };
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 === "data-fy-anim-start" || name === "data-fy-anim-duration") {
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: ${KNOWN_BINDING_ATTRIBUTES.join(", ")}.`, this.attributeDetails(element, name, { value: attribute.value }));
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
- validateAnimationDuration(element, name, value) {
614
- const frames = Number(value);
615
- const valid = /^\d+$/.test(value.trim()) &&
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
- if (literal === undefined && bound === undefined) {
672
- this.error("MEDIA_INVALID", `<${element.tagName}> requires a src attribute or data-fy-attr-src binding.`, this.elementDetails(element));
475
+ const tag = entry.element.tagName;
476
+ if (!NON_ANIMATED_ELEMENTS.has(tag)) {
673
477
  continue;
674
478
  }
675
- let source;
676
- if (literal !== undefined) {
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
- source = resolved;
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
- resolveMediaBinding(path) {
721
- const root = path.split(".")[0];
722
- if (root === "item" || root === "index") {
723
- return null;
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 value = Number(raw);
741
- return /^\d+$/.test(raw.trim()) && Number.isInteger(value) && value >= 0 ? value : undefined;
742
- }
743
- readMediaNumber(element, attribute, fallback) {
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
- readOptionalMediaNumber(element, attribute) {
752
- const raw = getAttribute(element, attribute);
753
- if (raw === undefined) {
754
- return undefined;
499
+ async mediaFileExists(declaration) {
500
+ if (!isElement(declaration.element)) {
501
+ return false;
755
502
  }
756
- const value = Number(raw);
757
- return Number.isFinite(value) && value > 0 ? value : undefined;
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 validateTranscripts() {
760
- const transcripts = findElements(this.document, (element) => element.tagName === "fy-transcript");
761
- for (const element of transcripts) {
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
- let source;
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
- const words = hasAttribute(element, "words");
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";