@editframe/elements 0.19.2-beta.0 → 0.20.0-beta.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 (96) hide show
  1. package/dist/elements/ContextProxiesController.d.ts +40 -0
  2. package/dist/elements/ContextProxiesController.js +69 -0
  3. package/dist/elements/EFCaptions.d.ts +45 -6
  4. package/dist/elements/EFCaptions.js +220 -26
  5. package/dist/elements/EFImage.js +4 -1
  6. package/dist/elements/EFMedia/AssetIdMediaEngine.d.ts +2 -1
  7. package/dist/elements/EFMedia/AssetIdMediaEngine.js +9 -0
  8. package/dist/elements/EFMedia/AssetMediaEngine.d.ts +1 -0
  9. package/dist/elements/EFMedia/AssetMediaEngine.js +11 -0
  10. package/dist/elements/EFMedia/BaseMediaEngine.d.ts +13 -1
  11. package/dist/elements/EFMedia/BaseMediaEngine.js +9 -0
  12. package/dist/elements/EFMedia/JitMediaEngine.d.ts +7 -1
  13. package/dist/elements/EFMedia/JitMediaEngine.js +24 -0
  14. package/dist/elements/EFMedia/shared/GlobalInputCache.d.ts +39 -0
  15. package/dist/elements/EFMedia/shared/GlobalInputCache.js +57 -0
  16. package/dist/elements/EFMedia/shared/ThumbnailExtractor.d.ts +27 -0
  17. package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +106 -0
  18. package/dist/elements/EFMedia.js +25 -1
  19. package/dist/elements/EFSurface.browsertest.d.ts +0 -0
  20. package/dist/elements/EFSurface.d.ts +30 -0
  21. package/dist/elements/EFSurface.js +96 -0
  22. package/dist/elements/EFTemporal.js +7 -6
  23. package/dist/elements/EFThumbnailStrip.browsertest.d.ts +0 -0
  24. package/dist/elements/EFThumbnailStrip.d.ts +86 -0
  25. package/dist/elements/EFThumbnailStrip.js +490 -0
  26. package/dist/elements/EFThumbnailStrip.media-engine.browsertest.d.ts +0 -0
  27. package/dist/elements/EFTimegroup.d.ts +7 -7
  28. package/dist/elements/EFTimegroup.js +59 -16
  29. package/dist/elements/updateAnimations.browsertest.d.ts +13 -0
  30. package/dist/elements/updateAnimations.d.ts +5 -0
  31. package/dist/elements/updateAnimations.js +37 -13
  32. package/dist/getRenderInfo.js +1 -1
  33. package/dist/gui/ContextMixin.js +27 -14
  34. package/dist/gui/EFControls.browsertest.d.ts +0 -0
  35. package/dist/gui/EFControls.d.ts +38 -0
  36. package/dist/gui/EFControls.js +51 -0
  37. package/dist/gui/EFFilmstrip.d.ts +40 -1
  38. package/dist/gui/EFFilmstrip.js +240 -3
  39. package/dist/gui/EFPreview.js +2 -1
  40. package/dist/gui/EFScrubber.d.ts +6 -5
  41. package/dist/gui/EFScrubber.js +31 -21
  42. package/dist/gui/EFTimeDisplay.browsertest.d.ts +0 -0
  43. package/dist/gui/EFTimeDisplay.d.ts +2 -6
  44. package/dist/gui/EFTimeDisplay.js +13 -23
  45. package/dist/gui/TWMixin.js +1 -1
  46. package/dist/gui/currentTimeContext.d.ts +3 -0
  47. package/dist/gui/currentTimeContext.js +3 -0
  48. package/dist/gui/durationContext.d.ts +3 -0
  49. package/dist/gui/durationContext.js +3 -0
  50. package/dist/index.d.ts +3 -0
  51. package/dist/index.js +4 -1
  52. package/dist/style.css +1 -1
  53. package/dist/transcoding/types/index.d.ts +11 -0
  54. package/dist/utils/LRUCache.d.ts +46 -0
  55. package/dist/utils/LRUCache.js +382 -1
  56. package/dist/utils/LRUCache.test.d.ts +1 -0
  57. package/package.json +2 -2
  58. package/src/elements/ContextProxiesController.ts +123 -0
  59. package/src/elements/EFCaptions.browsertest.ts +1820 -0
  60. package/src/elements/EFCaptions.ts +373 -36
  61. package/src/elements/EFImage.ts +4 -1
  62. package/src/elements/EFMedia/AssetIdMediaEngine.ts +30 -1
  63. package/src/elements/EFMedia/AssetMediaEngine.ts +33 -0
  64. package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +3 -8
  65. package/src/elements/EFMedia/BaseMediaEngine.ts +35 -0
  66. package/src/elements/EFMedia/JitMediaEngine.ts +48 -0
  67. package/src/elements/EFMedia/shared/GlobalInputCache.ts +77 -0
  68. package/src/elements/EFMedia/shared/ThumbnailExtractor.ts +227 -0
  69. package/src/elements/EFMedia.ts +38 -1
  70. package/src/elements/EFSurface.browsertest.ts +155 -0
  71. package/src/elements/EFSurface.ts +141 -0
  72. package/src/elements/EFTemporal.ts +14 -8
  73. package/src/elements/EFThumbnailStrip.browsertest.ts +591 -0
  74. package/src/elements/EFThumbnailStrip.media-engine.browsertest.ts +713 -0
  75. package/src/elements/EFThumbnailStrip.ts +905 -0
  76. package/src/elements/EFTimegroup.browsertest.ts +56 -7
  77. package/src/elements/EFTimegroup.ts +88 -18
  78. package/src/elements/updateAnimations.browsertest.ts +361 -12
  79. package/src/elements/updateAnimations.ts +68 -19
  80. package/src/gui/ContextMixin.browsertest.ts +0 -25
  81. package/src/gui/ContextMixin.ts +44 -20
  82. package/src/gui/EFControls.browsertest.ts +175 -0
  83. package/src/gui/EFControls.ts +84 -0
  84. package/src/gui/EFFilmstrip.ts +323 -4
  85. package/src/gui/EFPreview.ts +2 -1
  86. package/src/gui/EFScrubber.ts +29 -25
  87. package/src/gui/EFTimeDisplay.browsertest.ts +237 -0
  88. package/src/gui/EFTimeDisplay.ts +12 -40
  89. package/src/gui/currentTimeContext.ts +5 -0
  90. package/src/gui/durationContext.ts +3 -0
  91. package/src/transcoding/types/index.ts +13 -0
  92. package/src/utils/LRUCache.test.ts +272 -0
  93. package/src/utils/LRUCache.ts +543 -0
  94. package/types.json +1 -1
  95. package/dist/transcoding/cache/CacheManager.d.ts +0 -73
  96. package/src/transcoding/cache/CacheManager.ts +0 -208
@@ -1,4 +1,4 @@
1
- import { Task } from "@lit/task";
1
+ import { Task, TaskStatus } from "@lit/task";
2
2
  import { css, html, LitElement, type PropertyValueMap } from "lit";
3
3
  import { customElement, property } from "lit/decorators.js";
4
4
  import type { GetISOBMFFFileTranscriptionResult } from "../../../api/src/index.js";
@@ -6,23 +6,24 @@ import { EF_INTERACTIVE } from "../EF_INTERACTIVE.js";
6
6
  import { CrossUpdateController } from "./CrossUpdateController.js";
7
7
  import { EFAudio } from "./EFAudio.js";
8
8
  import { EFSourceMixin } from "./EFSourceMixin.js";
9
- import { EFTemporal } from "./EFTemporal.js";
9
+ import { EFTemporal, flushStartTimeMsCache } from "./EFTemporal.js";
10
+ import { flushSequenceDurationCache } from "./EFTimegroup.js";
10
11
  import { EFVideo } from "./EFVideo.js";
11
12
  import { FetchMixin } from "./FetchMixin.js";
12
13
 
13
- interface WordSegment {
14
+ export interface WordSegment {
14
15
  text: string;
15
16
  start: number;
16
17
  end: number;
17
18
  }
18
19
 
19
- interface Segment {
20
+ export interface Segment {
20
21
  start: number;
21
22
  end: number;
22
23
  text: string;
23
24
  }
24
25
 
25
- interface Caption {
26
+ export interface Caption {
26
27
  segments: Segment[];
27
28
  word_segments: WordSegment[];
28
29
  }
@@ -36,6 +37,7 @@ export class EFCaptionsActiveWord extends EFTemporal(LitElement) {
36
37
  :host {
37
38
  display: inline-block;
38
39
  white-space: pre;
40
+ transform-origin: center;
39
41
  }
40
42
  :host([hidden]) {
41
43
  display: none;
@@ -49,6 +51,12 @@ export class EFCaptionsActiveWord extends EFTemporal(LitElement) {
49
51
  return undefined;
50
52
  }
51
53
  this.hidden = false;
54
+
55
+ // Set deterministic --ef-word-seed value based on word index
56
+ const seed = (this.wordIndex * 9007) % 233; // Prime numbers for better distribution
57
+ const seedValue = seed / 233; // Normalize to 0-1 range
58
+ this.style.setProperty("--ef-word-seed", seedValue.toString());
59
+
52
60
  return html` ${this.wordText.trim()} `;
53
61
  }
54
62
 
@@ -61,11 +69,23 @@ export class EFCaptionsActiveWord extends EFTemporal(LitElement) {
61
69
  @property({ type: String, attribute: false })
62
70
  wordText = "";
63
71
 
72
+ @property({ type: Number, attribute: false })
73
+ wordIndex = 0;
74
+
64
75
  @property({ type: Boolean, reflect: true })
65
76
  hidden = false;
66
77
 
67
78
  get startTimeMs() {
68
- return this.wordStartMs || 0;
79
+ // Get parent captions element's absolute start time, then add our local offset
80
+ const parentCaptions = this.closest("ef-captions") as EFCaptions;
81
+ const parentStartTime = parentCaptions?.startTimeMs || 0;
82
+ return parentStartTime + (this.wordStartMs || 0);
83
+ }
84
+
85
+ get endTimeMs() {
86
+ const parentCaptions = this.closest("ef-captions") as EFCaptions;
87
+ const parentStartTime = parentCaptions?.startTimeMs || 0;
88
+ return parentStartTime + (this.wordEndMs || 0);
69
89
  }
70
90
 
71
91
  get durationMs(): number {
@@ -108,7 +128,16 @@ export class EFCaptionsSegment extends EFTemporal(LitElement) {
108
128
  hidden = false;
109
129
 
110
130
  get startTimeMs() {
111
- return this.segmentStartMs || 0;
131
+ // Get parent captions element's absolute start time, then add our local offset
132
+ const parentCaptions = this.closest("ef-captions") as EFCaptions;
133
+ const parentStartTime = parentCaptions?.startTimeMs || 0;
134
+ return parentStartTime + (this.segmentStartMs || 0);
135
+ }
136
+
137
+ get endTimeMs() {
138
+ const parentCaptions = this.closest("ef-captions") as EFCaptions;
139
+ const parentStartTime = parentCaptions?.startTimeMs || 0;
140
+ return parentStartTime + (this.segmentEndMs || 0);
112
141
  }
113
142
 
114
143
  get durationMs(): number {
@@ -152,7 +181,16 @@ export class EFCaptionsBeforeActiveWord extends EFCaptionsSegment {
152
181
  segmentEndMs = 0;
153
182
 
154
183
  get startTimeMs() {
155
- return this.segmentStartMs || 0;
184
+ // Get parent captions element's absolute start time, then add our local offset
185
+ const parentCaptions = this.closest("ef-captions") as EFCaptions;
186
+ const parentStartTime = parentCaptions?.startTimeMs || 0;
187
+ return parentStartTime + (this.segmentStartMs || 0);
188
+ }
189
+
190
+ get endTimeMs() {
191
+ const parentCaptions = this.closest("ef-captions") as EFCaptions;
192
+ const parentStartTime = parentCaptions?.startTimeMs || 0;
193
+ return parentStartTime + (this.segmentEndMs || 0);
156
194
  }
157
195
 
158
196
  get durationMs(): number {
@@ -196,7 +234,16 @@ export class EFCaptionsAfterActiveWord extends EFCaptionsSegment {
196
234
  segmentEndMs = 0;
197
235
 
198
236
  get startTimeMs() {
199
- return this.segmentStartMs || 0;
237
+ // Get parent captions element's absolute start time, then add our local offset
238
+ const parentCaptions = this.closest("ef-captions") as EFCaptions;
239
+ const parentStartTime = parentCaptions?.startTimeMs || 0;
240
+ return parentStartTime + (this.segmentStartMs || 0);
241
+ }
242
+
243
+ get endTimeMs() {
244
+ const parentCaptions = this.closest("ef-captions") as EFCaptions;
245
+ const parentStartTime = parentCaptions?.startTimeMs || 0;
246
+ return parentStartTime + (this.segmentEndMs || 0);
200
247
  }
201
248
 
202
249
  get durationMs(): number {
@@ -212,15 +259,23 @@ export class EFCaptions extends EFSourceMixin(
212
259
  static styles = [
213
260
  css`
214
261
  :host {
215
- display: flex;
216
- flex-wrap: wrap;
217
- align-items: baseline;
262
+ display: inline-flex;
218
263
  width: fit-content;
264
+ align-items: baseline;
219
265
  }
220
266
  ::slotted(*) {
221
267
  margin: 0;
222
268
  padding: 0;
223
269
  }
270
+ ::slotted(ef-captions-active-word) {
271
+ min-width: 0.5ch; /* Maintain minimum width when empty */
272
+ min-height: 1em; /* Maintain height for baseline alignment */
273
+ }
274
+ ::slotted(ef-captions-active-word[hidden]) {
275
+ opacity: 0; /* Hide when empty but maintain layout */
276
+ min-width: 0.5ch;
277
+ min-height: 1em;
278
+ }
224
279
  `,
225
280
  ];
226
281
 
@@ -240,6 +295,27 @@ export class EFCaptions extends EFSourceMixin(
240
295
  @property({ attribute: "word-style" })
241
296
  wordStyle = "";
242
297
 
298
+ /**
299
+ * URL or path to a JSON file containing custom captions data.
300
+ * The JSON should conform to the Caption interface with 'segments' and 'word_segments' arrays.
301
+ */
302
+ @property({ type: String, attribute: "captions-src", reflect: true })
303
+ captionsSrc = "";
304
+
305
+ /**
306
+ * Direct captions data object. Takes priority over captions-src and captions-script.
307
+ * Should conform to the Caption interface with 'segments' and 'word_segments' arrays.
308
+ */
309
+ @property({ type: Object, attribute: false })
310
+ captionsData: Caption | null = null;
311
+
312
+ /**
313
+ * ID of a <script> element containing JSON captions data.
314
+ * The script's textContent should be valid JSON conforming to the Caption interface.
315
+ */
316
+ @property({ type: String, attribute: "captions-script", reflect: true })
317
+ captionsScript = "";
318
+
243
319
  activeWordContainers = this.getElementsByTagName("ef-captions-active-word");
244
320
  segmentContainers = this.getElementsByTagName("ef-captions-segment");
245
321
  beforeActiveWordContainers = this.getElementsByTagName(
@@ -254,6 +330,9 @@ export class EFCaptions extends EFSourceMixin(
254
330
  }
255
331
 
256
332
  transcriptionsPath() {
333
+ if (!this.targetElement) {
334
+ return null;
335
+ }
257
336
  if (this.targetElement.assetId) {
258
337
  return `${this.apiHost}/api/v1/isobmff_files/${this.targetElement.assetId}/transcription`;
259
338
  }
@@ -261,6 +340,9 @@ export class EFCaptions extends EFSourceMixin(
261
340
  }
262
341
 
263
342
  captionsPath() {
343
+ if (!this.targetElement) {
344
+ return null;
345
+ }
264
346
  if (this.targetElement.assetId) {
265
347
  return `${this.apiHost}/api/v1/caption_files/${this.targetElement.assetId}`;
266
348
  }
@@ -272,6 +354,9 @@ export class EFCaptions extends EFSourceMixin(
272
354
  autoRun: false,
273
355
  args: () => [this.target, this.fetch] as const,
274
356
  task: async ([_target, fetch], { signal }) => {
357
+ if (!this.targetElement) {
358
+ return null;
359
+ }
275
360
  const md5Path = `/@ef-asset/${this.targetElement.src ?? ""}`;
276
361
  const response = await fetch(md5Path, { method: "HEAD", signal });
277
362
  return response.headers.get("etag") ?? undefined;
@@ -280,9 +365,15 @@ export class EFCaptions extends EFSourceMixin(
280
365
 
281
366
  private transcriptionDataTask = new Task(this, {
282
367
  autoRun: EF_INTERACTIVE,
283
- args: () => [this.transcriptionsPath(), this.fetch] as const,
284
- task: async ([transcriptionsPath, fetch], { signal }) => {
285
- if (!transcriptionsPath) {
368
+ args: () =>
369
+ [
370
+ this.transcriptionsPath(),
371
+ this.fetch,
372
+ this.hasCustomCaptionsData,
373
+ ] as const,
374
+ task: async ([transcriptionsPath, fetch, hasCustomData], { signal }) => {
375
+ // Skip transcription if we have custom captions data
376
+ if (hasCustomData || !transcriptionsPath) {
286
377
  return null;
287
378
  }
288
379
  const response = await fetch(transcriptionsPath, { signal });
@@ -312,6 +403,53 @@ export class EFCaptions extends EFSourceMixin(
312
403
  },
313
404
  });
314
405
 
406
+ private customCaptionsDataTask = new Task(this, {
407
+ autoRun: EF_INTERACTIVE,
408
+ args: () =>
409
+ [
410
+ this.captionsSrc,
411
+ this.captionsData,
412
+ this.captionsScript,
413
+ this.fetch,
414
+ ] as const,
415
+ task: async (
416
+ [captionsSrc, captionsData, captionsScript, fetch],
417
+ { signal },
418
+ ) => {
419
+ // Priority: direct data > script reference > URL source
420
+ if (captionsData) {
421
+ return captionsData;
422
+ }
423
+
424
+ if (captionsScript) {
425
+ const scriptElement = document.getElementById(captionsScript);
426
+ if (scriptElement?.textContent) {
427
+ try {
428
+ return JSON.parse(scriptElement.textContent) as Caption;
429
+ } catch (error) {
430
+ console.error(
431
+ `Failed to parse captions from script #${captionsScript}:`,
432
+ error,
433
+ );
434
+ return null;
435
+ }
436
+ }
437
+ }
438
+
439
+ if (captionsSrc) {
440
+ try {
441
+ const response = await fetch(captionsSrc, { signal });
442
+ return (await response.json()) as Caption;
443
+ } catch (error) {
444
+ console.error(`Failed to load captions from ${captionsSrc}:`, error);
445
+ return null;
446
+ }
447
+ }
448
+
449
+ return null;
450
+ },
451
+ });
452
+
315
453
  private transcriptionFragmentDataTask = new Task(this, {
316
454
  autoRun: EF_INTERACTIVE,
317
455
  args: () =>
@@ -338,46 +476,104 @@ export class EFCaptions extends EFSourceMixin(
338
476
  },
339
477
  });
340
478
 
479
+ unifiedCaptionsDataTask = new Task(this, {
480
+ autoRun: EF_INTERACTIVE,
481
+ args: () =>
482
+ [
483
+ this.customCaptionsDataTask.value,
484
+ this.transcriptionFragmentDataTask.value,
485
+ ] as const,
486
+ task: async ([_customData, _transcriptionData]) => {
487
+ if (this.customCaptionsDataTask.status === TaskStatus.PENDING) {
488
+ await this.customCaptionsDataTask.taskComplete;
489
+ }
490
+ if (this.transcriptionFragmentDataTask.status === TaskStatus.PENDING) {
491
+ await this.transcriptionFragmentDataTask.taskComplete;
492
+ }
493
+ return (
494
+ this.customCaptionsDataTask.value ||
495
+ this.transcriptionFragmentDataTask.value
496
+ );
497
+ },
498
+ });
499
+
341
500
  frameTask = new Task(this, {
342
501
  autoRun: EF_INTERACTIVE,
343
- args: () => [this.transcriptionFragmentDataTask.status],
502
+ args: () => [this.unifiedCaptionsDataTask.status],
344
503
  task: async () => {
345
- await this.transcriptionFragmentDataTask.taskComplete;
504
+ await this.unifiedCaptionsDataTask.taskComplete;
346
505
  },
347
506
  });
348
507
 
349
508
  connectedCallback() {
350
509
  super.connectedCallback();
351
- if (this.targetElement) {
352
- new CrossUpdateController(this.targetElement, this);
510
+
511
+ // Try to get target element safely
512
+ const target = this.targetSelector
513
+ ? document.getElementById(this.targetSelector)
514
+ : null;
515
+ if (target && (target instanceof EFAudio || target instanceof EFVideo)) {
516
+ new CrossUpdateController(target, this);
517
+ }
518
+ // For standalone captions with custom data, ensure proper timeline sync
519
+ else if (this.hasCustomCaptionsData && this.rootTimegroup) {
520
+ new CrossUpdateController(this.rootTimegroup, this);
353
521
  }
354
522
  }
355
523
 
356
524
  protected updated(
357
- _changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>,
525
+ changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>,
358
526
  ): void {
359
527
  this.updateTextContainers();
528
+
529
+ // Force duration recalculation when custom captions data changes
530
+ if (
531
+ changedProperties.has("captionsData") ||
532
+ changedProperties.has("captionsSrc") ||
533
+ changedProperties.has("captionsScript")
534
+ ) {
535
+ this.requestUpdate("intrinsicDurationMs");
536
+
537
+ // Flush sequence duration cache and notify parent timegroups that child duration has changed
538
+ flushSequenceDurationCache();
539
+ flushStartTimeMsCache();
540
+
541
+ // Notify parent timegroup to recalculate its duration
542
+ if (this.parentTimegroup) {
543
+ this.parentTimegroup.requestUpdate("durationMs");
544
+ this.parentTimegroup.requestUpdate("currentTime");
545
+ }
546
+ }
547
+
548
+ // Update captions when timeline position changes
549
+ if (changedProperties.has("ownCurrentTimeMs")) {
550
+ this.updateTextContainers();
551
+ }
360
552
  }
361
553
 
362
554
  updateTextContainers() {
363
- const transcriptionFragment = this.transcriptionFragmentDataTask
364
- .value as Caption;
365
- if (!transcriptionFragment) {
555
+ const captionsData = this.unifiedCaptionsDataTask.value as Caption;
556
+ if (!captionsData) {
366
557
  return;
367
558
  }
368
559
 
369
- const currentTimeMs = this.targetElement.currentSourceTimeMs;
560
+ // Get current time from target element or parent timegroup
561
+ const currentTimeMs = this.targetElement
562
+ ? this.targetElement.currentSourceTimeMs
563
+ : this.ownCurrentTimeMs;
370
564
  const currentTimeSec = currentTimeMs / 1000;
371
565
 
372
566
  // Find the current word from word_segments
373
- const currentWord = transcriptionFragment.word_segments.find(
374
- (word) => currentTimeSec >= word.start && currentTimeSec <= word.end,
567
+ // Use exclusive end boundary to prevent overlap at exact boundaries
568
+ const currentWord = captionsData.word_segments.find(
569
+ (word) => currentTimeSec >= word.start && currentTimeSec < word.end,
375
570
  );
376
571
 
377
572
  // Find the current segment
378
- const currentSegment = transcriptionFragment.segments.find(
573
+ // Use exclusive end boundary to prevent overlap at exact boundaries
574
+ const currentSegment = captionsData.segments.find(
379
575
  (segment) =>
380
- currentTimeSec >= segment.start && currentTimeSec <= segment.end,
576
+ currentTimeSec >= segment.start && currentTimeSec < segment.end,
381
577
  );
382
578
 
383
579
  for (const wordContainer of this.activeWordContainers) {
@@ -385,6 +581,22 @@ export class EFCaptions extends EFSourceMixin(
385
581
  wordContainer.wordText = currentWord.text;
386
582
  wordContainer.wordStartMs = currentWord.start * 1000;
387
583
  wordContainer.wordEndMs = currentWord.end * 1000;
584
+ // Set word index for deterministic animation variation
585
+ const wordIndex = captionsData.word_segments.findIndex(
586
+ (w) =>
587
+ w.start === currentWord.start &&
588
+ w.end === currentWord.end &&
589
+ w.text === currentWord.text,
590
+ );
591
+ wordContainer.wordIndex = wordIndex >= 0 ? wordIndex : 0;
592
+ // Force re-render to update hidden property
593
+ wordContainer.requestUpdate();
594
+ } else {
595
+ // No active word - maintain layout with invisible placeholder
596
+ wordContainer.wordText = ""; // Empty when no active word
597
+ wordContainer.wordStartMs = 0;
598
+ wordContainer.wordEndMs = 0;
599
+ wordContainer.requestUpdate();
388
600
  }
389
601
  }
390
602
 
@@ -393,13 +605,18 @@ export class EFCaptions extends EFSourceMixin(
393
605
  segmentContainer.segmentText = currentSegment.text;
394
606
  segmentContainer.segmentStartMs = currentSegment.start * 1000;
395
607
  segmentContainer.segmentEndMs = currentSegment.end * 1000;
608
+ } else {
609
+ // No active segment - clear the container
610
+ segmentContainer.segmentText = "";
611
+ segmentContainer.segmentStartMs = 0;
612
+ segmentContainer.segmentEndMs = 0;
396
613
  }
397
614
  }
398
615
 
399
- // Only process context if we have both a current word and segment
616
+ // Process context for both word and segment cases
400
617
  if (currentWord && currentSegment) {
401
618
  // Find all word segments that fall within the current segment's time range
402
- const segmentWords = transcriptionFragment.word_segments.filter(
619
+ const segmentWords = captionsData.word_segments.filter(
403
620
  (word) =>
404
621
  word.start >= currentSegment.start && word.end <= currentSegment.end,
405
622
  );
@@ -423,19 +640,76 @@ export class EFCaptions extends EFSourceMixin(
423
640
  .map((w) => w.text.trim())
424
641
  .join(" ");
425
642
 
426
- // Update before containers
643
+ // Update before containers - should be visible at the same time as active word
427
644
  for (const container of this.beforeActiveWordContainers) {
428
645
  container.segmentText = beforeWords;
429
- container.segmentStartMs = currentSegment.start * 1000;
430
- container.segmentEndMs = currentWord.start * 1000;
646
+ container.segmentStartMs = currentWord.start * 1000;
647
+ container.segmentEndMs = currentWord.end * 1000;
431
648
  }
432
649
 
433
- // Update after containers
650
+ // Update after containers - should be visible at the same time as active word
434
651
  for (const container of this.afterActiveWordContainers) {
435
652
  container.segmentText = afterWords;
436
- container.segmentStartMs = currentWord.end * 1000;
653
+ container.segmentStartMs = currentWord.start * 1000;
654
+ container.segmentEndMs = currentWord.end * 1000;
655
+ }
656
+ }
657
+ } else if (currentSegment) {
658
+ // No active word but we have an active segment
659
+ const segmentWords = captionsData.word_segments.filter(
660
+ (word) =>
661
+ word.start >= currentSegment.start && word.end <= currentSegment.end,
662
+ );
663
+
664
+ // Check if we're before the first word or after the last word
665
+ const firstWord = segmentWords[0];
666
+ const isBeforeFirstWord = firstWord && currentTimeSec < firstWord.start;
667
+
668
+ if (isBeforeFirstWord) {
669
+ // Before first word starts - show all words in "after" container (they're all upcoming)
670
+ const allWords = segmentWords.map((w) => w.text.trim()).join(" ");
671
+
672
+ for (const container of this.beforeActiveWordContainers) {
673
+ container.segmentText = ""; // Nothing before yet
674
+ container.segmentStartMs = currentSegment.start * 1000;
675
+ container.segmentEndMs = currentSegment.end * 1000;
676
+ }
677
+
678
+ for (const container of this.afterActiveWordContainers) {
679
+ container.segmentText = allWords; // All words are upcoming
680
+ container.segmentStartMs = currentSegment.start * 1000;
681
+ container.segmentEndMs = currentSegment.end * 1000;
682
+ }
683
+ } else {
684
+ // After last word ends - show all completed words in "before" container
685
+ const allCompletedWords = segmentWords
686
+ .map((w) => w.text.trim())
687
+ .join(" ");
688
+
689
+ for (const container of this.beforeActiveWordContainers) {
690
+ container.segmentText = allCompletedWords;
691
+ container.segmentStartMs = currentSegment.start * 1000;
437
692
  container.segmentEndMs = currentSegment.end * 1000;
438
693
  }
694
+
695
+ for (const container of this.afterActiveWordContainers) {
696
+ container.segmentText = "";
697
+ container.segmentStartMs = currentSegment.start * 1000;
698
+ container.segmentEndMs = currentSegment.end * 1000;
699
+ }
700
+ }
701
+ } else {
702
+ // No active segment or word - clear all context containers
703
+ for (const container of this.beforeActiveWordContainers) {
704
+ container.segmentText = "";
705
+ container.segmentStartMs = 0;
706
+ container.segmentEndMs = 0;
707
+ }
708
+
709
+ for (const container of this.afterActiveWordContainers) {
710
+ container.segmentText = "";
711
+ container.segmentStartMs = 0;
712
+ container.segmentEndMs = 0;
439
713
  }
440
714
  }
441
715
  }
@@ -445,7 +719,70 @@ export class EFCaptions extends EFSourceMixin(
445
719
  if (target instanceof EFAudio || target instanceof EFVideo) {
446
720
  return target;
447
721
  }
448
- throw new Error("Invalid target, must be an EFAudio or EFVideo element");
722
+ // When using custom captions data, a target is not required
723
+ if (this.hasCustomCaptionsData) {
724
+ return null;
725
+ }
726
+ return null;
727
+ }
728
+
729
+ get hasCustomCaptionsData(): boolean {
730
+ return !!(this.captionsData || this.captionsSrc || this.captionsScript);
731
+ }
732
+
733
+ // Follow the exact EFMedia pattern for safe duration integration
734
+ get intrinsicDurationMs(): number | undefined {
735
+ // Direct access to custom captions data - avoiding complex task dependencies
736
+ // Priority: direct data > script reference > external file
737
+ let captionsData: Caption | null = null;
738
+
739
+ if (this.captionsData) {
740
+ captionsData = this.captionsData;
741
+ } else if (this.captionsScript) {
742
+ const scriptElement = document.getElementById(this.captionsScript);
743
+ if (scriptElement?.textContent) {
744
+ try {
745
+ captionsData = JSON.parse(scriptElement.textContent) as Caption;
746
+ } catch {
747
+ // Invalid JSON, fall through to return undefined
748
+ }
749
+ }
750
+ } else if (this.customCaptionsDataTask.value) {
751
+ captionsData = this.customCaptionsDataTask.value as Caption;
752
+ }
753
+
754
+ if (!captionsData) {
755
+ return undefined;
756
+ }
757
+
758
+ if (
759
+ captionsData.segments.length === 0 &&
760
+ captionsData.word_segments.length === 0
761
+ ) {
762
+ return 0;
763
+ }
764
+
765
+ // Find the maximum end time from both segments and word_segments
766
+ const maxSegmentEnd =
767
+ captionsData.segments.length > 0
768
+ ? Math.max(...captionsData.segments.map((s) => s.end))
769
+ : 0;
770
+ const maxWordEnd =
771
+ captionsData.word_segments.length > 0
772
+ ? Math.max(...captionsData.word_segments.map((w) => w.end))
773
+ : 0;
774
+
775
+ return Math.max(maxSegmentEnd, maxWordEnd) * 1000; // Convert to milliseconds
776
+ }
777
+
778
+ // Follow the exact EFMedia pattern for safe duration integration
779
+ get hasOwnDuration(): boolean {
780
+ // Simple check - if we have any form of custom captions data, we have our own duration
781
+ return !!(
782
+ this.captionsData ||
783
+ this.captionsScript ||
784
+ this.customCaptionsDataTask.value
785
+ );
449
786
  }
450
787
  }
451
788
 
@@ -22,7 +22,10 @@ export class EFImage extends EFTemporal(
22
22
  justify-content: center;
23
23
  }
24
24
  canvas, img {
25
- all: inherit;
25
+ position: static;
26
+ all: initial;
27
+ width: 100%;
28
+ height: 100%;
26
29
  }
27
30
  `,
28
31
  ];
@@ -1,5 +1,9 @@
1
1
  import type { TrackFragmentIndex } from "@editframe/assets";
2
- import type { InitSegmentPaths, MediaEngine } from "../../transcoding/types";
2
+ import type {
3
+ InitSegmentPaths,
4
+ MediaEngine,
5
+ VideoRendition,
6
+ } from "../../transcoding/types";
3
7
  import type { UrlGenerator } from "../../transcoding/utils/UrlGenerator";
4
8
  import type { EFMedia } from "../EFMedia";
5
9
  import { AssetMediaEngine } from "./AssetMediaEngine";
@@ -76,4 +80,29 @@ export class AssetIdMediaEngine
76
80
  buildMediaSegmentUrl(trackId: number, _segmentId: number) {
77
81
  return `${this.apiHost}/api/v1/isobmff_tracks/${this.assetId}/${trackId}`;
78
82
  }
83
+
84
+ convertToSegmentRelativeTimestamps(
85
+ globalTimestamps: number[],
86
+ segmentId: number,
87
+ rendition: VideoRendition,
88
+ ): number[] {
89
+ if (!rendition.trackId) {
90
+ throw new Error("Track ID is required for asset metadata");
91
+ }
92
+ // For AssetMediaEngine, we need to calculate the actual segment start time
93
+ // using the precise segment boundaries from the track fragment index
94
+ const trackData = this.data[rendition.trackId];
95
+ if (!trackData) {
96
+ throw new Error("Track not found");
97
+ }
98
+ const segment = trackData.segments?.[segmentId];
99
+ if (!segment) {
100
+ throw new Error("Segment not found");
101
+ }
102
+ const segmentStartMs = (segment.cts / trackData.timescale) * 1000;
103
+
104
+ return globalTimestamps.map(
105
+ (globalMs) => (globalMs - segmentStartMs) / 1000,
106
+ );
107
+ }
79
108
  }
@@ -297,4 +297,37 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
297
297
  maxAudioBufferFetches: 1,
298
298
  };
299
299
  }
300
+
301
+ // AssetMediaEngine inherits the default extractThumbnails from BaseMediaEngine
302
+ // which provides a clear warning that this engine type is not supported
303
+
304
+ convertToSegmentRelativeTimestamps(
305
+ globalTimestamps: number[],
306
+ segmentId: number,
307
+ rendition: VideoRendition,
308
+ ): number[] {
309
+ {
310
+ // Asset: MediaBunny expects segment-relative timestamps in seconds
311
+ // This is because Asset segments are independent timeline fragments
312
+
313
+ if (!rendition.trackId) {
314
+ throw new Error("Track ID is required for asset metadata");
315
+ }
316
+ // For AssetMediaEngine, we need to calculate the actual segment start time
317
+ // using the precise segment boundaries from the track fragment index
318
+ const trackData = this.data[rendition.trackId];
319
+ if (!trackData) {
320
+ throw new Error("Track not found");
321
+ }
322
+ const segment = trackData.segments?.[segmentId];
323
+ if (!segment) {
324
+ throw new Error("Segment not found");
325
+ }
326
+ const segmentStartMs = (segment.cts / trackData.timescale) * 1000;
327
+
328
+ return globalTimestamps.map(
329
+ (globalMs) => (globalMs - segmentStartMs) / 1000,
330
+ );
331
+ }
332
+ }
300
333
  }