visualfries 0.1.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 (219) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +213 -0
  3. package/dist/DIContainer.d.ts +4 -0
  4. package/dist/DIContainer.js +145 -0
  5. package/dist/SceneBuilder.svelte.d.ts +8574 -0
  6. package/dist/SceneBuilder.svelte.js +409 -0
  7. package/dist/adapters/subtitleHelpers.d.ts +2 -0
  8. package/dist/adapters/subtitleHelpers.js +187 -0
  9. package/dist/animations/AnimationContext.d.ts +17 -0
  10. package/dist/animations/AnimationContext.js +72 -0
  11. package/dist/animations/AnimationPresetsRegister.d.ts +362 -0
  12. package/dist/animations/AnimationPresetsRegister.js +20 -0
  13. package/dist/animations/AnimationSetup.d.ts +8 -0
  14. package/dist/animations/AnimationSetup.js +30 -0
  15. package/dist/animations/SplitTextCache.d.ts +28 -0
  16. package/dist/animations/SplitTextCache.js +68 -0
  17. package/dist/animations/animationBuilder.d.ts +31 -0
  18. package/dist/animations/animationBuilder.js +255 -0
  19. package/dist/animations/animationPreset.d.ts +7 -0
  20. package/dist/animations/animationPreset.js +31 -0
  21. package/dist/animations/builders/AnimationPresetFactory.d.ts +43 -0
  22. package/dist/animations/builders/AnimationPresetFactory.js +139 -0
  23. package/dist/animations/builders/LineHighlighterAnimationBuilder.d.ts +16 -0
  24. package/dist/animations/builders/LineHighlighterAnimationBuilder.js +183 -0
  25. package/dist/animations/builders/WordHighlighterAnimationBuilder.d.ts +15 -0
  26. package/dist/animations/builders/WordHighlighterAnimationBuilder.js +180 -0
  27. package/dist/animations/engines/AnimationEngineAdaptor.d.ts +107 -0
  28. package/dist/animations/engines/AnimationEngineAdaptor.js +1 -0
  29. package/dist/animations/engines/GSAPEngineAdaptor.d.ts +21 -0
  30. package/dist/animations/engines/GSAPEngineAdaptor.js +145 -0
  31. package/dist/animations/presets/index.d.ts +2 -0
  32. package/dist/animations/presets/index.js +3 -0
  33. package/dist/animations/presets/lines.d.ts +52 -0
  34. package/dist/animations/presets/lines.js +547 -0
  35. package/dist/animations/presets/words.d.ts +31 -0
  36. package/dist/animations/presets/words.js +268 -0
  37. package/dist/animations/transformers/AnimationReferenceTransformer.d.ts +9 -0
  38. package/dist/animations/transformers/AnimationReferenceTransformer.js +114 -0
  39. package/dist/builders/PixiComponentBuilder.d.ts +63 -0
  40. package/dist/builders/PixiComponentBuilder.js +112 -0
  41. package/dist/builders/_ComponentState.svelte.d.ts +795 -0
  42. package/dist/builders/_ComponentState.svelte.js +203 -0
  43. package/dist/builders/html/HtmlBuilder.d.ts +66 -0
  44. package/dist/builders/html/HtmlBuilder.js +171 -0
  45. package/dist/builders/html/HtmlBuilderFactory.d.ts +27 -0
  46. package/dist/builders/html/HtmlBuilderFactory.js +30 -0
  47. package/dist/builders/html/StyleBuilder.d.ts +13 -0
  48. package/dist/builders/html/StyleBuilder.js +133 -0
  49. package/dist/builders/html/StyleProcessor.d.ts +9 -0
  50. package/dist/builders/html/StyleProcessor.js +1 -0
  51. package/dist/builders/html/TextComponentHtmlBuilder.d.ts +16 -0
  52. package/dist/builders/html/TextComponentHtmlBuilder.js +93 -0
  53. package/dist/builders/html/TextShadowBuilder.d.ts +60 -0
  54. package/dist/builders/html/TextShadowBuilder.js +227 -0
  55. package/dist/builders/html/processors/AppearanceStyleProcessor.d.ts +5 -0
  56. package/dist/builders/html/processors/AppearanceStyleProcessor.js +57 -0
  57. package/dist/builders/html/processors/TextAppearanceStyleProcessor.d.ts +5 -0
  58. package/dist/builders/html/processors/TextAppearanceStyleProcessor.js +37 -0
  59. package/dist/builders/html/processors/TextEffectsStyleProcessor.d.ts +6 -0
  60. package/dist/builders/html/processors/TextEffectsStyleProcessor.js +68 -0
  61. package/dist/commands/Command.d.ts +6 -0
  62. package/dist/commands/Command.js +1 -0
  63. package/dist/commands/CommandRunner.d.ts +28 -0
  64. package/dist/commands/CommandRunner.js +81 -0
  65. package/dist/commands/CommandTypes.d.ts +11 -0
  66. package/dist/commands/CommandTypes.js +13 -0
  67. package/dist/commands/PauseCommand.d.ts +4 -0
  68. package/dist/commands/PauseCommand.js +5 -0
  69. package/dist/commands/PlayCommand.d.ts +4 -0
  70. package/dist/commands/PlayCommand.js +6 -0
  71. package/dist/commands/RenderCommand.d.ts +15 -0
  72. package/dist/commands/RenderCommand.js +18 -0
  73. package/dist/commands/RenderFrameCommand.d.ts +17 -0
  74. package/dist/commands/RenderFrameCommand.js +93 -0
  75. package/dist/commands/ReplaceSourceOnTimeCommand.d.ts +4 -0
  76. package/dist/commands/ReplaceSourceOnTimeCommand.js +22 -0
  77. package/dist/commands/SeekCommand.d.ts +15 -0
  78. package/dist/commands/SeekCommand.js +39 -0
  79. package/dist/commands/UpdateComponentCommand.d.ts +4 -0
  80. package/dist/commands/UpdateComponentCommand.js +17 -0
  81. package/dist/components/AnimatedGIF.d.ts +201 -0
  82. package/dist/components/AnimatedGIF.js +391 -0
  83. package/dist/components/Component.svelte.d.ts +33 -0
  84. package/dist/components/Component.svelte.js +152 -0
  85. package/dist/components/ComponentContext.svelte.d.ts +33 -0
  86. package/dist/components/ComponentContext.svelte.js +105 -0
  87. package/dist/components/hooks/AnimationHook.d.ts +25 -0
  88. package/dist/components/hooks/AnimationHook.js +180 -0
  89. package/dist/components/hooks/CanvasShapeHook.d.ts +12 -0
  90. package/dist/components/hooks/CanvasShapeHook.js +229 -0
  91. package/dist/components/hooks/HtmlAnimationHook.d.ts +8 -0
  92. package/dist/components/hooks/HtmlAnimationHook.js +70 -0
  93. package/dist/components/hooks/HtmlTextHook.d.ts +16 -0
  94. package/dist/components/hooks/HtmlTextHook.js +102 -0
  95. package/dist/components/hooks/HtmlToCanvasHook.d.ts +16 -0
  96. package/dist/components/hooks/HtmlToCanvasHook.js +148 -0
  97. package/dist/components/hooks/ImageHook.d.ts +10 -0
  98. package/dist/components/hooks/ImageHook.js +45 -0
  99. package/dist/components/hooks/MediaHook.d.ts +15 -0
  100. package/dist/components/hooks/MediaHook.js +252 -0
  101. package/dist/components/hooks/MediaSeekingHook.d.ts +12 -0
  102. package/dist/components/hooks/MediaSeekingHook.js +204 -0
  103. package/dist/components/hooks/PixiDisplayObjectHook.d.ts +15 -0
  104. package/dist/components/hooks/PixiDisplayObjectHook.js +77 -0
  105. package/dist/components/hooks/PixiGifHook.d.ts +15 -0
  106. package/dist/components/hooks/PixiGifHook.js +97 -0
  107. package/dist/components/hooks/PixiProgressShapeHook.d.ts +12 -0
  108. package/dist/components/hooks/PixiProgressShapeHook.js +128 -0
  109. package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.d.ts +21 -0
  110. package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.js +210 -0
  111. package/dist/components/hooks/PixiTextureHook.d.ts +7 -0
  112. package/dist/components/hooks/PixiTextureHook.js +29 -0
  113. package/dist/components/hooks/PixiVideoTextureHook.d.ts +10 -0
  114. package/dist/components/hooks/PixiVideoTextureHook.js +35 -0
  115. package/dist/components/hooks/SubtitlesHook.d.ts +88 -0
  116. package/dist/components/hooks/SubtitlesHook.js +199 -0
  117. package/dist/components/hooks/VerifyGifHook.d.ts +7 -0
  118. package/dist/components/hooks/VerifyGifHook.js +27 -0
  119. package/dist/components/hooks/VerifyImageHook.d.ts +7 -0
  120. package/dist/components/hooks/VerifyImageHook.js +27 -0
  121. package/dist/components/hooks/VerifyMediaHook.d.ts +7 -0
  122. package/dist/components/hooks/VerifyMediaHook.js +21 -0
  123. package/dist/components/hooks/shapes/progress/CustomProgressRenderer.d.ts +8 -0
  124. package/dist/components/hooks/shapes/progress/CustomProgressRenderer.js +53 -0
  125. package/dist/components/hooks/shapes/progress/DoubleProgressRenderer.d.ts +8 -0
  126. package/dist/components/hooks/shapes/progress/DoubleProgressRenderer.js +69 -0
  127. package/dist/components/hooks/shapes/progress/LinearProgressRenderer.d.ts +8 -0
  128. package/dist/components/hooks/shapes/progress/LinearProgressRenderer.js +60 -0
  129. package/dist/components/hooks/shapes/progress/PerimeterProgressRenderer.d.ts +9 -0
  130. package/dist/components/hooks/shapes/progress/PerimeterProgressRenderer.js +213 -0
  131. package/dist/components/hooks/shapes/progress/ProgressRenderer.d.ts +17 -0
  132. package/dist/components/hooks/shapes/progress/ProgressRenderer.js +75 -0
  133. package/dist/components/hooks/shapes/progress/RadialProgressRenderer.d.ts +8 -0
  134. package/dist/components/hooks/shapes/progress/RadialProgressRenderer.js +50 -0
  135. package/dist/components/hooks/shapes/progress/index.d.ts +6 -0
  136. package/dist/components/hooks/shapes/progress/index.js +6 -0
  137. package/dist/composers/componentComposer.d.ts +55 -0
  138. package/dist/composers/componentComposer.js +118 -0
  139. package/dist/composers/layerComposer.d.ts +46 -0
  140. package/dist/composers/layerComposer.js +79 -0
  141. package/dist/composers/sceneComposer.d.ts +48 -0
  142. package/dist/composers/sceneComposer.js +92 -0
  143. package/dist/constants.d.ts +12 -0
  144. package/dist/constants.js +14 -0
  145. package/dist/directors/ComponentDirector.d.ts +20 -0
  146. package/dist/directors/ComponentDirector.js +86 -0
  147. package/dist/factories/SceneBuilderFactory.d.ts +15 -0
  148. package/dist/factories/SceneBuilderFactory.js +51 -0
  149. package/dist/fonts/GoogleFontsProvider.d.ts +12 -0
  150. package/dist/fonts/GoogleFontsProvider.js +125 -0
  151. package/dist/fonts/fontLoader.d.ts +15 -0
  152. package/dist/fonts/fontLoader.js +41 -0
  153. package/dist/fonts/types.d.ts +1 -0
  154. package/dist/fonts/types.js +1 -0
  155. package/dist/index.d.ts +11 -0
  156. package/dist/index.js +14 -0
  157. package/dist/layers/Layer.svelte.d.ts +8492 -0
  158. package/dist/layers/Layer.svelte.js +125 -0
  159. package/dist/managers/AppManager.svelte.d.ts +23 -0
  160. package/dist/managers/AppManager.svelte.js +89 -0
  161. package/dist/managers/ComponentsManager.svelte.d.ts +49 -0
  162. package/dist/managers/ComponentsManager.svelte.js +247 -0
  163. package/dist/managers/DomManager.d.ts +18 -0
  164. package/dist/managers/DomManager.js +73 -0
  165. package/dist/managers/EventManager.d.ts +7 -0
  166. package/dist/managers/EventManager.js +22 -0
  167. package/dist/managers/LayersManager.svelte.d.ts +8499 -0
  168. package/dist/managers/LayersManager.svelte.js +176 -0
  169. package/dist/managers/MediaManager.d.ts +32 -0
  170. package/dist/managers/MediaManager.js +243 -0
  171. package/dist/managers/RenderManager.d.ts +23 -0
  172. package/dist/managers/RenderManager.js +59 -0
  173. package/dist/managers/StateManager.svelte.d.ts +8746 -0
  174. package/dist/managers/StateManager.svelte.js +272 -0
  175. package/dist/managers/SubtitlesManager.svelte.d.ts +261 -0
  176. package/dist/managers/SubtitlesManager.svelte.js +1385 -0
  177. package/dist/managers/TimeManager.svelte.d.ts +6 -0
  178. package/dist/managers/TimeManager.svelte.js +18 -0
  179. package/dist/managers/TimelineManager.svelte.d.ts +25 -0
  180. package/dist/managers/TimelineManager.svelte.js +152 -0
  181. package/dist/registers.d.ts +12 -0
  182. package/dist/registers.js +29 -0
  183. package/dist/schemas/runtime/index.d.ts +3 -0
  184. package/dist/schemas/runtime/index.js +4 -0
  185. package/dist/schemas/runtime/types.d.ts +323 -0
  186. package/dist/schemas/runtime/types.js +12 -0
  187. package/dist/schemas/scene/animations.d.ts +89738 -0
  188. package/dist/schemas/scene/animations.js +211 -0
  189. package/dist/schemas/scene/components.js +515 -0
  190. package/dist/schemas/scene/core.js +160 -0
  191. package/dist/schemas/scene/index.d.ts +22 -0
  192. package/dist/schemas/scene/index.js +10 -0
  193. package/dist/schemas/scene/properties.d.ts +914 -0
  194. package/dist/schemas/scene/properties.js +398 -0
  195. package/dist/schemas/scene/subtitles.d.ts +1141 -0
  196. package/dist/schemas/scene/subtitles.js +111 -0
  197. package/dist/schemas/scene/utils.d.ts +1 -0
  198. package/dist/schemas/scene/utils.js +5 -0
  199. package/dist/seeds/SeedFactory.d.ts +59 -0
  200. package/dist/seeds/SeedFactory.js +99 -0
  201. package/dist/seeds/index.d.ts +8 -0
  202. package/dist/seeds/index.js +8 -0
  203. package/dist/transformers/ColorTransformer.d.ts +5 -0
  204. package/dist/transformers/ColorTransformer.js +67 -0
  205. package/dist/transformers/PixiColorTransformer.d.ts +22 -0
  206. package/dist/transformers/PixiColorTransformer.js +104 -0
  207. package/dist/utils/canvas.d.ts +6 -0
  208. package/dist/utils/canvas.js +18 -0
  209. package/dist/utils/document.d.ts +2 -0
  210. package/dist/utils/document.js +36 -0
  211. package/dist/utils/emoji.d.ts +10 -0
  212. package/dist/utils/emoji.js +51 -0
  213. package/dist/utils/html.d.ts +4 -0
  214. package/dist/utils/html.js +45 -0
  215. package/dist/utils/svgGenerator.d.ts +20 -0
  216. package/dist/utils/svgGenerator.js +103 -0
  217. package/dist/utils/utils.d.ts +5 -0
  218. package/dist/utils/utils.js +125 -0
  219. package/package.json +96 -0
@@ -0,0 +1,1385 @@
1
+ import { EventManager } from './EventManager.js';
2
+ import { normalizeSubtitle } from '../adapters/subtitleHelpers.js';
3
+ import { SubtitleWithCompactWordsShape } from '..';
4
+ import { get, omit } from 'lodash-es';
5
+ import { v4 as uuidv4 } from 'uuid';
6
+ // Default settings
7
+ const DEFAULT_SETTINGS = {
8
+ punctuation: true,
9
+ mergeGap: 0.3
10
+ };
11
+ // Helper functions for word timing validation and adjustment
12
+ function splitTextIntoWords(text) {
13
+ return text.split(/\s+/).filter((word) => word.trim() !== '');
14
+ }
15
+ function generateWordsFromText(text, startTime, endTime) {
16
+ const words = splitTextIntoWords(text);
17
+ if (words.length === 0)
18
+ return [];
19
+ const totalChars = words.reduce((sum, word) => sum + word.length, 0);
20
+ const duration = endTime - startTime;
21
+ const timePerChar = duration / totalChars;
22
+ let currentTime = startTime;
23
+ return words.map((word) => {
24
+ const wordDuration = word.length * timePerChar;
25
+ const wordStart = currentTime;
26
+ const wordEnd = currentTime + wordDuration;
27
+ currentTime = wordEnd;
28
+ return [
29
+ word,
30
+ parseFloat(wordStart.toFixed(6)),
31
+ parseFloat(wordEnd.toFixed(6))
32
+ ];
33
+ });
34
+ }
35
+ function validateAndAdjustWordTiming(originalWords, text, startTime, endTime) {
36
+ // If no words provided, generate from text
37
+ if (!originalWords || originalWords.length === 0) {
38
+ return generateWordsFromText(text, startTime, endTime);
39
+ }
40
+ // Check if words match the text
41
+ const textWords = splitTextIntoWords(text);
42
+ const wordTexts = originalWords.map((w) => w[0]);
43
+ // If text doesn't match, fall back to character-based distribution
44
+ if (textWords.length !== wordTexts.length ||
45
+ !textWords.every((word, i) => word === wordTexts[i])) {
46
+ return generateWordsFromText(text, startTime, endTime);
47
+ }
48
+ // Check timing boundaries
49
+ const firstWordStart = originalWords[0]?.[1] ?? startTime;
50
+ const lastWordEnd = originalWords[originalWords.length - 1]?.[2] ?? endTime;
51
+ // If timing fits perfectly within bounds, use original
52
+ if (firstWordStart >= startTime && lastWordEnd <= endTime) {
53
+ // Check if words cover the full duration, if not extend last word
54
+ if (lastWordEnd < endTime) {
55
+ const adjustedWords = [...originalWords];
56
+ const lastIndex = adjustedWords.length - 1;
57
+ const lastWord = adjustedWords[lastIndex];
58
+ adjustedWords[lastIndex] = [
59
+ lastWord[0],
60
+ lastWord[1],
61
+ endTime,
62
+ ...(lastWord.length > 3 ? [lastWord[3]] : [])
63
+ ];
64
+ return adjustedWords;
65
+ }
66
+ return originalWords;
67
+ }
68
+ // Need to adjust timing - compress/shift to fit within bounds
69
+ const originalDuration = lastWordEnd - firstWordStart;
70
+ const targetDuration = endTime - startTime;
71
+ if (originalDuration <= 0) {
72
+ // Fallback if timing is invalid
73
+ return generateWordsFromText(text, startTime, endTime);
74
+ }
75
+ // Calculate compression ratio and offset
76
+ const compressionRatio = targetDuration / originalDuration;
77
+ const timeOffset = startTime - firstWordStart;
78
+ return originalWords.map((word) => {
79
+ const [text, originalStart, originalEnd, ...metadata] = word;
80
+ // Apply offset and compression
81
+ const adjustedStart = startTime + (originalStart - firstWordStart) * compressionRatio;
82
+ const adjustedEnd = startTime + (originalEnd - firstWordStart) * compressionRatio;
83
+ const result = [
84
+ text,
85
+ parseFloat(adjustedStart.toFixed(6)),
86
+ parseFloat(adjustedEnd.toFixed(6))
87
+ ];
88
+ // Preserve metadata if it exists
89
+ if (metadata.length > 0 && metadata[0] !== undefined && metadata[0] !== null) {
90
+ result.push(metadata[0]);
91
+ }
92
+ return result;
93
+ });
94
+ }
95
+ // Natural, word-safe splitting helper: prefers punctuation/space boundaries and never splits inside a word
96
+ function findNaturalSplitIndex(text, targetChars, tolerance = 12) {
97
+ const length = text.length;
98
+ if (length <= targetChars)
99
+ return -1;
100
+ const windowStart = Math.max(1, targetChars - tolerance);
101
+ const windowEnd = Math.min(length - 1, targetChars + tolerance);
102
+ const strong = new Set(['.', '!', '?']);
103
+ const medium = new Set([',', ';', ':']);
104
+ const advanceAfterBreak = (i) => {
105
+ let j = i + 1;
106
+ while (j < length && text[j] === ' ')
107
+ j++;
108
+ return j;
109
+ };
110
+ const isWordChar = (ch) => !!ch && /[\p{L}\p{N}_]/u.test(ch);
111
+ const punctuationOnly = (s) => /^[\s,.!?:;]+$/.test(s);
112
+ const validSplit = (pos) => {
113
+ if (pos <= 0 || pos >= length)
114
+ return false;
115
+ // never split inside a word
116
+ if (isWordChar(text[pos - 1]) && isWordChar(text[pos]))
117
+ return false;
118
+ const first = text.slice(0, pos).trim();
119
+ const second = text.slice(pos).trim();
120
+ if (first.length === 0 || second.length === 0)
121
+ return false;
122
+ // avoid creating punctuation-only segments
123
+ if (punctuationOnly(first) || punctuationOnly(second))
124
+ return false;
125
+ // avoid starting next with punctuation when possible
126
+ if (/^[,.!?:;]/.test(second))
127
+ return false;
128
+ return true;
129
+ };
130
+ // 1) Strong punctuation, search backward then forward
131
+ for (let i = Math.min(targetChars, windowEnd); i >= windowStart; i--) {
132
+ if (strong.has(text[i])) {
133
+ const splitAt = advanceAfterBreak(i);
134
+ if (validSplit(splitAt))
135
+ return splitAt;
136
+ }
137
+ }
138
+ for (let i = Math.max(targetChars + 1, windowStart); i <= windowEnd; i++) {
139
+ if (strong.has(text[i])) {
140
+ const splitAt = advanceAfterBreak(i);
141
+ if (validSplit(splitAt))
142
+ return splitAt;
143
+ }
144
+ }
145
+ // 2) Medium punctuation, search backward then forward
146
+ for (let i = Math.min(targetChars, windowEnd); i >= windowStart; i--) {
147
+ if (medium.has(text[i])) {
148
+ const splitAt = advanceAfterBreak(i);
149
+ if (validSplit(splitAt))
150
+ return splitAt;
151
+ }
152
+ }
153
+ for (let i = Math.max(targetChars + 1, windowStart); i <= windowEnd; i++) {
154
+ if (medium.has(text[i])) {
155
+ const splitAt = advanceAfterBreak(i);
156
+ if (validSplit(splitAt))
157
+ return splitAt;
158
+ }
159
+ }
160
+ // 3) Whitespace, prefer splitting after a space
161
+ for (let i = Math.min(targetChars, windowEnd); i >= windowStart; i--) {
162
+ if (text[i] === ' ') {
163
+ const splitAt = i + 1; // next non-space is handled by advance per earlier cases
164
+ if (validSplit(splitAt))
165
+ return splitAt;
166
+ }
167
+ }
168
+ for (let i = Math.max(targetChars + 1, windowStart); i <= windowEnd; i++) {
169
+ if (text[i] === ' ') {
170
+ const splitAt = i + 1;
171
+ if (validSplit(splitAt))
172
+ return splitAt;
173
+ }
174
+ }
175
+ // 4) Wider search for nearest whitespace anywhere in the string
176
+ for (let i = targetChars; i >= 1; i--) {
177
+ if (text[i - 1] === ' ' && validSplit(i))
178
+ return i;
179
+ }
180
+ for (let i = targetChars + 1; i < length; i++) {
181
+ if (text[i - 1] === ' ' && validSplit(i))
182
+ return i;
183
+ }
184
+ // If no safe boundary exists (e.g., single long word), do not split
185
+ return -1;
186
+ }
187
+ function buildSubtitlesManager(timeManager, eventManager, sceneData, subtitles) {
188
+ // Source of truth - assetId -> lang -> id -> subtitle
189
+ let index = $state({});
190
+ // Settings state
191
+ const sceneSubtitlesSettings = get(sceneData, 'settings.subtitles', {});
192
+ let settings = $state({ ...DEFAULT_SETTINGS, ...sceneSubtitlesSettings });
193
+ const configuredMergeGap = typeof settings?.mergeGap === 'number' ? settings.mergeGap : (DEFAULT_SETTINGS.mergeGap ?? 0);
194
+ // Cache for validated subtitle IDs - tracks which subtitles have clean/validated words
195
+ // This optimization prevents expensive word validation on every reactive update.
196
+ // When a subtitle is modified (text, timing, split, merge), it's marked dirty.
197
+ // The derived subtitlesData block only validates dirty subtitles, skipping clean ones.
198
+ // This reduces a 50ms+ operation to <1ms for most subtitle edits.
199
+ let validatedSubtitles = new Set();
200
+ // Cache for validated subtitle collections per asset/language
201
+ // Key format: "assetId:lang"
202
+ // This prevents recalculating unchanged languages when editing a single language
203
+ let validatedCollections = new Map();
204
+ // Mark subtitle as dirty (needs word validation)
205
+ function markSubtitleDirty(subtitleId) {
206
+ validatedSubtitles.delete(subtitleId);
207
+ }
208
+ function markAllSubtitlesDirty() {
209
+ validatedSubtitles.clear();
210
+ }
211
+ // Mark subtitle as clean (words are validated)
212
+ function markSubtitleClean(subtitleId) {
213
+ validatedSubtitles.add(subtitleId);
214
+ }
215
+ function markAllLanguagesDirty() {
216
+ validatedCollections.clear();
217
+ }
218
+ // Mark an entire asset/language as needing revalidation
219
+ function markLanguageDirty(assetId, lang) {
220
+ const key = `${assetId}:${lang}`;
221
+ validatedCollections.delete(key);
222
+ }
223
+ // Cache validated collection for an asset/language
224
+ function cacheValidatedCollection(assetId, lang, collection) {
225
+ const key = `${assetId}:${lang}`;
226
+ validatedCollections.set(key, collection);
227
+ }
228
+ // Get cached collection if available
229
+ function getCachedCollection(assetId, lang) {
230
+ const key = `${assetId}:${lang}`;
231
+ return validatedCollections.get(key);
232
+ }
233
+ // Derived from index - converts to collection format with word timing validation
234
+ const subtitlesData = $derived.by(() => {
235
+ const result = {};
236
+ for (const [assetId, assetIndex] of Object.entries(index)) {
237
+ const collection = {};
238
+ for (const [lang, langIndex] of Object.entries(assetIndex)) {
239
+ // Check if we have a cached collection for this asset/language
240
+ const cached = getCachedCollection(assetId, lang);
241
+ const subsInOrder = Object.values(langIndex).sort((a, b) => a.start_at - b.start_at);
242
+ // Check if any subtitle in this language is dirty
243
+ const hasDirtySubtitles = subsInOrder.some((sub) => sub && !validatedSubtitles.has(sub.id));
244
+ // Use cached collection if available and no dirty subtitles
245
+ if (cached && !hasDirtySubtitles) {
246
+ collection[lang] = cached;
247
+ continue;
248
+ }
249
+ // Need to revalidate this language
250
+ const validatedCollection = subsInOrder.map((subtitle, i) => {
251
+ // Adjust end time based on mergeGap relative to the next subtitle
252
+ const next = subsInOrder[i + 1];
253
+ let endAt = subtitle?.end_at;
254
+ if (endAt && next) {
255
+ const gap = next?.start_at - endAt;
256
+ if (gap >= 0 && gap <= configuredMergeGap) {
257
+ endAt = next?.start_at;
258
+ }
259
+ }
260
+ // Only validate and adjust word timing if subtitle is dirty (not in cache)
261
+ let adjustedWords = [];
262
+ if (subtitle && validatedSubtitles.has(subtitle.id)) {
263
+ // Subtitle is clean, use existing words
264
+ adjustedWords = (subtitle?.words || []);
265
+ }
266
+ else if (subtitle) {
267
+ // Subtitle is dirty, validate and cache
268
+ adjustedWords = validateAndAdjustWordTiming((subtitle.words || []), subtitle.text, subtitle.start_at, endAt);
269
+ markSubtitleClean(subtitle.id);
270
+ }
271
+ // Return subtitle with adjusted words (never modify the source)
272
+ return {
273
+ ...subtitle,
274
+ end_at: endAt,
275
+ words: adjustedWords
276
+ };
277
+ });
278
+ // Cache the validated collection
279
+ cacheValidatedCollection(assetId, lang, validatedCollection);
280
+ collection[lang] = validatedCollection;
281
+ }
282
+ result[assetId] = collection;
283
+ }
284
+ return result;
285
+ });
286
+ // Derived from subtitlesData - sorted arrays for time-based lookups
287
+ const sorted = $derived.by(() => {
288
+ const result = {};
289
+ for (const [assetId, collection] of Object.entries(subtitlesData)) {
290
+ const assetSorted = {};
291
+ for (const [lang, subs] of Object.entries(collection)) {
292
+ assetSorted[lang] = [...subs].sort((a, b) => a.start_at - b.start_at);
293
+ }
294
+ result[assetId] = assetSorted;
295
+ }
296
+ return result;
297
+ });
298
+ // Initialize with provided data - do this synchronously before derived computations
299
+ function initializeData() {
300
+ if (Object.keys(subtitles).length > 0) {
301
+ const firstValue = Object.values(subtitles)[0];
302
+ if (Array.isArray(firstValue)) {
303
+ // Legacy format: Record<string, Subtitle[]>
304
+ processLegacySubtitles(subtitles);
305
+ }
306
+ else {
307
+ // New format: Record<string, SubtitleCollection>
308
+ processSubtitleCollections(subtitles);
309
+ }
310
+ }
311
+ }
312
+ // Initialize immediately
313
+ initializeData();
314
+ // Process subtitles from scene data assets (async)
315
+ initializeSceneAssetSubtitles(sceneData);
316
+ function recomputeIndex() {
317
+ const newIndex = {};
318
+ for (const [assetId, assetIndex] of Object.entries(index)) {
319
+ const newAssetIndex = {};
320
+ for (const [lang, langIndex] of Object.entries(assetIndex)) {
321
+ // Get current subtitles and sort them by start_at
322
+ const subs = Object.values(langIndex).sort((a, b) => a.start_at - b.start_at);
323
+ // Create new langIndex with insertion order matching sorted order
324
+ const newLangIndex = {};
325
+ for (const sub of subs) {
326
+ newLangIndex[sub.id] = sub;
327
+ }
328
+ newAssetIndex[lang] = newLangIndex;
329
+ }
330
+ newIndex[assetId] = newAssetIndex;
331
+ }
332
+ // Assign the new index to trigger reactivity
333
+ index = newIndex;
334
+ }
335
+ // Optimized version that only recomputes a specific asset/language
336
+ function recomputeIndexForLanguage(assetId, lang) {
337
+ const assetIndex = index[assetId];
338
+ if (!assetIndex)
339
+ return;
340
+ const langIndex = assetIndex[lang];
341
+ if (!langIndex)
342
+ return;
343
+ // Get current subtitles and sort them by start_at
344
+ const subs = Object.values(langIndex).sort((a, b) => a.start_at - b.start_at);
345
+ // Create new langIndex with insertion order matching sorted order
346
+ const newLangIndex = {};
347
+ for (const sub of subs) {
348
+ newLangIndex[sub.id] = sub;
349
+ }
350
+ // Update only the affected language in the index
351
+ index = {
352
+ ...index,
353
+ [assetId]: {
354
+ ...assetIndex,
355
+ [lang]: newLangIndex
356
+ }
357
+ };
358
+ }
359
+ function processSubtitleCollections(collections) {
360
+ const newIndex = {};
361
+ for (const [assetId, collection] of Object.entries(collections)) {
362
+ const assetIndex = {};
363
+ for (const [lang, subs] of Object.entries(collection)) {
364
+ const langIndex = {};
365
+ for (const sub of subs) {
366
+ langIndex[sub.id] = normalizeSubtitle(sub);
367
+ }
368
+ assetIndex[lang] = langIndex;
369
+ }
370
+ newIndex[assetId] = assetIndex;
371
+ }
372
+ // Assign the new index to trigger reactivity
373
+ index = newIndex;
374
+ }
375
+ function processLegacySubtitles(legacySubtitles) {
376
+ // Group by assetId, handling both assetId and assetId-languageCode patterns
377
+ const assetGroups = {};
378
+ for (const [key, subtitles] of Object.entries(legacySubtitles)) {
379
+ let assetId;
380
+ let languageCode;
381
+ // Check if key contains language code (assetId-languageCode pattern)
382
+ const dashIndex = key.lastIndexOf('-');
383
+ if (dashIndex > 0 && dashIndex < key.length - 1) {
384
+ // Potential assetId-languageCode format
385
+ assetId = key.substring(0, dashIndex);
386
+ languageCode = key.substring(dashIndex + 1);
387
+ }
388
+ else {
389
+ // Plain assetId format
390
+ assetId = key;
391
+ languageCode = 'default';
392
+ }
393
+ // Group subtitles by assetId
394
+ if (!assetGroups[assetId]) {
395
+ assetGroups[assetId] = {};
396
+ }
397
+ assetGroups[assetId][languageCode] = subtitles.map((sub) => normalizeSubtitle(sub));
398
+ }
399
+ // Create new index and populate it
400
+ const newIndex = {};
401
+ for (const [assetId, languageGroups] of Object.entries(assetGroups)) {
402
+ const assetIndex = {};
403
+ for (const [lang, subs] of Object.entries(languageGroups)) {
404
+ const langIndex = {};
405
+ for (const sub of subs) {
406
+ if (sub && sub.id) {
407
+ langIndex[sub.id] = sub;
408
+ }
409
+ }
410
+ assetIndex[lang] = langIndex;
411
+ }
412
+ newIndex[assetId] = assetIndex;
413
+ }
414
+ // Assign the new index to trigger reactivity
415
+ index = newIndex;
416
+ }
417
+ function initializeSceneAssetSubtitles(sceneData) {
418
+ // Process async without blocking constructor
419
+ processSceneAssetSubtitles(sceneData).catch((error) => {
420
+ console.error('SubtitlesManager: Error processing scene asset subtitles:', error);
421
+ });
422
+ }
423
+ async function processSceneAssetSubtitles(sceneData) {
424
+ for (const asset of sceneData.assets || []) {
425
+ if (asset.subtitles && asset.subtitles.length > 0) {
426
+ const assetId = asset.id;
427
+ // Prepare language groups for this asset
428
+ const languageGroups = {};
429
+ for (const subtitleEntry of asset.subtitles) {
430
+ if (subtitleEntry.url) {
431
+ // Handle URL-based subtitles - fetch from URL and process
432
+ const fetchedSubtitles = await fetchSubtitlesFromUrl(subtitleEntry.url);
433
+ if (fetchedSubtitles) {
434
+ const languageCode = subtitleEntry.language_code || 'default';
435
+ languageGroups[languageCode] = fetchedSubtitles;
436
+ }
437
+ continue;
438
+ }
439
+ if (subtitleEntry.subtitles) {
440
+ const languageCode = subtitleEntry.language_code || 'default';
441
+ languageGroups[languageCode] = subtitleEntry.subtitles.map((sub) => normalizeSubtitle(sub));
442
+ }
443
+ }
444
+ // If we have subtitle data, replace existing collection
445
+ if (Object.keys(languageGroups).length > 0) {
446
+ // Check if subtitles already exist for this asset and warn about replacement
447
+ const existingIndex = index[assetId];
448
+ if (existingIndex && Object.keys(existingIndex).length > 0) {
449
+ console.warn(`SubtitlesManager: Replacing existing subtitles for asset ${assetId} with scene data subtitles`);
450
+ }
451
+ const assetIndex = {};
452
+ for (const [lang, subs] of Object.entries(languageGroups)) {
453
+ const langIndex = {};
454
+ for (const sub of subs) {
455
+ langIndex[sub.id] = sub;
456
+ }
457
+ assetIndex[lang] = langIndex;
458
+ }
459
+ index = {
460
+ ...index,
461
+ [assetId]: assetIndex
462
+ };
463
+ // Emit event for async updates
464
+ eventManager.emit('subtitleschange');
465
+ recomputeIndex();
466
+ }
467
+ }
468
+ }
469
+ }
470
+ async function fetchSubtitlesFromUrl(url) {
471
+ try {
472
+ const response = await fetch(url);
473
+ // Skip if not successful (404, error, etc.)
474
+ if (!response.ok) {
475
+ console.warn(`SubtitlesManager: Failed to fetch subtitles from ${url}: ${response.status} ${response.statusText}`);
476
+ return null;
477
+ }
478
+ const data = await response.json();
479
+ // Validate that the data matches the expected Subtitle[] format
480
+ if (!isValidSubtitlesArray(data)) {
481
+ console.warn(`SubtitlesManager: Invalid subtitle format from ${url}, skipping`);
482
+ return null;
483
+ }
484
+ return data;
485
+ }
486
+ catch (error) {
487
+ console.warn(`SubtitlesManager: Error fetching subtitles from ${url}:`, error);
488
+ return null;
489
+ }
490
+ }
491
+ function isValidSubtitlesArray(data) {
492
+ // Check if it's an array
493
+ if (!Array.isArray(data)) {
494
+ return false;
495
+ }
496
+ // Check if each item has the required Subtitle properties
497
+ return data.every((item) => {
498
+ const result = SubtitleWithCompactWordsShape.safeParse(item);
499
+ if (!result.success) {
500
+ console.warn(`SubtitlesManager: Invalid subtitle format, skipping`);
501
+ return false;
502
+ }
503
+ return true;
504
+ });
505
+ }
506
+ function getDefaultLanguage(assetId) {
507
+ return 'default'; // or logic to find first available
508
+ }
509
+ // Public interface
510
+ return {
511
+ get data() {
512
+ return subtitlesData;
513
+ },
514
+ get settings() {
515
+ return { ...settings };
516
+ },
517
+ updateSettings(newSettings) {
518
+ settings = { ...settings, ...newSettings };
519
+ eventManager.emit('subtitlessettingschange');
520
+ },
521
+ getAssetSubtitlesForSceneData(assetId) {
522
+ const collection = subtitlesData[assetId];
523
+ if (!collection) {
524
+ return [];
525
+ }
526
+ const result = [];
527
+ for (const [languageCode, subtitles] of Object.entries(collection)) {
528
+ result.push({
529
+ language_code: languageCode,
530
+ subtitles: subtitles
531
+ });
532
+ }
533
+ return result;
534
+ },
535
+ getAssetSubtitles(assetId) {
536
+ if (!subtitlesData[assetId]) {
537
+ console.warn('-- subtitlesData does not have assetId', assetId, subtitlesData, index, subtitles);
538
+ }
539
+ return subtitlesData[assetId] || {};
540
+ },
541
+ getText(assetId, language = '') {
542
+ const lang = language || getDefaultLanguage(assetId);
543
+ const collection = subtitlesData[assetId];
544
+ if (!collection || !collection[lang]) {
545
+ return '';
546
+ }
547
+ const subtitles = collection[lang];
548
+ if (subtitles.length === 0) {
549
+ return '';
550
+ }
551
+ // Return all subtitle texts joined with spaces
552
+ return subtitles
553
+ .map((sub) => sub.text || '')
554
+ .join(' ')
555
+ .trim();
556
+ },
557
+ setAssetSubtitles(assetId, subtitles) {
558
+ const assetIndex = {};
559
+ const langIndex = {};
560
+ for (const sub of subtitles) {
561
+ langIndex[sub.id] = sub;
562
+ }
563
+ assetIndex['default'] = langIndex;
564
+ index = {
565
+ ...index,
566
+ [assetId]: assetIndex
567
+ };
568
+ recomputeIndex();
569
+ markAllSubtitlesDirty();
570
+ markAllLanguagesDirty();
571
+ eventManager.emit('subtitleschange');
572
+ },
573
+ replaceCollection(assetId, newCollection, language = '') {
574
+ const assetIndex = {};
575
+ for (const [lang, subs] of Object.entries(newCollection)) {
576
+ const langIndex = {};
577
+ for (const sub of subs) {
578
+ langIndex[sub.id] = sub;
579
+ }
580
+ assetIndex[lang] = langIndex;
581
+ }
582
+ index = {
583
+ ...index,
584
+ [assetId]: assetIndex
585
+ };
586
+ recomputeIndex();
587
+ markAllSubtitlesDirty();
588
+ markAllLanguagesDirty();
589
+ eventManager.emit('subtitleschange');
590
+ },
591
+ // Split all subtitles in an asset/language to be near maxChars, using natural breakpoints.
592
+ splitByChars(assetId, language = '', maxChars) {
593
+ const lang = language || getDefaultLanguage(assetId);
594
+ if (!assetId || maxChars <= 0)
595
+ return;
596
+ let list = sorted[assetId]?.[lang] || [];
597
+ if (!list || list.length === 0)
598
+ return;
599
+ const maxOps = 2000;
600
+ let ops = 0;
601
+ // First pass: split long subtitles
602
+ for (let i = 0; i < list.length; i++) {
603
+ let sub = list[i];
604
+ if (!sub || typeof sub.text !== 'string')
605
+ continue;
606
+ while (sub.text.length > maxChars) {
607
+ if (++ops > maxOps) {
608
+ console.warn('SubtitlesManager.splitByChars: aborting due to excessive operations (split)');
609
+ return;
610
+ }
611
+ const tolerance = Math.max(8, Math.floor(maxChars * 0.35));
612
+ const splitAt = findNaturalSplitIndex(sub.text, maxChars, tolerance);
613
+ // Guard against pathological cases
614
+ if (splitAt <= 0 || splitAt >= sub.text.length)
615
+ break;
616
+ this.splitSubtitle(assetId, sub.id, lang, splitAt);
617
+ // Refresh list and current sub reference
618
+ list = sorted[assetId]?.[lang] || [];
619
+ sub = list[i]; // original id keeps first part, second is inserted after
620
+ if (!sub)
621
+ break;
622
+ }
623
+ }
624
+ // Second pass: merge adjacent short subtitles when reasonable
625
+ list = sorted[assetId]?.[lang] || [];
626
+ for (let i = 0; i < list.length; i++) {
627
+ let sub = list[i];
628
+ if (!sub)
629
+ continue;
630
+ const trimmedLen = (sub.text || '').trim().length;
631
+ if (trimmedLen === 0)
632
+ continue;
633
+ const threshold = Math.max(4, Math.floor(maxChars * 0.45));
634
+ if (trimmedLen < threshold) {
635
+ const next = list[i + 1];
636
+ if (next) {
637
+ const combinedLen = (sub.text.trim() + ' ' + (next.text || '').trim()).length;
638
+ const gap = next.start_at - sub.end_at;
639
+ // allow slight overflow and require small temporal gap
640
+ if (combinedLen <= Math.round(maxChars * 1.15) && gap <= 0.7) {
641
+ if (++ops > maxOps) {
642
+ console.warn('SubtitlesManager.splitByChars: aborting due to excessive operations (merge)');
643
+ return;
644
+ }
645
+ this.mergeSubtitles(assetId, sub.id, lang, 'start');
646
+ // After merge, refresh and re-evaluate current index
647
+ list = sorted[assetId]?.[lang] || [];
648
+ i = Math.max(i - 1, -1); // -1 because loop will i++
649
+ continue;
650
+ }
651
+ }
652
+ }
653
+ }
654
+ markAllSubtitlesDirty();
655
+ markLanguageDirty(assetId, lang);
656
+ },
657
+ updateSubtitleProps(assetId, subtitleId, props, language = '') {
658
+ const lang = language || getDefaultLanguage(assetId);
659
+ const sub = index[assetId]?.[lang]?.[subtitleId];
660
+ if (!sub)
661
+ return;
662
+ const updates = omit(props, ['id', 'text', 'words', 'start_at', 'end_at']);
663
+ const updatedSub = {
664
+ ...sub,
665
+ ...updates
666
+ };
667
+ index = {
668
+ ...index,
669
+ [assetId]: {
670
+ ...index[assetId],
671
+ [lang]: {
672
+ ...index[assetId][lang],
673
+ [subtitleId]: updatedSub
674
+ }
675
+ }
676
+ };
677
+ markSubtitleDirty(subtitleId);
678
+ markLanguageDirty(assetId, lang);
679
+ eventManager.emit('subtitlechange', {
680
+ assetId,
681
+ language,
682
+ subtitleId,
683
+ subtitle: updatedSub
684
+ });
685
+ eventManager.emit('subtitleschange');
686
+ },
687
+ updateSubtitleText(assetId, subtitleId, newText, language = '') {
688
+ const lang = language || getDefaultLanguage(assetId);
689
+ const sub = index[assetId]?.[lang]?.[subtitleId];
690
+ if (!sub)
691
+ return;
692
+ const oldWords = (sub.words || []);
693
+ // Filter out empty words from split - use safe filtering
694
+ const newWords = newText.split(' ').filter((word) => word.trim() !== '');
695
+ let newWordsArray;
696
+ // Handle empty text case
697
+ if (newText.trim() === '' || newWords.length === 0) {
698
+ newWordsArray = [];
699
+ }
700
+ else if (newWords.length === oldWords.length) {
701
+ // Preserve timings with safe tuple access
702
+ newWordsArray = newWords.map((word, i) => {
703
+ const oldWord = oldWords[i];
704
+ if (!oldWord) {
705
+ // Fallback if old word doesn't exist
706
+ return [word, sub.start_at, sub.end_at];
707
+ }
708
+ // Safely access tuple elements
709
+ const startTime = oldWord[1] ?? sub.start_at;
710
+ const endTime = oldWord[2] ?? sub.end_at;
711
+ const metadata = oldWord.length > 3 ? oldWord[3] : undefined;
712
+ // Build tuple with proper type safety
713
+ const tuple = [word, startTime, endTime];
714
+ if (metadata !== undefined && metadata !== null) {
715
+ tuple.push(metadata);
716
+ }
717
+ return tuple;
718
+ });
719
+ }
720
+ else {
721
+ // Redistribute proportionally
722
+ const duration = sub.end_at - sub.start_at;
723
+ const step = duration / newWords.length;
724
+ newWordsArray = newWords.map((word, i) => [
725
+ word,
726
+ parseFloat((sub.start_at + i * step).toFixed(6)),
727
+ parseFloat((sub.start_at + (i + 1) * step).toFixed(6))
728
+ ]);
729
+ }
730
+ // Create a NEW object - this is the key for reactivity!
731
+ const updatedSub = {
732
+ ...sub,
733
+ text: newText,
734
+ words: newWordsArray
735
+ };
736
+ // Mark as dirty since words changed
737
+ markSubtitleDirty(subtitleId);
738
+ markLanguageDirty(assetId, lang);
739
+ // Update the subtitle - this will trigger reactivity
740
+ index = {
741
+ ...index,
742
+ [assetId]: {
743
+ ...index[assetId],
744
+ [lang]: {
745
+ ...index[assetId][lang],
746
+ [subtitleId]: updatedSub
747
+ }
748
+ }
749
+ };
750
+ eventManager.emit('subtitleschange');
751
+ eventManager.emit('subtitlechange', {
752
+ assetId,
753
+ language,
754
+ subtitleId,
755
+ subtitle: updatedSub
756
+ });
757
+ },
758
+ getSubtitle(assetId, timeOrId, language = '') {
759
+ const lang = language || getDefaultLanguage(assetId);
760
+ const langIndex = index[assetId]?.[lang];
761
+ if (typeof timeOrId === 'string') {
762
+ return langIndex?.[timeOrId];
763
+ }
764
+ else {
765
+ const sortedSubs = sorted[assetId]?.[lang] || [];
766
+ // Binary search for time
767
+ let low = 0, high = sortedSubs.length - 1;
768
+ while (low <= high) {
769
+ const mid = Math.floor((low + high) / 2);
770
+ const sub = sortedSubs[mid];
771
+ if (timeOrId >= sub.start_at && timeOrId <= sub.end_at)
772
+ return sub;
773
+ if (timeOrId < sub.start_at)
774
+ high = mid - 1;
775
+ else
776
+ low = mid + 1;
777
+ }
778
+ return undefined;
779
+ }
780
+ },
781
+ setStart(assetId, subtitleId, start, language = '') {
782
+ const lang = language || getDefaultLanguage(assetId);
783
+ const sub = this.getSubtitle(assetId, subtitleId, language);
784
+ if (sub) {
785
+ const updatedSub = {
786
+ ...sub,
787
+ start_at: timeManager.transformTime(start)
788
+ };
789
+ // Mark as dirty since timing changed (might affect word validation)
790
+ markSubtitleDirty(subtitleId);
791
+ markLanguageDirty(assetId, lang);
792
+ const assetIndex = index[assetId];
793
+ if (assetIndex) {
794
+ const newLangIndex = { ...assetIndex[lang] };
795
+ newLangIndex[subtitleId] = updatedSub;
796
+ index = {
797
+ ...index,
798
+ [assetId]: {
799
+ ...assetIndex,
800
+ [lang]: newLangIndex
801
+ }
802
+ };
803
+ recomputeIndexForLanguage(assetId, lang);
804
+ eventManager.emit('subtitleschange');
805
+ eventManager.emit('subtitlechange', {
806
+ assetId,
807
+ language,
808
+ subtitleId,
809
+ subtitle: updatedSub
810
+ });
811
+ }
812
+ }
813
+ },
814
+ setEnd(assetId, subtitleId, end, language = '') {
815
+ const lang = language || getDefaultLanguage(assetId);
816
+ const sub = this.getSubtitle(assetId, subtitleId, language);
817
+ if (sub) {
818
+ const updatedSub = {
819
+ ...sub,
820
+ end_at: timeManager.transformTime(end)
821
+ };
822
+ // Mark as dirty since timing changed (might affect word validation)
823
+ markSubtitleDirty(subtitleId);
824
+ markLanguageDirty(assetId, lang);
825
+ const assetIndex = index[assetId];
826
+ if (assetIndex) {
827
+ const newLangIndex = { ...assetIndex[lang] };
828
+ newLangIndex[subtitleId] = updatedSub;
829
+ index = {
830
+ ...index,
831
+ [assetId]: {
832
+ ...assetIndex,
833
+ [lang]: newLangIndex
834
+ }
835
+ };
836
+ recomputeIndexForLanguage(assetId, lang);
837
+ eventManager.emit('subtitleschange');
838
+ eventManager.emit('subtitlechange', {
839
+ assetId,
840
+ language,
841
+ subtitleId,
842
+ subtitle: updatedSub
843
+ });
844
+ }
845
+ }
846
+ },
847
+ splitSubtitle(assetId, subtitleId, language = '', splitAt) {
848
+ const lang = language || getDefaultLanguage(assetId);
849
+ const sub = this.getSubtitle(assetId, subtitleId, language);
850
+ if (!sub) {
851
+ console.warn(`SubtitlesManager: Subtitle ${subtitleId} not found for asset ${assetId}`);
852
+ return;
853
+ }
854
+ // Ignore split if splitAt is 0 or after the last character
855
+ if (splitAt <= 0 || splitAt >= sub.text.length) {
856
+ console.warn(`SubtitlesManager: Invalid split position ${splitAt} for subtitle with length ${sub.text.length}`);
857
+ return;
858
+ }
859
+ const words = (sub.words || []);
860
+ const totalDuration = sub.end_at - sub.start_at;
861
+ // Calculate text for first and second subtitle early
862
+ const firstText = sub.text.substring(0, splitAt).trim();
863
+ const secondText = sub.text.substring(splitAt).trim();
864
+ // Guard against accidental empty parts early
865
+ if (firstText.length === 0 || secondText.length === 0) {
866
+ console.warn('SubtitlesManager: Skipping split that would create empty text segments');
867
+ return;
868
+ }
869
+ // Calculate timing split point proportionally
870
+ const splitRatio = splitAt / sub.text.length;
871
+ const splitTime = sub.start_at + totalDuration * splitRatio;
872
+ // Find the word boundaries around the split position
873
+ let currentCharPos = 0;
874
+ let splitWordIndex = -1;
875
+ let splitCharInWord = 0;
876
+ // Find which word contains the split position with safe tuple access
877
+ for (let i = 0; i < words.length; i++) {
878
+ const wordTuple = words[i];
879
+ if (!wordTuple || wordTuple.length < 3)
880
+ continue; // Skip invalid tuples
881
+ const word = wordTuple[0];
882
+ if (typeof word !== 'string')
883
+ continue; // Skip invalid word text
884
+ const wordLength = word.length;
885
+ if (currentCharPos <= splitAt && splitAt < currentCharPos + wordLength) {
886
+ splitWordIndex = i;
887
+ splitCharInWord = splitAt - currentCharPos;
888
+ break;
889
+ }
890
+ currentCharPos += wordLength + 1; // +1 for space
891
+ }
892
+ // Create words arrays for both subtitles
893
+ let firstWords = [];
894
+ let secondWords = [];
895
+ if (splitWordIndex === -1) {
896
+ // Split at word boundary - simple distribution
897
+ currentCharPos = 0;
898
+ for (let i = 0; i < words.length; i++) {
899
+ const wordTuple = words[i];
900
+ if (!wordTuple || wordTuple.length < 3)
901
+ continue;
902
+ const word = wordTuple[0];
903
+ if (typeof word !== 'string')
904
+ continue;
905
+ const wordLength = word.length;
906
+ if (currentCharPos + wordLength <= splitAt) {
907
+ firstWords.push(wordTuple);
908
+ }
909
+ else {
910
+ secondWords.push(wordTuple);
911
+ }
912
+ currentCharPos += wordLength + 1; // +1 for space
913
+ }
914
+ }
915
+ else {
916
+ // Split within a word
917
+ const splitWord = words[splitWordIndex];
918
+ if (!splitWord || splitWord.length < 3) {
919
+ console.warn('SubtitlesManager: Invalid word tuple for splitting');
920
+ return;
921
+ }
922
+ const originalWord = splitWord[0];
923
+ const originalStartTime = splitWord[1];
924
+ const originalEndTime = splitWord[2];
925
+ const originalMetadata = splitWord.length > 3 ? splitWord[3] : undefined;
926
+ // Type safety checks
927
+ if (typeof originalWord !== 'string' ||
928
+ typeof originalStartTime !== 'number' ||
929
+ typeof originalEndTime !== 'number') {
930
+ console.warn('SubtitlesManager: Invalid word tuple data types for splitting');
931
+ return;
932
+ }
933
+ // Split the word timing proportionally
934
+ const wordDuration = originalEndTime - originalStartTime;
935
+ const wordSplitRatio = splitCharInWord / originalWord.length;
936
+ const wordSplitTime = originalStartTime + wordDuration * wordSplitRatio;
937
+ // Create split word parts
938
+ const firstWordPart = [
939
+ originalWord.substring(0, splitCharInWord),
940
+ originalStartTime,
941
+ wordSplitTime
942
+ ];
943
+ const secondWordPart = [
944
+ originalWord.substring(splitCharInWord),
945
+ wordSplitTime,
946
+ originalEndTime
947
+ ];
948
+ // Add metadata if it exists
949
+ if (originalMetadata !== undefined && originalMetadata !== null) {
950
+ firstWordPart.push(originalMetadata);
951
+ secondWordPart.push(originalMetadata);
952
+ }
953
+ // Distribute words to respective subtitles - use simple loops
954
+ firstWords = words.slice(0, splitWordIndex);
955
+ firstWords.push(firstWordPart);
956
+ secondWords.push(secondWordPart);
957
+ if (splitWordIndex + 1 < words.length) {
958
+ secondWords.push(...words.slice(splitWordIndex + 1));
959
+ }
960
+ }
961
+ // Create new subtitle ID for the second part
962
+ const newSubtitleId = uuidv4();
963
+ // Create the subtitles
964
+ const updatedSub = {
965
+ ...sub,
966
+ text: firstText,
967
+ end_at: splitTime,
968
+ words: firstWords
969
+ };
970
+ const newSub = {
971
+ id: newSubtitleId,
972
+ text: secondText,
973
+ start_at: splitTime,
974
+ end_at: sub.end_at,
975
+ words: secondWords
976
+ };
977
+ // Mark both subtitles as dirty since we manually created their words
978
+ markSubtitleDirty(subtitleId);
979
+ markSubtitleDirty(newSubtitleId);
980
+ markLanguageDirty(assetId, lang);
981
+ // Update the index - build new langIndex directly
982
+ const assetIndex = index[assetId];
983
+ const langIndex = assetIndex?.[lang];
984
+ if (langIndex) {
985
+ const newLangIndex = { ...langIndex };
986
+ newLangIndex[subtitleId] = updatedSub;
987
+ newLangIndex[newSubtitleId] = newSub;
988
+ index = {
989
+ ...index,
990
+ [assetId]: {
991
+ ...assetIndex,
992
+ [lang]: newLangIndex
993
+ }
994
+ };
995
+ // Use optimized recompute for just this language
996
+ recomputeIndexForLanguage(assetId, lang);
997
+ // Emit events
998
+ eventManager.emit('subtitleschange');
999
+ eventManager.emit('subtitlesplit', {
1000
+ assetId,
1001
+ language,
1002
+ subtitleId,
1003
+ subtitle: updatedSub,
1004
+ newSubtitle: newSub
1005
+ });
1006
+ }
1007
+ },
1008
+ mergeSubtitles(assetId, sourceSubtitleId, language = '', mergeTo) {
1009
+ const lang = language || getDefaultLanguage(assetId);
1010
+ const sourceSub = this.getSubtitle(assetId, sourceSubtitleId, language);
1011
+ if (!sourceSub) {
1012
+ console.warn(`SubtitlesManager: Subtitle ${sourceSubtitleId} not found for asset ${assetId}`);
1013
+ return;
1014
+ }
1015
+ const sortedSubs = sorted[assetId]?.[lang] || [];
1016
+ const sourceIndex = sortedSubs.findIndex((sub) => sub.id === sourceSubtitleId);
1017
+ if (sourceIndex === -1) {
1018
+ console.warn(`SubtitlesManager: Source subtitle ${sourceSubtitleId} not found in sorted list`);
1019
+ return;
1020
+ }
1021
+ let targetSub;
1022
+ let subtitleToDeleteId;
1023
+ let survivingSubtitle;
1024
+ if (mergeTo === 'start') {
1025
+ // The source subtitle merges into the NEXT one. The next one survives.
1026
+ if (sourceIndex >= sortedSubs.length - 1) {
1027
+ console.warn(`SubtitlesManager: No next subtitle to merge with for ${sourceSubtitleId}`);
1028
+ return;
1029
+ }
1030
+ targetSub = sortedSubs[sourceIndex + 1];
1031
+ subtitleToDeleteId = sourceSubtitleId;
1032
+ survivingSubtitle = {
1033
+ ...targetSub, // The target subtitle survives, keep its ID
1034
+ text: sourceSub.text + ' ' + targetSub.text,
1035
+ start_at: sourceSub.start_at, // Use the start time of the first subtitle
1036
+ // end_at remains targetSub.end_at
1037
+ words: [...(sourceSub.words || []), ...(targetSub.words || [])]
1038
+ };
1039
+ }
1040
+ else {
1041
+ // mergeTo === 'end'
1042
+ // The source subtitle merges into the PREVIOUS one. The previous one survives.
1043
+ if (sourceIndex === 0) {
1044
+ console.warn(`SubtitlesManager: No previous subtitle to merge with for ${sourceSubtitleId}`);
1045
+ return;
1046
+ }
1047
+ targetSub = sortedSubs[sourceIndex - 1];
1048
+ subtitleToDeleteId = sourceSubtitleId;
1049
+ survivingSubtitle = {
1050
+ ...targetSub, // The target subtitle survives, keep its ID
1051
+ text: targetSub.text + ' ' + sourceSub.text,
1052
+ // start_at remains targetSub.start_at
1053
+ end_at: sourceSub.end_at, // Use the end time of the last subtitle
1054
+ words: [...(targetSub.words || []), ...(sourceSub.words || [])]
1055
+ };
1056
+ }
1057
+ // --- State Update ---
1058
+ const assetIndex = index[assetId];
1059
+ const langIndex = assetIndex?.[lang];
1060
+ if (langIndex) {
1061
+ // Create a new map for the updated language subtitles
1062
+ const newLangIndex = { ...langIndex };
1063
+ // 1. Update the surviving subtitle with the merged content
1064
+ newLangIndex[survivingSubtitle.id] = survivingSubtitle;
1065
+ // 2. CRUCIAL: Delete the other subtitle
1066
+ delete newLangIndex[subtitleToDeleteId];
1067
+ // Mark surviving subtitle as dirty since we merged words
1068
+ markSubtitleDirty(survivingSubtitle.id);
1069
+ markLanguageDirty(assetId, lang);
1070
+ // Update the main index immutably
1071
+ index = {
1072
+ ...index,
1073
+ [assetId]: {
1074
+ ...assetIndex,
1075
+ [lang]: newLangIndex
1076
+ }
1077
+ };
1078
+ recomputeIndexForLanguage(assetId, lang);
1079
+ // Emit events
1080
+ eventManager.emit('subtitleschange');
1081
+ eventManager.emit('subtitlemerge', {
1082
+ assetId,
1083
+ language,
1084
+ targetSubtitle: survivingSubtitle,
1085
+ sourceSubtitle: sourceSub,
1086
+ mergeTo
1087
+ });
1088
+ }
1089
+ },
1090
+ addNewSubtitleAfter(assetId, subtitleId, language = '') {
1091
+ const lang = language || getDefaultLanguage(assetId);
1092
+ const currentSub = this.getSubtitle(assetId, subtitleId, language);
1093
+ if (!currentSub) {
1094
+ console.warn(`SubtitlesManager: Subtitle ${subtitleId} not found for asset ${assetId}`);
1095
+ return;
1096
+ }
1097
+ const sortedSubs = sorted[assetId]?.[lang] || [];
1098
+ const currentIndex = sortedSubs.findIndex((sub) => sub.id === subtitleId);
1099
+ if (currentIndex === -1) {
1100
+ console.warn(`SubtitlesManager: Subtitle ${subtitleId} not found in sorted list`);
1101
+ return;
1102
+ }
1103
+ let nextStart;
1104
+ let isLast = currentIndex === sortedSubs.length - 1;
1105
+ if (!isLast) {
1106
+ // Get next subtitle's start time
1107
+ nextStart = sortedSubs[currentIndex + 1].start_at;
1108
+ }
1109
+ else {
1110
+ // Use scene duration for last subtitle
1111
+ nextStart = timeManager.duration; // Assuming sceneData has duration property
1112
+ if (nextStart === undefined) {
1113
+ console.warn(`SubtitlesManager: Scene duration not available`);
1114
+ return;
1115
+ }
1116
+ }
1117
+ const gap = nextStart - currentSub.end_at;
1118
+ const minGap = 0.2; // Minimum meaningful gap in seconds
1119
+ const maxDuration = 5; // Maximum new subtitle duration in seconds
1120
+ if (gap <= minGap) {
1121
+ console.warn(`SubtitlesManager: Gap too small (${gap}s) to insert new subtitle after ${subtitleId}`);
1122
+ return;
1123
+ }
1124
+ // Calculate new subtitle times
1125
+ const newStart = currentSub.end_at;
1126
+ const newEnd = Math.min(newStart + maxDuration, nextStart);
1127
+ // Create new subtitle
1128
+ const newSubtitleId = uuidv4();
1129
+ const newSub = {
1130
+ id: newSubtitleId,
1131
+ text: '', // Empty text for new subtitle
1132
+ start_at: newStart,
1133
+ end_at: newEnd,
1134
+ words: []
1135
+ };
1136
+ // Add to index
1137
+ const assetIndex = index[assetId];
1138
+ const langIndex = assetIndex?.[lang];
1139
+ if (langIndex) {
1140
+ index = {
1141
+ ...index,
1142
+ [assetId]: {
1143
+ ...index[assetId],
1144
+ [lang]: {
1145
+ ...index[assetId][lang],
1146
+ [newSubtitleId]: newSub
1147
+ }
1148
+ }
1149
+ };
1150
+ // Emit events
1151
+ eventManager.emit('subtitleschange');
1152
+ eventManager.emit('subtitlechange', {
1153
+ assetId,
1154
+ language,
1155
+ subtitleId: newSubtitleId,
1156
+ subtitle: newSub
1157
+ });
1158
+ }
1159
+ },
1160
+ destroy() { }
1161
+ };
1162
+ }
1163
+ export class SubtitlesManager {
1164
+ builder;
1165
+ assetId;
1166
+ language = 'default';
1167
+ constructor(cradle) {
1168
+ this.builder = buildSubtitlesManager(cradle.timeManager, cradle.eventManager, cradle.sceneData, cradle.subtitles);
1169
+ }
1170
+ get data() {
1171
+ return this.builder.data;
1172
+ }
1173
+ get settings() {
1174
+ return this.builder.settings;
1175
+ }
1176
+ setAssetId(assetId) {
1177
+ return (this.assetId = assetId);
1178
+ }
1179
+ setLanguage(language) {
1180
+ return (this.language = language);
1181
+ }
1182
+ getAssetSubtitlesForSceneData(assetId) {
1183
+ return this.builder.getAssetSubtitlesForSceneData(assetId);
1184
+ }
1185
+ getAssetSubtitles(assetId) {
1186
+ return this.builder.getAssetSubtitles(assetId);
1187
+ }
1188
+ setAssetSubtitles(assetId, subtitles) {
1189
+ return this.builder.setAssetSubtitles(assetId, subtitles);
1190
+ }
1191
+ replaceCollection(assetId, newCollection, language = '') {
1192
+ return this.builder.replaceCollection(assetId, newCollection, language);
1193
+ }
1194
+ updateSubtitleText(subtitleId, newText) {
1195
+ if (!this.assetId || !this.language) {
1196
+ console.warn('SubtitlesManager: Asset ID or language is not set');
1197
+ return;
1198
+ }
1199
+ return this.builder.updateSubtitleText(this.assetId, subtitleId, newText, this.language);
1200
+ }
1201
+ updateSubtitleProps(subtitleId, props) {
1202
+ if (!this.assetId || !this.language) {
1203
+ console.warn('SubtitlesManager: Asset ID or language is not set');
1204
+ return;
1205
+ }
1206
+ return this.builder.updateSubtitleProps(this.assetId, subtitleId, props, this.language);
1207
+ }
1208
+ splitSubtitle(subtitleId, splitAt) {
1209
+ if (!this.assetId || !this.language) {
1210
+ console.warn('SubtitlesManager: Asset ID or language is not set');
1211
+ return;
1212
+ }
1213
+ return this.builder.splitSubtitle(this.assetId, subtitleId, this.language, splitAt);
1214
+ }
1215
+ mergeSubtitles(sourceSubtitleId, mergeTo) {
1216
+ if (!this.assetId || !this.language) {
1217
+ console.warn('SubtitlesManager: Asset ID or language is not set');
1218
+ return;
1219
+ }
1220
+ return this.builder.mergeSubtitles(this.assetId, sourceSubtitleId, this.language, mergeTo);
1221
+ }
1222
+ getSubtitle(timeOrId) {
1223
+ if (!this.assetId || !this.language) {
1224
+ console.warn('SubtitlesManager: Asset ID or language is not set');
1225
+ return undefined;
1226
+ }
1227
+ return this.builder.getSubtitle(this.assetId, timeOrId, this.language);
1228
+ }
1229
+ // this is a convenience method to set the start and end times for a subtitle in a specific asset and language
1230
+ // we use it in subtitles timeline so we don't change assetId and language which would cause conflict to subtitles editor lol
1231
+ // TODO fix, refactor
1232
+ setTimesForSubtitleInAssetAndLanguage(assetId, language, subtitleId, start, end) {
1233
+ if (typeof start === 'number') {
1234
+ this.builder.setStart(assetId, subtitleId, start, language);
1235
+ }
1236
+ if (typeof end === 'number') {
1237
+ this.builder.setEnd(assetId, subtitleId, end, language);
1238
+ }
1239
+ }
1240
+ setStart(subtitleId, start) {
1241
+ if (!this.assetId || !this.language) {
1242
+ console.warn('SubtitlesManager: Asset ID or language is not set');
1243
+ return;
1244
+ }
1245
+ return this.builder.setStart(this.assetId, subtitleId, start, this.language);
1246
+ }
1247
+ setEnd(subtitleId, end) {
1248
+ if (!this.assetId || !this.language) {
1249
+ console.warn('SubtitlesManager: Asset ID or language is not set');
1250
+ return;
1251
+ }
1252
+ return this.builder.setEnd(this.assetId, subtitleId, end, this.language);
1253
+ }
1254
+ addNewSubtitleAfter(subtitleId, newText) {
1255
+ if (!this.assetId || !this.language) {
1256
+ console.warn('SubtitlesManager: Asset ID or language is not set');
1257
+ return;
1258
+ }
1259
+ return this.builder.addNewSubtitleAfter(this.assetId, subtitleId, this.language);
1260
+ }
1261
+ splitByChars(maxChars) {
1262
+ if (!this.assetId || !this.language) {
1263
+ console.warn('SubtitlesManager: Asset ID or language is not set');
1264
+ return;
1265
+ }
1266
+ return this.builder.splitByChars(this.assetId, this.language, maxChars);
1267
+ }
1268
+ getText() {
1269
+ if (!this.assetId || !this.language) {
1270
+ console.warn('SubtitlesManager: Asset ID or language is not set');
1271
+ return;
1272
+ }
1273
+ return this.builder.getText(this.assetId, this.language);
1274
+ }
1275
+ updateSettings(newSettings) {
1276
+ return this.builder.updateSettings(newSettings);
1277
+ }
1278
+ findTextChunkTiming(searchText, options = {}) {
1279
+ if (!this.assetId || !this.language) {
1280
+ return [];
1281
+ }
1282
+ const collection = this.data[this.assetId];
1283
+ if (!collection || !collection[this.language]) {
1284
+ return [];
1285
+ }
1286
+ const subtitles = collection[this.language];
1287
+ const results = [];
1288
+ // Normalize search text
1289
+ const normalizedSearchText = (options.caseSensitive ? searchText : searchText.toLowerCase()).trim();
1290
+ // Return empty array if search text is empty after trimming
1291
+ if (normalizedSearchText.length === 0) {
1292
+ return [];
1293
+ }
1294
+ // Build a continuous text stream with timing information
1295
+ const textStream = [];
1296
+ for (const subtitle of subtitles) {
1297
+ // Words are always present after auto-generation, so always use word-level timing
1298
+ if (subtitle.words && subtitle.words.length > 0) {
1299
+ // Use word-level timing
1300
+ for (const word of subtitle.words) {
1301
+ // Handle both compact tuple format [text, start_at, end_at, metadata?] and object format
1302
+ if (Array.isArray(word)) {
1303
+ textStream.push({
1304
+ text: word[0],
1305
+ startTime: word[1],
1306
+ endTime: word[2],
1307
+ subtitleId: subtitle.id
1308
+ });
1309
+ }
1310
+ else {
1311
+ textStream.push({
1312
+ text: word.text,
1313
+ startTime: word.start_at,
1314
+ endTime: word.end_at,
1315
+ subtitleId: subtitle.id
1316
+ });
1317
+ }
1318
+ }
1319
+ }
1320
+ else {
1321
+ // Fallback: if somehow no words are present, use subtitle-level timing
1322
+ textStream.push({
1323
+ text: subtitle.text,
1324
+ startTime: subtitle.start_at,
1325
+ endTime: subtitle.end_at,
1326
+ subtitleId: subtitle.id
1327
+ });
1328
+ }
1329
+ }
1330
+ // Build a continuous text string with timing markers
1331
+ const continuousText = [];
1332
+ for (let i = 0; i < textStream.length; i++) {
1333
+ const item = textStream[i];
1334
+ // Add the word characters
1335
+ for (let j = 0; j < item.text.length; j++) {
1336
+ continuousText.push({
1337
+ char: item.text[j],
1338
+ startTime: item.startTime,
1339
+ endTime: item.endTime,
1340
+ subtitleId: item.subtitleId
1341
+ });
1342
+ }
1343
+ // Add a space after each word (except the very last word)
1344
+ if (i < textStream.length - 1) {
1345
+ // Add space between words (both within and across subtitles)
1346
+ continuousText.push({
1347
+ char: ' ',
1348
+ startTime: item.endTime,
1349
+ endTime: item.endTime,
1350
+ subtitleId: item.subtitleId
1351
+ });
1352
+ }
1353
+ }
1354
+ // Convert to string for searching
1355
+ const fullText = continuousText.map((item) => item.char).join('');
1356
+ const searchPattern = options.caseSensitive
1357
+ ? normalizedSearchText
1358
+ : normalizedSearchText.toLowerCase();
1359
+ const textToSearch = options.caseSensitive ? fullText : fullText.toLowerCase();
1360
+ // Find all occurrences of the search text
1361
+ let startIndex = 0;
1362
+ while (true) {
1363
+ const foundIndex = textToSearch.indexOf(searchPattern, startIndex);
1364
+ if (foundIndex === -1)
1365
+ break;
1366
+ // Find the timing information for this match
1367
+ const startChar = continuousText[foundIndex];
1368
+ const endChar = continuousText[foundIndex + searchPattern.length - 1];
1369
+ // Build the matched text from the original (non-lowercased) text
1370
+ const matchedText = fullText.substring(foundIndex, foundIndex + searchPattern.length);
1371
+ results.push({
1372
+ startTime: startChar.startTime,
1373
+ endTime: endChar.endTime,
1374
+ startSubtitleId: startChar.subtitleId,
1375
+ endSubtitleId: endChar.subtitleId,
1376
+ matchedText: matchedText
1377
+ });
1378
+ startIndex = foundIndex + 1;
1379
+ }
1380
+ return results;
1381
+ }
1382
+ destroy() {
1383
+ return this.builder.destroy();
1384
+ }
1385
+ }