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.
- package/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/DIContainer.d.ts +4 -0
- package/dist/DIContainer.js +145 -0
- package/dist/SceneBuilder.svelte.d.ts +8574 -0
- package/dist/SceneBuilder.svelte.js +409 -0
- package/dist/adapters/subtitleHelpers.d.ts +2 -0
- package/dist/adapters/subtitleHelpers.js +187 -0
- package/dist/animations/AnimationContext.d.ts +17 -0
- package/dist/animations/AnimationContext.js +72 -0
- package/dist/animations/AnimationPresetsRegister.d.ts +362 -0
- package/dist/animations/AnimationPresetsRegister.js +20 -0
- package/dist/animations/AnimationSetup.d.ts +8 -0
- package/dist/animations/AnimationSetup.js +30 -0
- package/dist/animations/SplitTextCache.d.ts +28 -0
- package/dist/animations/SplitTextCache.js +68 -0
- package/dist/animations/animationBuilder.d.ts +31 -0
- package/dist/animations/animationBuilder.js +255 -0
- package/dist/animations/animationPreset.d.ts +7 -0
- package/dist/animations/animationPreset.js +31 -0
- package/dist/animations/builders/AnimationPresetFactory.d.ts +43 -0
- package/dist/animations/builders/AnimationPresetFactory.js +139 -0
- package/dist/animations/builders/LineHighlighterAnimationBuilder.d.ts +16 -0
- package/dist/animations/builders/LineHighlighterAnimationBuilder.js +183 -0
- package/dist/animations/builders/WordHighlighterAnimationBuilder.d.ts +15 -0
- package/dist/animations/builders/WordHighlighterAnimationBuilder.js +180 -0
- package/dist/animations/engines/AnimationEngineAdaptor.d.ts +107 -0
- package/dist/animations/engines/AnimationEngineAdaptor.js +1 -0
- package/dist/animations/engines/GSAPEngineAdaptor.d.ts +21 -0
- package/dist/animations/engines/GSAPEngineAdaptor.js +145 -0
- package/dist/animations/presets/index.d.ts +2 -0
- package/dist/animations/presets/index.js +3 -0
- package/dist/animations/presets/lines.d.ts +52 -0
- package/dist/animations/presets/lines.js +547 -0
- package/dist/animations/presets/words.d.ts +31 -0
- package/dist/animations/presets/words.js +268 -0
- package/dist/animations/transformers/AnimationReferenceTransformer.d.ts +9 -0
- package/dist/animations/transformers/AnimationReferenceTransformer.js +114 -0
- package/dist/builders/PixiComponentBuilder.d.ts +63 -0
- package/dist/builders/PixiComponentBuilder.js +112 -0
- package/dist/builders/_ComponentState.svelte.d.ts +795 -0
- package/dist/builders/_ComponentState.svelte.js +203 -0
- package/dist/builders/html/HtmlBuilder.d.ts +66 -0
- package/dist/builders/html/HtmlBuilder.js +171 -0
- package/dist/builders/html/HtmlBuilderFactory.d.ts +27 -0
- package/dist/builders/html/HtmlBuilderFactory.js +30 -0
- package/dist/builders/html/StyleBuilder.d.ts +13 -0
- package/dist/builders/html/StyleBuilder.js +133 -0
- package/dist/builders/html/StyleProcessor.d.ts +9 -0
- package/dist/builders/html/StyleProcessor.js +1 -0
- package/dist/builders/html/TextComponentHtmlBuilder.d.ts +16 -0
- package/dist/builders/html/TextComponentHtmlBuilder.js +93 -0
- package/dist/builders/html/TextShadowBuilder.d.ts +60 -0
- package/dist/builders/html/TextShadowBuilder.js +227 -0
- package/dist/builders/html/processors/AppearanceStyleProcessor.d.ts +5 -0
- package/dist/builders/html/processors/AppearanceStyleProcessor.js +57 -0
- package/dist/builders/html/processors/TextAppearanceStyleProcessor.d.ts +5 -0
- package/dist/builders/html/processors/TextAppearanceStyleProcessor.js +37 -0
- package/dist/builders/html/processors/TextEffectsStyleProcessor.d.ts +6 -0
- package/dist/builders/html/processors/TextEffectsStyleProcessor.js +68 -0
- package/dist/commands/Command.d.ts +6 -0
- package/dist/commands/Command.js +1 -0
- package/dist/commands/CommandRunner.d.ts +28 -0
- package/dist/commands/CommandRunner.js +81 -0
- package/dist/commands/CommandTypes.d.ts +11 -0
- package/dist/commands/CommandTypes.js +13 -0
- package/dist/commands/PauseCommand.d.ts +4 -0
- package/dist/commands/PauseCommand.js +5 -0
- package/dist/commands/PlayCommand.d.ts +4 -0
- package/dist/commands/PlayCommand.js +6 -0
- package/dist/commands/RenderCommand.d.ts +15 -0
- package/dist/commands/RenderCommand.js +18 -0
- package/dist/commands/RenderFrameCommand.d.ts +17 -0
- package/dist/commands/RenderFrameCommand.js +93 -0
- package/dist/commands/ReplaceSourceOnTimeCommand.d.ts +4 -0
- package/dist/commands/ReplaceSourceOnTimeCommand.js +22 -0
- package/dist/commands/SeekCommand.d.ts +15 -0
- package/dist/commands/SeekCommand.js +39 -0
- package/dist/commands/UpdateComponentCommand.d.ts +4 -0
- package/dist/commands/UpdateComponentCommand.js +17 -0
- package/dist/components/AnimatedGIF.d.ts +201 -0
- package/dist/components/AnimatedGIF.js +391 -0
- package/dist/components/Component.svelte.d.ts +33 -0
- package/dist/components/Component.svelte.js +152 -0
- package/dist/components/ComponentContext.svelte.d.ts +33 -0
- package/dist/components/ComponentContext.svelte.js +105 -0
- package/dist/components/hooks/AnimationHook.d.ts +25 -0
- package/dist/components/hooks/AnimationHook.js +180 -0
- package/dist/components/hooks/CanvasShapeHook.d.ts +12 -0
- package/dist/components/hooks/CanvasShapeHook.js +229 -0
- package/dist/components/hooks/HtmlAnimationHook.d.ts +8 -0
- package/dist/components/hooks/HtmlAnimationHook.js +70 -0
- package/dist/components/hooks/HtmlTextHook.d.ts +16 -0
- package/dist/components/hooks/HtmlTextHook.js +102 -0
- package/dist/components/hooks/HtmlToCanvasHook.d.ts +16 -0
- package/dist/components/hooks/HtmlToCanvasHook.js +148 -0
- package/dist/components/hooks/ImageHook.d.ts +10 -0
- package/dist/components/hooks/ImageHook.js +45 -0
- package/dist/components/hooks/MediaHook.d.ts +15 -0
- package/dist/components/hooks/MediaHook.js +252 -0
- package/dist/components/hooks/MediaSeekingHook.d.ts +12 -0
- package/dist/components/hooks/MediaSeekingHook.js +204 -0
- package/dist/components/hooks/PixiDisplayObjectHook.d.ts +15 -0
- package/dist/components/hooks/PixiDisplayObjectHook.js +77 -0
- package/dist/components/hooks/PixiGifHook.d.ts +15 -0
- package/dist/components/hooks/PixiGifHook.js +97 -0
- package/dist/components/hooks/PixiProgressShapeHook.d.ts +12 -0
- package/dist/components/hooks/PixiProgressShapeHook.js +128 -0
- package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.d.ts +21 -0
- package/dist/components/hooks/PixiSplitScreenDisplayObjectHook.js +210 -0
- package/dist/components/hooks/PixiTextureHook.d.ts +7 -0
- package/dist/components/hooks/PixiTextureHook.js +29 -0
- package/dist/components/hooks/PixiVideoTextureHook.d.ts +10 -0
- package/dist/components/hooks/PixiVideoTextureHook.js +35 -0
- package/dist/components/hooks/SubtitlesHook.d.ts +88 -0
- package/dist/components/hooks/SubtitlesHook.js +199 -0
- package/dist/components/hooks/VerifyGifHook.d.ts +7 -0
- package/dist/components/hooks/VerifyGifHook.js +27 -0
- package/dist/components/hooks/VerifyImageHook.d.ts +7 -0
- package/dist/components/hooks/VerifyImageHook.js +27 -0
- package/dist/components/hooks/VerifyMediaHook.d.ts +7 -0
- package/dist/components/hooks/VerifyMediaHook.js +21 -0
- package/dist/components/hooks/shapes/progress/CustomProgressRenderer.d.ts +8 -0
- package/dist/components/hooks/shapes/progress/CustomProgressRenderer.js +53 -0
- package/dist/components/hooks/shapes/progress/DoubleProgressRenderer.d.ts +8 -0
- package/dist/components/hooks/shapes/progress/DoubleProgressRenderer.js +69 -0
- package/dist/components/hooks/shapes/progress/LinearProgressRenderer.d.ts +8 -0
- package/dist/components/hooks/shapes/progress/LinearProgressRenderer.js +60 -0
- package/dist/components/hooks/shapes/progress/PerimeterProgressRenderer.d.ts +9 -0
- package/dist/components/hooks/shapes/progress/PerimeterProgressRenderer.js +213 -0
- package/dist/components/hooks/shapes/progress/ProgressRenderer.d.ts +17 -0
- package/dist/components/hooks/shapes/progress/ProgressRenderer.js +75 -0
- package/dist/components/hooks/shapes/progress/RadialProgressRenderer.d.ts +8 -0
- package/dist/components/hooks/shapes/progress/RadialProgressRenderer.js +50 -0
- package/dist/components/hooks/shapes/progress/index.d.ts +6 -0
- package/dist/components/hooks/shapes/progress/index.js +6 -0
- package/dist/composers/componentComposer.d.ts +55 -0
- package/dist/composers/componentComposer.js +118 -0
- package/dist/composers/layerComposer.d.ts +46 -0
- package/dist/composers/layerComposer.js +79 -0
- package/dist/composers/sceneComposer.d.ts +48 -0
- package/dist/composers/sceneComposer.js +92 -0
- package/dist/constants.d.ts +12 -0
- package/dist/constants.js +14 -0
- package/dist/directors/ComponentDirector.d.ts +20 -0
- package/dist/directors/ComponentDirector.js +86 -0
- package/dist/factories/SceneBuilderFactory.d.ts +15 -0
- package/dist/factories/SceneBuilderFactory.js +51 -0
- package/dist/fonts/GoogleFontsProvider.d.ts +12 -0
- package/dist/fonts/GoogleFontsProvider.js +125 -0
- package/dist/fonts/fontLoader.d.ts +15 -0
- package/dist/fonts/fontLoader.js +41 -0
- package/dist/fonts/types.d.ts +1 -0
- package/dist/fonts/types.js +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +14 -0
- package/dist/layers/Layer.svelte.d.ts +8492 -0
- package/dist/layers/Layer.svelte.js +125 -0
- package/dist/managers/AppManager.svelte.d.ts +23 -0
- package/dist/managers/AppManager.svelte.js +89 -0
- package/dist/managers/ComponentsManager.svelte.d.ts +49 -0
- package/dist/managers/ComponentsManager.svelte.js +247 -0
- package/dist/managers/DomManager.d.ts +18 -0
- package/dist/managers/DomManager.js +73 -0
- package/dist/managers/EventManager.d.ts +7 -0
- package/dist/managers/EventManager.js +22 -0
- package/dist/managers/LayersManager.svelte.d.ts +8499 -0
- package/dist/managers/LayersManager.svelte.js +176 -0
- package/dist/managers/MediaManager.d.ts +32 -0
- package/dist/managers/MediaManager.js +243 -0
- package/dist/managers/RenderManager.d.ts +23 -0
- package/dist/managers/RenderManager.js +59 -0
- package/dist/managers/StateManager.svelte.d.ts +8746 -0
- package/dist/managers/StateManager.svelte.js +272 -0
- package/dist/managers/SubtitlesManager.svelte.d.ts +261 -0
- package/dist/managers/SubtitlesManager.svelte.js +1385 -0
- package/dist/managers/TimeManager.svelte.d.ts +6 -0
- package/dist/managers/TimeManager.svelte.js +18 -0
- package/dist/managers/TimelineManager.svelte.d.ts +25 -0
- package/dist/managers/TimelineManager.svelte.js +152 -0
- package/dist/registers.d.ts +12 -0
- package/dist/registers.js +29 -0
- package/dist/schemas/runtime/index.d.ts +3 -0
- package/dist/schemas/runtime/index.js +4 -0
- package/dist/schemas/runtime/types.d.ts +323 -0
- package/dist/schemas/runtime/types.js +12 -0
- package/dist/schemas/scene/animations.d.ts +89738 -0
- package/dist/schemas/scene/animations.js +211 -0
- package/dist/schemas/scene/components.js +515 -0
- package/dist/schemas/scene/core.js +160 -0
- package/dist/schemas/scene/index.d.ts +22 -0
- package/dist/schemas/scene/index.js +10 -0
- package/dist/schemas/scene/properties.d.ts +914 -0
- package/dist/schemas/scene/properties.js +398 -0
- package/dist/schemas/scene/subtitles.d.ts +1141 -0
- package/dist/schemas/scene/subtitles.js +111 -0
- package/dist/schemas/scene/utils.d.ts +1 -0
- package/dist/schemas/scene/utils.js +5 -0
- package/dist/seeds/SeedFactory.d.ts +59 -0
- package/dist/seeds/SeedFactory.js +99 -0
- package/dist/seeds/index.d.ts +8 -0
- package/dist/seeds/index.js +8 -0
- package/dist/transformers/ColorTransformer.d.ts +5 -0
- package/dist/transformers/ColorTransformer.js +67 -0
- package/dist/transformers/PixiColorTransformer.d.ts +22 -0
- package/dist/transformers/PixiColorTransformer.js +104 -0
- package/dist/utils/canvas.d.ts +6 -0
- package/dist/utils/canvas.js +18 -0
- package/dist/utils/document.d.ts +2 -0
- package/dist/utils/document.js +36 -0
- package/dist/utils/emoji.d.ts +10 -0
- package/dist/utils/emoji.js +51 -0
- package/dist/utils/html.d.ts +4 -0
- package/dist/utils/html.js +45 -0
- package/dist/utils/svgGenerator.d.ts +20 -0
- package/dist/utils/svgGenerator.js +103 -0
- package/dist/utils/utils.d.ts +5 -0
- package/dist/utils/utils.js +125 -0
- 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
|
+
}
|