@zync/zync-screnplay-player 0.1.251 → 0.1.253

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 (32) hide show
  1. package/dist/bundle.js +1 -1
  2. package/dist/bundle.js.LICENSE.txt +24 -0
  3. package/dist/screenplay/RemotionRenderer/RemotionRenderer.js +51 -42
  4. package/dist/screenplay/RemotionRenderer/assets/wordObjects/LICENSE +21 -0
  5. package/dist/screenplay/RemotionRenderer/assets/wordObjects/emojiCatalog.json +1 -0
  6. package/dist/screenplay/RemotionRenderer/assets/wordObjects/images.js +24 -0
  7. package/dist/screenplay/RemotionRenderer/assets/wordObjects/manifest.json +67 -0
  8. package/dist/screenplay/RemotionRenderer/components/effects/CaptionWordObjectOverlay.js +275 -0
  9. package/dist/screenplay/RemotionRenderer/components/effects/Sentence.js +10 -3
  10. package/dist/screenplay/RemotionRenderer/components/layouts/Keyword.js +7 -9
  11. package/dist/screenplay/RemotionRenderer/components/layouts/KeywordStudioBackdrop.js +5 -7
  12. package/dist/screenplay/RemotionRenderer/components/layouts/SimpleFrameSentence.js +3 -1
  13. package/dist/screenplay/RemotionRenderer/components/utils/FaceCenteredVideo.js +9 -0
  14. package/dist/screenplay/RemotionRenderer/helpers/captionWordObjectGeometry.js +143 -0
  15. package/dist/screenplay/RemotionRenderer/helpers/captionWordObjects.js +143 -0
  16. package/dist/screenplay/RemotionRenderer/helpers/emojiCatalog.js +17 -0
  17. package/dist/screenplay/RemotionRenderer/helpers/headingColors.js +12 -0
  18. package/dist/screenplay/RemotionRenderer/helpers/loadWordObjectArtwork.js +56 -0
  19. package/dist/screenplay/RemotionRenderer/helpers/normalizeDisabledTranscripts.js +3 -629
  20. package/dist/screenplay/RemotionRenderer/main/lib/layouts/LayoutFactory.js +0 -15
  21. package/dist/screenplay/RemotionRenderer/main/lib/layouts/SimpleFrameLayout.js +8 -3
  22. package/dist/screenplay/RemotionRenderer/theme/themes/default/CaptionPunctuated.js +29 -6
  23. package/dist/screenplay/RemotionRenderer/theme/themes/default/CaptionWordBoom.js +42 -47
  24. package/dist/screenplay/RemotionRenderer/theme/themes/default/CaptionWordBubble.js +30 -7
  25. package/dist/screenplay/RemotionRenderer/theme/themes/default/Sentence/AnimatedSentence.js +1 -0
  26. package/dist/screenplay/RemotionRenderer/theme/themes/default/Sentence.js +8 -4
  27. package/dist/screenplay/RemotionRenderer/theme/themes/fullcover/TextFullCover.js +10 -5
  28. package/dist/screenplay/RemotionRenderer/theme/themes/glassmorphism/Sentence.js +14 -4
  29. package/dist/screenplay/RemotionRenderer/tracks/LayoutVideoTrack.js +1 -1
  30. package/dist/screenplay/RemotionRenderer/tracks/Soundtrack.js +20 -3
  31. package/dist/timing.cjs +1050 -0
  32. package/package.json +8 -4
@@ -0,0 +1,1050 @@
1
+ const EPSILON = 1e-6;
2
+ const DEFAULT_FPS = 30;
3
+ const MIN_CHUNK_DURATION_SECONDS = 0.26;
4
+
5
+ const cloneValue = (value) => {
6
+ if (value === undefined) {
7
+ return undefined;
8
+ }
9
+
10
+ return JSON.parse(JSON.stringify(value));
11
+ };
12
+
13
+ const toFiniteNumber = (value, fallback = null) => {
14
+ const parsed = Number(value);
15
+ if (!Number.isFinite(parsed)) {
16
+ return fallback;
17
+ }
18
+
19
+ return parsed;
20
+ };
21
+
22
+ const isDisabled = (value) =>
23
+ value === true || value === "true" || value === 1 || value === "1";
24
+
25
+ const buildInterval = (start, end) => {
26
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
27
+ return null;
28
+ }
29
+
30
+ if (end <= start + EPSILON) {
31
+ return null;
32
+ }
33
+
34
+ return { start, end };
35
+ };
36
+
37
+ const isMeaningfulDuration = (duration) =>
38
+ Number.isFinite(duration) &&
39
+ duration > Math.max(EPSILON, MIN_CHUNK_DURATION_SECONDS - EPSILON);
40
+
41
+ const mergeIntervals = (intervals = []) => {
42
+ if (!Array.isArray(intervals) || intervals.length === 0) {
43
+ return [];
44
+ }
45
+
46
+ const normalized = intervals
47
+ .map((interval) => buildInterval(interval?.start, interval?.end))
48
+ .filter(Boolean)
49
+ .sort((a, b) => a.start - b.start);
50
+
51
+ if (normalized.length === 0) {
52
+ return [];
53
+ }
54
+
55
+ return normalized.reduce((merged, current) => {
56
+ const previous = merged[merged.length - 1];
57
+
58
+ if (!previous) {
59
+ merged.push({ ...current });
60
+ return merged;
61
+ }
62
+
63
+ if (current.start <= previous.end + EPSILON) {
64
+ previous.end = Math.max(previous.end, current.end);
65
+ return merged;
66
+ }
67
+
68
+ merged.push({ ...current });
69
+ return merged;
70
+ }, []);
71
+ };
72
+
73
+ const subtractIntervals = (range, cuts = []) => {
74
+ const start = toFiniteNumber(range?.start, null);
75
+ const end = toFiniteNumber(range?.end, null);
76
+
77
+ if (
78
+ !Number.isFinite(start) ||
79
+ !Number.isFinite(end) ||
80
+ end <= start + EPSILON
81
+ ) {
82
+ return [];
83
+ }
84
+
85
+ if (!Array.isArray(cuts) || cuts.length === 0) {
86
+ return [{ start, end }];
87
+ }
88
+
89
+ const mergedCuts = mergeIntervals(cuts);
90
+ const kept = [];
91
+ let cursor = start;
92
+
93
+ for (const cut of mergedCuts) {
94
+ if (cut.end <= cursor + EPSILON) {
95
+ continue;
96
+ }
97
+
98
+ if (cut.start >= end - EPSILON) {
99
+ break;
100
+ }
101
+
102
+ if (cut.start > cursor + EPSILON) {
103
+ kept.push({
104
+ start: cursor,
105
+ end: Math.min(cut.start, end),
106
+ });
107
+ }
108
+
109
+ cursor = Math.max(cursor, cut.end);
110
+
111
+ if (cursor >= end - EPSILON) {
112
+ break;
113
+ }
114
+ }
115
+
116
+ if (cursor < end - EPSILON) {
117
+ kept.push({ start: cursor, end });
118
+ }
119
+
120
+ return kept.filter((interval) => interval.end > interval.start + EPSILON);
121
+ };
122
+
123
+ const clipIntervalToRange = (interval, rangeStart, rangeEnd) => {
124
+ const start = Math.max(interval.start, rangeStart);
125
+ const end = Math.min(interval.end, rangeEnd);
126
+ return buildInterval(start, end);
127
+ };
128
+
129
+ const mapTimeAfterCuts = (time, cuts = []) => {
130
+ const normalizedTime = toFiniteNumber(time, null);
131
+ if (!Number.isFinite(normalizedTime)) {
132
+ return 0;
133
+ }
134
+
135
+ if (!Array.isArray(cuts) || cuts.length === 0) {
136
+ return normalizedTime;
137
+ }
138
+
139
+ let removedDuration = 0;
140
+
141
+ for (const cut of cuts) {
142
+ if (cut.start >= normalizedTime) {
143
+ break;
144
+ }
145
+
146
+ removedDuration += Math.max(
147
+ 0,
148
+ Math.min(normalizedTime, cut.end) - cut.start
149
+ );
150
+ }
151
+
152
+ return Math.max(0, normalizedTime - removedDuration);
153
+ };
154
+
155
+ const isCaptionEffect = (effect) =>
156
+ typeof effect?.type === "string" && effect.type.includes("caption");
157
+
158
+ const getSegmentTiming = (segment) => {
159
+ const duration = Math.max(
160
+ 0,
161
+ toFiniteNumber(segment?.layout?.data?.duration, 0)
162
+ );
163
+ const trimLeft = Math.max(
164
+ 0,
165
+ toFiniteNumber(segment?.layout?.data?.trimLeft, 0)
166
+ );
167
+ const trimRight = Math.max(
168
+ 0,
169
+ toFiniteNumber(segment?.layout?.data?.trimRight, 0)
170
+ );
171
+ const playableStart = trimLeft;
172
+ const playableEnd = Math.max(playableStart, duration - trimRight);
173
+ const playableDuration = Math.max(0, playableEnd - playableStart);
174
+
175
+ return {
176
+ duration,
177
+ trimLeft,
178
+ trimRight,
179
+ playableStart,
180
+ playableEnd,
181
+ playableDuration,
182
+ };
183
+ };
184
+
185
+ const getCaptionPunctuationRange = (entry = {}) => {
186
+ const punctuations = Array.isArray(entry?.punctuations)
187
+ ? entry.punctuations
188
+ : [];
189
+
190
+ if (!punctuations.length) {
191
+ return null;
192
+ }
193
+
194
+ let minStart = Infinity;
195
+ let maxEnd = 0;
196
+
197
+ punctuations.forEach((punctuation) => {
198
+ const start = toFiniteNumber(punctuation?.start, Infinity);
199
+ const end = toFiniteNumber(punctuation?.end, -Infinity);
200
+
201
+ if (start < minStart) {
202
+ minStart = start;
203
+ }
204
+
205
+ if (end > maxEnd) {
206
+ maxEnd = end;
207
+ }
208
+ });
209
+
210
+ if (!Number.isFinite(minStart) || maxEnd <= minStart + EPSILON) {
211
+ return null;
212
+ }
213
+
214
+ return {
215
+ start: minStart,
216
+ end: maxEnd,
217
+ };
218
+ };
219
+
220
+ const getCaptionEntryOffset = (entry = {}) => {
221
+ const directOffset = toFiniteNumber(entry?.offset, null);
222
+
223
+ if (Number.isFinite(directOffset)) {
224
+ return directOffset;
225
+ }
226
+
227
+ const originalOffset = toFiniteNumber(entry?.originalOffset, null);
228
+
229
+ if (Number.isFinite(originalOffset)) {
230
+ return originalOffset;
231
+ }
232
+
233
+ const punctuationRange = getCaptionPunctuationRange(entry);
234
+
235
+ return punctuationRange?.start ?? null;
236
+ };
237
+
238
+ const getCaptionEntryTiming = (entry = {}) => {
239
+ const offset = getCaptionEntryOffset(entry);
240
+ const directDuration = toFiniteNumber(entry?.duration, null);
241
+
242
+ if (Number.isFinite(offset) && Number.isFinite(directDuration)) {
243
+ if (directDuration > EPSILON) {
244
+ return { offset, duration: directDuration };
245
+ }
246
+
247
+ return null;
248
+ }
249
+
250
+ const punctuationRange = getCaptionPunctuationRange(entry);
251
+
252
+ if (!punctuationRange) {
253
+ return null;
254
+ }
255
+
256
+ const resolvedOffset = Number.isFinite(offset)
257
+ ? offset
258
+ : punctuationRange.start;
259
+ const resolvedDuration = punctuationRange.end - resolvedOffset;
260
+
261
+ if (resolvedDuration <= EPSILON) {
262
+ return null;
263
+ }
264
+
265
+ return {
266
+ offset: resolvedOffset,
267
+ duration: resolvedDuration,
268
+ };
269
+ };
270
+
271
+ const normalizeCaptionEntriesWithTiming = (entries = []) => {
272
+ if (!Array.isArray(entries)) {
273
+ return [];
274
+ }
275
+
276
+ return entries.map((entry) => {
277
+ const normalizedEntry = { ...entry };
278
+ const timing = getCaptionEntryTiming(entry);
279
+
280
+ if (timing) {
281
+ normalizedEntry.offset = timing.offset;
282
+ normalizedEntry.duration = timing.duration;
283
+
284
+ if (normalizedEntry.originalOffset == null) {
285
+ normalizedEntry.originalOffset = timing.offset;
286
+ }
287
+ }
288
+
289
+ if (Array.isArray(entry?.punctuations)) {
290
+ normalizedEntry.punctuations = entry.punctuations.map((punctuation) => ({
291
+ ...punctuation,
292
+ ...(punctuation?.start != null && punctuation?.originalStart == null
293
+ ? { originalStart: punctuation.start }
294
+ : {}),
295
+ ...(punctuation?.end != null && punctuation?.originalEnd == null
296
+ ? { originalEnd: punctuation.end }
297
+ : {}),
298
+ }));
299
+ }
300
+
301
+ return normalizedEntry;
302
+ });
303
+ };
304
+
305
+ const collectDisabledTranscriptIntervals = (transcripts = []) => {
306
+ if (!Array.isArray(transcripts)) {
307
+ return [];
308
+ }
309
+
310
+ const intervals = [];
311
+
312
+ transcripts.forEach((transcript) => {
313
+ if (!isDisabled(transcript?.disabled)) {
314
+ return;
315
+ }
316
+
317
+ const offset = toFiniteNumber(transcript?.offset, null);
318
+ const duration = toFiniteNumber(transcript?.duration, null);
319
+ if (
320
+ !Number.isFinite(offset) ||
321
+ !Number.isFinite(duration) ||
322
+ duration <= EPSILON
323
+ ) {
324
+ return;
325
+ }
326
+
327
+ intervals.push({
328
+ start: offset,
329
+ end: offset + duration,
330
+ });
331
+ });
332
+
333
+ return mergeIntervals(intervals);
334
+ };
335
+
336
+ const collectSegmentDisabledIntervals = (segment, timing) => {
337
+ const effects = Array.isArray(segment?.effects) ? segment.effects : [];
338
+ const cuts = [];
339
+
340
+ effects.forEach((effect) => {
341
+ if (!isCaptionEffect(effect)) {
342
+ return;
343
+ }
344
+
345
+ const intervals = collectDisabledTranscriptIntervals(
346
+ effect?.data?.transcript_text || []
347
+ );
348
+
349
+ intervals.forEach((interval) => {
350
+ const clipped = clipIntervalToRange(
351
+ interval,
352
+ timing.playableStart,
353
+ timing.playableEnd
354
+ );
355
+
356
+ if (clipped) {
357
+ cuts.push(clipped);
358
+ }
359
+ });
360
+ });
361
+
362
+ return mergeIntervals(cuts);
363
+ };
364
+
365
+ const collectGlobalDisabledIntervals = (screenplay) => {
366
+ const allIntervals = [];
367
+ const segmentTrack = screenplay?.tracks?.find(
368
+ (track) => track.type === "segment"
369
+ );
370
+ const captionTrack = screenplay?.tracks?.find(
371
+ (track) => track.type === "caption"
372
+ );
373
+
374
+ let segmentTimelineCursor = 0;
375
+
376
+ if (Array.isArray(segmentTrack?.segments)) {
377
+ segmentTrack.segments.forEach((segment) => {
378
+ const timing = getSegmentTiming(segment);
379
+ if (isDisabled(segment?.layout?.removed)) {
380
+ const removed = buildInterval(
381
+ segmentTimelineCursor,
382
+ segmentTimelineCursor + timing.playableDuration
383
+ );
384
+ if (removed) allIntervals.push(removed);
385
+ }
386
+ const localCuts = collectSegmentDisabledIntervals(segment, timing);
387
+
388
+ localCuts.forEach((cut) => {
389
+ const start = segmentTimelineCursor + (cut.start - timing.trimLeft);
390
+ const end = segmentTimelineCursor + (cut.end - timing.trimLeft);
391
+ const absolute = buildInterval(start, end);
392
+
393
+ if (absolute) {
394
+ allIntervals.push(absolute);
395
+ }
396
+ });
397
+
398
+ segmentTimelineCursor += timing.playableDuration;
399
+ });
400
+ }
401
+
402
+ if (Array.isArray(captionTrack?.segments)) {
403
+ captionTrack.segments.forEach((segment) => {
404
+ const segmentOffset = toFiniteNumber(segment?.data?.offset, 0);
405
+ const intervals = collectDisabledTranscriptIntervals(
406
+ segment?.data?.transcript_text || []
407
+ );
408
+
409
+ intervals.forEach((interval) => {
410
+ const absolute = buildInterval(
411
+ segmentOffset + interval.start,
412
+ segmentOffset + interval.end
413
+ );
414
+
415
+ if (absolute) {
416
+ allIntervals.push(absolute);
417
+ }
418
+ });
419
+ });
420
+ }
421
+
422
+ const initialCuts = mergeIntervals(allIntervals);
423
+ // Video drops tiny retained chunks around cuts. Include those discarded
424
+ // source ranges before normalizing every track so they share one timeline.
425
+ segmentTimelineCursor = 0;
426
+ for (const segment of segmentTrack?.segments || []) {
427
+ const timing = getSegmentTiming(segment);
428
+ const start = segmentTimelineCursor;
429
+ const end = start + timing.playableDuration;
430
+ segmentTimelineCursor = end;
431
+ if (
432
+ isDisabled(segment?.layout?.removed) ||
433
+ !initialCuts.some(
434
+ (cut) => cut.start < end - EPSILON && cut.end > start + EPSILON
435
+ )
436
+ )
437
+ continue;
438
+ for (const range of subtractIntervals({ start, end }, initialCuts)) {
439
+ if (!isMeaningfulDuration(range.end - range.start))
440
+ allIntervals.push(range);
441
+ }
442
+ }
443
+ return mergeIntervals(allIntervals);
444
+ };
445
+
446
+ const normalizeTextAndPunctuations = (text, punctuations = []) => {
447
+ if (!Array.isArray(punctuations) || punctuations.length === 0) {
448
+ return {
449
+ text,
450
+ punctuations: [],
451
+ };
452
+ }
453
+
454
+ if (typeof text !== "string") {
455
+ return {
456
+ text,
457
+ punctuations,
458
+ };
459
+ }
460
+
461
+ const words = text.split(" ");
462
+ if (words.length === 0) {
463
+ return {
464
+ text,
465
+ punctuations,
466
+ };
467
+ }
468
+
469
+ const wordIndexOrder = [];
470
+ const seen = new Set();
471
+
472
+ punctuations.forEach((punctuation) => {
473
+ const index = toFiniteNumber(punctuation?.index, null);
474
+ if (!Number.isInteger(index)) {
475
+ return;
476
+ }
477
+
478
+ if (index < 0 || index >= words.length) {
479
+ return;
480
+ }
481
+
482
+ if (!seen.has(index)) {
483
+ seen.add(index);
484
+ wordIndexOrder.push(index);
485
+ }
486
+ });
487
+
488
+ if (wordIndexOrder.length === 0) {
489
+ return {
490
+ text,
491
+ punctuations: [],
492
+ };
493
+ }
494
+
495
+ const filteredWords = wordIndexOrder
496
+ .map((wordIndex) => words[wordIndex])
497
+ .filter((word) => word !== undefined);
498
+
499
+ if (filteredWords.length === 0) {
500
+ return {
501
+ text,
502
+ punctuations: [],
503
+ };
504
+ }
505
+
506
+ const indexMap = new Map(
507
+ wordIndexOrder.map((wordIndex, mappedIndex) => [wordIndex, mappedIndex])
508
+ );
509
+
510
+ const normalizedPunctuations = punctuations
511
+ .filter((punctuation) =>
512
+ indexMap.has(toFiniteNumber(punctuation?.index, null))
513
+ )
514
+ .map((punctuation) => ({
515
+ ...punctuation,
516
+ index: indexMap.get(toFiniteNumber(punctuation?.index, null)),
517
+ }));
518
+
519
+ return {
520
+ text: filteredWords.join(" "),
521
+ punctuations: normalizedPunctuations,
522
+ };
523
+ };
524
+
525
+ const clipTranscriptToRange = ({
526
+ transcript,
527
+ rangeStart,
528
+ rangeEnd,
529
+ offsetMode,
530
+ offsetBase = 0,
531
+ stripDisabled = false,
532
+ }) => {
533
+ if (!transcript) {
534
+ return null;
535
+ }
536
+
537
+ if (stripDisabled && isDisabled(transcript?.disabled)) {
538
+ return null;
539
+ }
540
+
541
+ const transcriptStart = toFiniteNumber(transcript?.offset, null);
542
+ const transcriptDuration = toFiniteNumber(transcript?.duration, null);
543
+ if (
544
+ !Number.isFinite(transcriptStart) ||
545
+ !Number.isFinite(transcriptDuration) ||
546
+ transcriptDuration <= EPSILON
547
+ ) {
548
+ return null;
549
+ }
550
+
551
+ const transcriptEnd = transcriptStart + transcriptDuration;
552
+ const overlapStart = Math.max(transcriptStart, rangeStart);
553
+ const overlapEnd = Math.min(transcriptEnd, rangeEnd);
554
+ if (overlapEnd <= overlapStart + EPSILON) {
555
+ return null;
556
+ }
557
+
558
+ const clonedTranscript = cloneValue(transcript);
559
+ const mapOffset = (value) =>
560
+ offsetMode === "relative" ? value - offsetBase : value;
561
+
562
+ clonedTranscript.offset = mapOffset(overlapStart);
563
+ clonedTranscript.duration = overlapEnd - overlapStart;
564
+
565
+ const punctuations = Array.isArray(transcript?.punctuations)
566
+ ? transcript.punctuations
567
+ : [];
568
+
569
+ const clippedPunctuations = punctuations
570
+ .map((punctuation) => {
571
+ const punctuationStart = toFiniteNumber(punctuation?.start, null);
572
+ const punctuationEnd = toFiniteNumber(punctuation?.end, null);
573
+
574
+ if (
575
+ !Number.isFinite(punctuationStart) ||
576
+ !Number.isFinite(punctuationEnd) ||
577
+ punctuationEnd <= punctuationStart + EPSILON
578
+ ) {
579
+ return null;
580
+ }
581
+
582
+ const clippedStart = Math.max(punctuationStart, overlapStart);
583
+ const clippedEnd = Math.min(punctuationEnd, overlapEnd);
584
+ if (clippedEnd <= clippedStart + EPSILON) {
585
+ return null;
586
+ }
587
+
588
+ return {
589
+ ...punctuation,
590
+ ...(punctuation.wordObject && punctuationStart < rangeStart - EPSILON
591
+ ? { __wordObjectOnsetClipped: true }
592
+ : {}),
593
+ start: mapOffset(clippedStart),
594
+ end: mapOffset(clippedEnd),
595
+ };
596
+ })
597
+ .filter(Boolean);
598
+
599
+ const wasClipped =
600
+ overlapStart > transcriptStart + EPSILON ||
601
+ overlapEnd < transcriptEnd - EPSILON;
602
+
603
+ if (wasClipped && clippedPunctuations.length > 0) {
604
+ const { text, punctuations: normalizedPunctuations } =
605
+ normalizeTextAndPunctuations(transcript?.text, clippedPunctuations);
606
+
607
+ clonedTranscript.text = text;
608
+ clonedTranscript.punctuations = normalizedPunctuations;
609
+ } else {
610
+ clonedTranscript.text = transcript?.text;
611
+ clonedTranscript.punctuations = clippedPunctuations;
612
+ }
613
+
614
+ return clonedTranscript;
615
+ };
616
+
617
+ const normalizeTranscriptsForRange = ({
618
+ transcripts,
619
+ rangeStart,
620
+ rangeEnd,
621
+ offsetMode,
622
+ offsetBase,
623
+ stripDisabled,
624
+ }) => {
625
+ if (!Array.isArray(transcripts)) {
626
+ return [];
627
+ }
628
+
629
+ return transcripts
630
+ .map((transcript) =>
631
+ clipTranscriptToRange({
632
+ transcript,
633
+ rangeStart,
634
+ rangeEnd,
635
+ offsetMode,
636
+ offsetBase,
637
+ stripDisabled,
638
+ })
639
+ )
640
+ .filter(Boolean);
641
+ };
642
+
643
+ const normalizeSegmentTrack = (segmentTrack, globalCuts) => {
644
+ if (!segmentTrack || !Array.isArray(segmentTrack?.segments)) {
645
+ return segmentTrack;
646
+ }
647
+
648
+ const normalizedSegments = [];
649
+ let timelineCursor = 0;
650
+
651
+ segmentTrack.segments.forEach((segment, segmentIndex) => {
652
+ const timing = getSegmentTiming(segment);
653
+ const { duration, playableStart, playableEnd, playableDuration, trimLeft } =
654
+ timing;
655
+
656
+ const isRemoved = isDisabled(segment?.layout?.removed);
657
+ if (isRemoved) {
658
+ // Timed tracks consume the same global cut below. Omit this layout now
659
+ // so preparing the returned screenplay again cannot apply it twice.
660
+ timelineCursor += playableDuration;
661
+ return;
662
+ }
663
+ if (playableDuration <= EPSILON) {
664
+ const untouched = cloneValue(segment);
665
+ untouched.__transitionAfter = true;
666
+ untouched.__originSegmentIndex = segmentIndex;
667
+ normalizedSegments.push(untouched);
668
+ timelineCursor += playableDuration;
669
+ return;
670
+ }
671
+
672
+ const localCuts = [];
673
+ const segmentAbsoluteStart = timelineCursor;
674
+ const segmentAbsoluteEnd = segmentAbsoluteStart + playableDuration;
675
+
676
+ globalCuts.forEach((globalCut) => {
677
+ if (globalCut.end <= segmentAbsoluteStart + EPSILON) {
678
+ return;
679
+ }
680
+
681
+ if (globalCut.start >= segmentAbsoluteEnd - EPSILON) {
682
+ return;
683
+ }
684
+
685
+ const overlapStart = Math.max(globalCut.start, segmentAbsoluteStart);
686
+ const overlapEnd = Math.min(globalCut.end, segmentAbsoluteEnd);
687
+ if (overlapEnd <= overlapStart + EPSILON) {
688
+ return;
689
+ }
690
+
691
+ const localStart = trimLeft + (overlapStart - segmentAbsoluteStart);
692
+ const localEnd = trimLeft + (overlapEnd - segmentAbsoluteStart);
693
+ const localInterval = buildInterval(localStart, localEnd);
694
+
695
+ if (localInterval) {
696
+ localCuts.push(localInterval);
697
+ }
698
+ });
699
+
700
+ const mergedLocalCuts = mergeIntervals(localCuts);
701
+ const keptRanges = subtractIntervals(
702
+ { start: playableStart, end: playableEnd },
703
+ mergedLocalCuts
704
+ );
705
+
706
+ if (keptRanges.length === 0) {
707
+ timelineCursor += playableDuration;
708
+ return;
709
+ }
710
+
711
+ keptRanges.forEach((keptRange, chunkIndex) => {
712
+ const chunkDuration = keptRange.end - keptRange.start;
713
+ if (
714
+ chunkDuration <= EPSILON ||
715
+ (mergedLocalCuts.length > 0 && !isMeaningfulDuration(chunkDuration))
716
+ ) {
717
+ return;
718
+ }
719
+
720
+ const clonedChunk = cloneValue(segment);
721
+
722
+ clonedChunk.layout.data.trimLeft = keptRange.start;
723
+ clonedChunk.layout.data.trimRight = Math.max(0, duration - keptRange.end);
724
+ clonedChunk.__transitionAfter = chunkIndex === keptRanges.length - 1;
725
+ clonedChunk.__originSegmentIndex = segmentIndex;
726
+
727
+ if (Array.isArray(clonedChunk.effects)) {
728
+ clonedChunk.effects = clonedChunk.effects.map((effect) => {
729
+ if (!isCaptionEffect(effect)) {
730
+ return effect;
731
+ }
732
+
733
+ const normalizedEffect = cloneValue(effect);
734
+ normalizedEffect.data = normalizedEffect.data || {};
735
+ normalizedEffect.data.transcript_text = normalizeTranscriptsForRange({
736
+ transcripts: effect?.data?.transcript_text || [],
737
+ rangeStart: keptRange.start,
738
+ rangeEnd: keptRange.end,
739
+ offsetMode: "absolute",
740
+ offsetBase: keptRange.start,
741
+ stripDisabled: true,
742
+ });
743
+ return normalizedEffect;
744
+ });
745
+ }
746
+
747
+ normalizedSegments.push(clonedChunk);
748
+ });
749
+
750
+ timelineCursor += playableDuration;
751
+ });
752
+
753
+ return {
754
+ ...segmentTrack,
755
+ segments: normalizedSegments,
756
+ };
757
+ };
758
+
759
+ const resolveSegmentDuration = (segment, trackType) => {
760
+ const explicitDuration = toFiniteNumber(segment?.data?.duration, null);
761
+ if (Number.isFinite(explicitDuration) && explicitDuration > EPSILON) {
762
+ return explicitDuration;
763
+ }
764
+
765
+ if (trackType !== "caption") {
766
+ return null;
767
+ }
768
+
769
+ const transcripts = Array.isArray(segment?.data?.transcript_text)
770
+ ? segment.data.transcript_text
771
+ : [];
772
+
773
+ const maxEnd = transcripts.reduce((currentMax, transcript) => {
774
+ const offset = toFiniteNumber(transcript?.offset, null);
775
+ const duration = toFiniteNumber(transcript?.duration, null);
776
+
777
+ if (
778
+ !Number.isFinite(offset) ||
779
+ !Number.isFinite(duration) ||
780
+ duration <= EPSILON
781
+ ) {
782
+ return currentMax;
783
+ }
784
+
785
+ return Math.max(currentMax, offset + duration);
786
+ }, 0);
787
+
788
+ return maxEnd > EPSILON ? maxEnd : null;
789
+ };
790
+
791
+ const shiftSourceMediaStart = (segment, deltaInSeconds) => {
792
+ if (
793
+ !segment?.data ||
794
+ !Number.isFinite(deltaInSeconds) ||
795
+ deltaInSeconds === 0
796
+ ) {
797
+ return;
798
+ }
799
+
800
+ const sourceVideoStart = toFiniteNumber(
801
+ segment?.data?.sourceVideo?.start,
802
+ segment?.data?.sourceVideo ? 0 : null
803
+ );
804
+ if (Number.isFinite(sourceVideoStart)) {
805
+ segment.data.sourceVideo.start = Math.max(
806
+ 0,
807
+ sourceVideoStart + deltaInSeconds
808
+ );
809
+ }
810
+
811
+ const sourceAudioStart = toFiniteNumber(
812
+ segment?.data?.sourceAudio?.start,
813
+ segment?.data?.sourceAudio ? 0 : null
814
+ );
815
+ if (Number.isFinite(sourceAudioStart)) {
816
+ segment.data.sourceAudio.start = Math.max(
817
+ 0,
818
+ sourceAudioStart + deltaInSeconds
819
+ );
820
+ }
821
+ };
822
+
823
+ const normalizeTimedTrack = (
824
+ track,
825
+ globalCuts,
826
+ trackType,
827
+ timelineEnd = null
828
+ ) => {
829
+ if (!track || !Array.isArray(track?.segments)) {
830
+ return track;
831
+ }
832
+
833
+ const normalizedSegments = [];
834
+
835
+ track.segments.forEach((segment) => {
836
+ const segmentStart = toFiniteNumber(segment?.data?.offset, 0);
837
+ let segmentDuration = resolveSegmentDuration(segment, trackType);
838
+ // An open-ended soundtrack plays to the pre-cut segment timeline end.
839
+ // Bound it in the same coordinates as globalCuts so source audio can be
840
+ // advanced or split exactly like an explicitly timed audio segment.
841
+ if (
842
+ trackType === "audio" &&
843
+ !Number.isFinite(segmentDuration) &&
844
+ timelineEnd > EPSILON
845
+ ) {
846
+ segmentDuration = timelineEnd - segmentStart;
847
+ if (segmentDuration <= EPSILON) return;
848
+ }
849
+
850
+ if (!Number.isFinite(segmentDuration) || segmentDuration <= EPSILON) {
851
+ const shiftedSegment = cloneValue(segment);
852
+ shiftedSegment.data = shiftedSegment.data || {};
853
+ shiftedSegment.data.offset = mapTimeAfterCuts(segmentStart, globalCuts);
854
+
855
+ if (trackType === "caption") {
856
+ shiftedSegment.data.transcript_text = normalizeTranscriptsForRange({
857
+ transcripts: segment?.data?.transcript_text || [],
858
+ rangeStart: 0,
859
+ rangeEnd: Number.POSITIVE_INFINITY,
860
+ offsetMode: "relative",
861
+ offsetBase: 0,
862
+ stripDisabled: true,
863
+ });
864
+
865
+ if (shiftedSegment.data.transcript_text.length === 0) {
866
+ return;
867
+ }
868
+ }
869
+
870
+ normalizedSegments.push(shiftedSegment);
871
+ return;
872
+ }
873
+
874
+ const segmentEnd = segmentStart + segmentDuration;
875
+ const wasCut = globalCuts.some(
876
+ (cut) =>
877
+ cut.start < segmentEnd - EPSILON && cut.end > segmentStart + EPSILON
878
+ );
879
+ const keptRanges = subtractIntervals(
880
+ { start: segmentStart, end: segmentEnd },
881
+ globalCuts
882
+ );
883
+
884
+ keptRanges.forEach((keptRange) => {
885
+ const chunkDuration = keptRange.end - keptRange.start;
886
+ if (
887
+ chunkDuration <= EPSILON ||
888
+ (wasCut && !isMeaningfulDuration(chunkDuration))
889
+ ) {
890
+ return;
891
+ }
892
+
893
+ const clonedChunk = cloneValue(segment);
894
+ clonedChunk.data = clonedChunk.data || {};
895
+ clonedChunk.data.offset = mapTimeAfterCuts(keptRange.start, globalCuts);
896
+ clonedChunk.data.duration = chunkDuration;
897
+
898
+ const sourceTimeDelta = keptRange.start - segmentStart;
899
+ shiftSourceMediaStart(clonedChunk, sourceTimeDelta);
900
+
901
+ if (trackType === "caption") {
902
+ const localStart = keptRange.start - segmentStart;
903
+ const localEnd = keptRange.end - segmentStart;
904
+
905
+ clonedChunk.data.transcript_text = normalizeTranscriptsForRange({
906
+ transcripts: segment?.data?.transcript_text || [],
907
+ rangeStart: localStart,
908
+ rangeEnd: localEnd,
909
+ offsetMode: "relative",
910
+ offsetBase: localStart,
911
+ stripDisabled: true,
912
+ });
913
+
914
+ if (clonedChunk.data.transcript_text.length === 0) {
915
+ return;
916
+ }
917
+ }
918
+
919
+ normalizedSegments.push(clonedChunk);
920
+ });
921
+ });
922
+
923
+ return {
924
+ ...track,
925
+ segments: normalizedSegments,
926
+ };
927
+ };
928
+
929
+ const normalizeDisabledTranscripts = (screenplayInput) => {
930
+ const screenplay = cloneValue(screenplayInput) || {};
931
+ if (!Array.isArray(screenplay?.tracks)) {
932
+ return screenplay;
933
+ }
934
+
935
+ // Repair legacy caption timing before deriving cuts, using the same helper
936
+ // that prepares editor previews and saved screenplays.
937
+ for (const track of screenplay.tracks) {
938
+ for (const segment of track?.segments || []) {
939
+ const carriers = [
940
+ segment,
941
+ ...(Array.isArray(segment?.effects) ? segment.effects : []),
942
+ ];
943
+ for (const carrier of carriers) {
944
+ if (Array.isArray(carrier?.data?.transcript_text))
945
+ carrier.data.transcript_text = normalizeCaptionEntriesWithTiming(
946
+ carrier.data.transcript_text
947
+ );
948
+ }
949
+ }
950
+ }
951
+
952
+ const globalDisabledIntervals = collectGlobalDisabledIntervals(screenplay);
953
+ if (globalDisabledIntervals.length === 0) {
954
+ return screenplay;
955
+ }
956
+
957
+ const segmentTimelineEnd = (
958
+ screenplay.tracks.find((track) => track.type === "segment")?.segments || []
959
+ ).reduce(
960
+ (end, segment) => end + getSegmentTiming(segment).playableDuration,
961
+ 0
962
+ );
963
+
964
+ screenplay.tracks = screenplay.tracks.map((track) => {
965
+ if (!track || !Array.isArray(track?.segments)) {
966
+ return track;
967
+ }
968
+
969
+ switch (track.type) {
970
+ case "segment":
971
+ return normalizeSegmentTrack(track, globalDisabledIntervals);
972
+ case "effect":
973
+ return normalizeTimedTrack(track, globalDisabledIntervals, "effect");
974
+ case "audio":
975
+ return normalizeTimedTrack(
976
+ track,
977
+ globalDisabledIntervals,
978
+ "audio",
979
+ segmentTimelineEnd
980
+ );
981
+ case "caption":
982
+ return normalizeTimedTrack(track, globalDisabledIntervals, "caption");
983
+ default:
984
+ return track;
985
+ }
986
+ });
987
+
988
+ return screenplay;
989
+ };
990
+
991
+ // Canonical, dependency-free timing artifact. Zync vendors this file verbatim in
992
+ // common/helper/screenplayTiming.cjs; verify parity before releasing either repo.
993
+ const prepareScreenplay = (input) => {
994
+ const screenplay = normalizeDisabledTranscripts(input);
995
+ for (const track of screenplay.tracks || []) {
996
+ if (track.type === "segment")
997
+ track.segments = (track.segments || [])
998
+ .map((segment, index) => {
999
+ segment.__originSegmentIndex ??= index;
1000
+ return segment;
1001
+ })
1002
+ .filter((s) => !isDisabled(s.layout?.removed));
1003
+ }
1004
+ return screenplay;
1005
+ };
1006
+ const getScreenplayTiming = (input, { prepared = false } = {}) => {
1007
+ const screenplay = prepared ? input : prepareScreenplay(input);
1008
+ const segments = (
1009
+ screenplay.tracks?.find((t) => t.type === "segment")?.segments || []
1010
+ ).filter((s) => !isDisabled(s.layout?.removed));
1011
+ const segmentFrames = segments.map((segment) =>
1012
+ Math.max(
1013
+ 1,
1014
+ Math.round(getSegmentTiming(segment).playableDuration * DEFAULT_FPS)
1015
+ )
1016
+ );
1017
+ let cursor = 0;
1018
+ const sequences = segments.map((segment, index) => {
1019
+ const frames = segmentFrames[index];
1020
+ const overlapFrames =
1021
+ screenplay.input?.theme === "none" &&
1022
+ index < segments.length - 1 &&
1023
+ frames >= 15 &&
1024
+ segmentFrames[index + 1] >= 15 &&
1025
+ (segment.transitionAfter ?? segment.__transitionAfter) !== false
1026
+ ? 15
1027
+ : 0;
1028
+ const startFrame = cursor;
1029
+ cursor += frames - overlapFrames;
1030
+ return {
1031
+ originSegmentIndex: segment.__originSegmentIndex ?? index,
1032
+ startFrame,
1033
+ endFrame: startFrame + frames,
1034
+ durationInFrames: frames,
1035
+ overlapFrames,
1036
+ };
1037
+ });
1038
+ return {
1039
+ fps: DEFAULT_FPS,
1040
+ durationInFrames: cursor,
1041
+ durationSeconds: cursor / DEFAULT_FPS,
1042
+ sequences,
1043
+ };
1044
+ };
1045
+ module.exports = {
1046
+ normalizeCaptionEntriesWithTiming,
1047
+ normalizeDisabledTranscripts,
1048
+ prepareScreenplay,
1049
+ getScreenplayTiming,
1050
+ };