@hyperframes/studio 0.2.0 → 0.2.2-alpha.1
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 +190 -21
- package/dist/assets/index-BT9D8I7B.css +1 -0
- package/dist/assets/index-DA_l-VKo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/App.tsx +213 -8
- package/src/captions/components/CaptionAnimationPanel.tsx +269 -0
- package/src/captions/components/CaptionOverlay.tsx +622 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +275 -0
- package/src/captions/components/CaptionTimeline.tsx +187 -0
- package/src/captions/components/shared.tsx +26 -0
- package/src/captions/generator.test.ts +279 -0
- package/src/captions/generator.ts +376 -0
- package/src/captions/hooks/useCaptionSync.ts +168 -0
- package/src/captions/index.ts +10 -0
- package/src/captions/parser.test.ts +377 -0
- package/src/captions/parser.ts +314 -0
- package/src/captions/store.ts +272 -0
- package/src/captions/types.ts +207 -0
- package/src/components/nle/NLELayout.tsx +1 -1
- package/dist/assets/index-Bkp9HQbo.css +0 -1
- package/dist/assets/index-DfhSlTti.js +0 -93
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Caption Parser — Extract Transcript & Build Caption Model
|
|
2
|
+
// Parses a caption composition's JavaScript source to extract the transcript word array,
|
|
3
|
+
// and builds a CaptionModel from a TranscriptWord array.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CaptionModel,
|
|
7
|
+
CaptionSegment,
|
|
8
|
+
CaptionGroup,
|
|
9
|
+
CaptionStyle,
|
|
10
|
+
CaptionContainerStyle,
|
|
11
|
+
DEFAULT_STYLE,
|
|
12
|
+
DEFAULT_CONTAINER,
|
|
13
|
+
DEFAULT_ANIMATION_SET,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
export interface TranscriptWord {
|
|
17
|
+
id?: string;
|
|
18
|
+
text: string;
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BuildOptions {
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
duration: number;
|
|
27
|
+
wordsPerGroup?: number; // default 5
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Builds a CaptionModel from a transcript word array and composition dimensions.
|
|
32
|
+
*
|
|
33
|
+
* Words are grouped into chunks of `wordsPerGroup` (default 5). Each word becomes a
|
|
34
|
+
* CaptionSegment with its original timing. Each chunk becomes a CaptionGroup with
|
|
35
|
+
* DEFAULT_STYLE, DEFAULT_ANIMATION_SET, and DEFAULT_CONTAINER.
|
|
36
|
+
*/
|
|
37
|
+
export function buildCaptionModel(
|
|
38
|
+
transcript: TranscriptWord[],
|
|
39
|
+
options: BuildOptions,
|
|
40
|
+
): CaptionModel {
|
|
41
|
+
const { width, height, duration, wordsPerGroup = 5 } = options;
|
|
42
|
+
|
|
43
|
+
const segments = new Map<string, CaptionSegment>();
|
|
44
|
+
const groups = new Map<string, CaptionGroup>();
|
|
45
|
+
const groupOrder: string[] = [];
|
|
46
|
+
|
|
47
|
+
// Chunk the transcript into groups of wordsPerGroup
|
|
48
|
+
for (let groupIdx = 0; groupIdx < transcript.length; groupIdx += wordsPerGroup) {
|
|
49
|
+
const chunk = transcript.slice(groupIdx, groupIdx + wordsPerGroup);
|
|
50
|
+
const groupId = `group-${groupIdx / wordsPerGroup}`;
|
|
51
|
+
const segmentIds: string[] = [];
|
|
52
|
+
|
|
53
|
+
chunk.forEach((word, wordIdx) => {
|
|
54
|
+
const segmentId = `segment-${groupIdx + wordIdx}`;
|
|
55
|
+
const segment: CaptionSegment = {
|
|
56
|
+
id: segmentId,
|
|
57
|
+
wordId: word.id ?? `w${groupIdx + wordIdx}`,
|
|
58
|
+
text: word.text,
|
|
59
|
+
start: word.start,
|
|
60
|
+
end: word.end,
|
|
61
|
+
groupIndex: wordIdx,
|
|
62
|
+
style: {},
|
|
63
|
+
animation: {},
|
|
64
|
+
};
|
|
65
|
+
segments.set(segmentId, segment);
|
|
66
|
+
segmentIds.push(segmentId);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const group: CaptionGroup = {
|
|
70
|
+
id: groupId,
|
|
71
|
+
segmentIds,
|
|
72
|
+
style: { ...DEFAULT_STYLE },
|
|
73
|
+
animation: {
|
|
74
|
+
entrance: { ...DEFAULT_ANIMATION_SET.entrance },
|
|
75
|
+
highlight: DEFAULT_ANIMATION_SET.highlight,
|
|
76
|
+
exit: { ...DEFAULT_ANIMATION_SET.exit },
|
|
77
|
+
},
|
|
78
|
+
containerStyle: { ...DEFAULT_CONTAINER },
|
|
79
|
+
};
|
|
80
|
+
groups.set(groupId, group);
|
|
81
|
+
groupOrder.push(groupId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
width,
|
|
86
|
+
height,
|
|
87
|
+
duration,
|
|
88
|
+
segments,
|
|
89
|
+
groups,
|
|
90
|
+
groupOrder,
|
|
91
|
+
defaultAnimation: {
|
|
92
|
+
entrance: { ...DEFAULT_ANIMATION_SET.entrance },
|
|
93
|
+
highlight: DEFAULT_ANIMATION_SET.highlight,
|
|
94
|
+
exit: { ...DEFAULT_ANIMATION_SET.exit },
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Extracts a transcript word array from caption composition source code.
|
|
101
|
+
*
|
|
102
|
+
* Looks for `const TRANSCRIPT = [...]` or `const script = [...]` (also let/var)
|
|
103
|
+
* and parses each `{ text, start, end }` object into TranscriptWord objects.
|
|
104
|
+
*
|
|
105
|
+
* Returns an empty array if no transcript is found or if parsing fails.
|
|
106
|
+
*/
|
|
107
|
+
export function extractTranscript(source: string): TranscriptWord[] {
|
|
108
|
+
// Match: (const|let|var) (TRANSCRIPT|script) = [...]
|
|
109
|
+
// The array may span multiple lines and contain trailing commas.
|
|
110
|
+
// The lazy [\s\S]*? anchors on the first `];` — assumes transcript word
|
|
111
|
+
// text never contains a literal `];` string (safe for speech transcripts).
|
|
112
|
+
const varPattern = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*(\[[\s\S]*?\]);/;
|
|
113
|
+
const match = source.match(varPattern);
|
|
114
|
+
|
|
115
|
+
if (!match) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const arrayLiteral = match[1];
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
return parseTranscriptArray(arrayLiteral);
|
|
123
|
+
} catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parses a caption composition from a live iframe DOM, extracting the transcript
|
|
130
|
+
* from the source and reading computed styles from rendered elements.
|
|
131
|
+
*
|
|
132
|
+
* Runs in the Studio (outside the iframe). Reads computed styles from iframe DOM
|
|
133
|
+
* elements to build a fully-styled CaptionModel.
|
|
134
|
+
*
|
|
135
|
+
* Returns null if no transcript is found in the source.
|
|
136
|
+
*/
|
|
137
|
+
export function parseCaptionComposition(
|
|
138
|
+
iframeDoc: Document,
|
|
139
|
+
iframeWin: Window,
|
|
140
|
+
source: string,
|
|
141
|
+
compositionWidth: number,
|
|
142
|
+
compositionHeight: number,
|
|
143
|
+
compositionDuration: number,
|
|
144
|
+
): CaptionModel | null {
|
|
145
|
+
// Step 1: Extract transcript words from source
|
|
146
|
+
const transcript = extractTranscript(source);
|
|
147
|
+
if (transcript.length === 0) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Step 2: Look for grouping and word elements in the iframe DOM
|
|
152
|
+
const groupEls = iframeDoc.querySelectorAll(".caption-group, .caption-line, .caption-block");
|
|
153
|
+
const wordEls = iframeDoc.querySelectorAll(".word, .caption-word");
|
|
154
|
+
|
|
155
|
+
// Step 3: Infer wordsPerGroup from element counts
|
|
156
|
+
let wordsPerGroup = 5; // default
|
|
157
|
+
if (groupEls.length > 0 && wordEls.length > 0) {
|
|
158
|
+
wordsPerGroup = Math.round(wordEls.length / groupEls.length);
|
|
159
|
+
if (wordsPerGroup < 1) {
|
|
160
|
+
wordsPerGroup = 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Step 4: Build the caption model with inferred grouping
|
|
165
|
+
const model = buildCaptionModel(transcript, {
|
|
166
|
+
width: compositionWidth,
|
|
167
|
+
height: compositionHeight,
|
|
168
|
+
duration: compositionDuration,
|
|
169
|
+
wordsPerGroup,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Step 5: Read computed styles from the first word or group element
|
|
173
|
+
const firstWordEl = wordEls.item(0) as Element | null;
|
|
174
|
+
const firstGroupEl = groupEls.item(0) as Element | null;
|
|
175
|
+
const styleSourceEl = firstWordEl ?? firstGroupEl;
|
|
176
|
+
|
|
177
|
+
if (styleSourceEl) {
|
|
178
|
+
const computed = iframeWin.getComputedStyle(styleSourceEl);
|
|
179
|
+
|
|
180
|
+
// Build partial style overrides from computed values
|
|
181
|
+
const styleOverrides: Partial<CaptionStyle> = {};
|
|
182
|
+
|
|
183
|
+
const fontSize = parseFloat(computed.fontSize);
|
|
184
|
+
if (!isNaN(fontSize) && fontSize > 0) {
|
|
185
|
+
styleOverrides.fontSize = fontSize;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const fontWeight = computed.fontWeight;
|
|
189
|
+
if (fontWeight) {
|
|
190
|
+
const numericWeight = parseInt(fontWeight, 10);
|
|
191
|
+
styleOverrides.fontWeight = isNaN(numericWeight) ? fontWeight : numericWeight;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const fontFamily = computed.fontFamily;
|
|
195
|
+
if (fontFamily) {
|
|
196
|
+
styleOverrides.fontFamily = fontFamily;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const color = computed.color;
|
|
200
|
+
if (color) {
|
|
201
|
+
styleOverrides.color = color;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const textTransform = computed.textTransform as CaptionStyle["textTransform"];
|
|
205
|
+
if (
|
|
206
|
+
textTransform === "none" ||
|
|
207
|
+
textTransform === "uppercase" ||
|
|
208
|
+
textTransform === "lowercase" ||
|
|
209
|
+
textTransform === "capitalize"
|
|
210
|
+
) {
|
|
211
|
+
styleOverrides.textTransform = textTransform;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const letterSpacing = computed.letterSpacing;
|
|
215
|
+
if (letterSpacing && letterSpacing !== "normal") {
|
|
216
|
+
const lsPx = parseFloat(letterSpacing);
|
|
217
|
+
const fsPx = styleOverrides.fontSize ?? DEFAULT_STYLE.fontSize;
|
|
218
|
+
if (!isNaN(lsPx) && fsPx > 0) {
|
|
219
|
+
// Convert px to em
|
|
220
|
+
styleOverrides.letterSpacing = lsPx / fsPx;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Step 6: Read container styles from group element (if visible background)
|
|
225
|
+
const containerOverrides: Partial<CaptionContainerStyle> = {};
|
|
226
|
+
|
|
227
|
+
if (firstGroupEl) {
|
|
228
|
+
const groupComputed = iframeWin.getComputedStyle(firstGroupEl);
|
|
229
|
+
const bgColor = groupComputed.backgroundColor;
|
|
230
|
+
// Only apply if it's not transparent/none
|
|
231
|
+
if (bgColor && bgColor !== "rgba(0, 0, 0, 0)" && bgColor !== "transparent") {
|
|
232
|
+
containerOverrides.backgroundColor = bgColor;
|
|
233
|
+
containerOverrides.backgroundOpacity = 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const borderRadius = parseFloat(groupComputed.borderRadius);
|
|
237
|
+
if (!isNaN(borderRadius) && borderRadius > 0) {
|
|
238
|
+
containerOverrides.borderRadius = borderRadius;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Parse padding shorthand or individual values
|
|
242
|
+
const paddingTop = parseFloat(groupComputed.paddingTop);
|
|
243
|
+
const paddingRight = parseFloat(groupComputed.paddingRight);
|
|
244
|
+
const paddingBottom = parseFloat(groupComputed.paddingBottom);
|
|
245
|
+
const paddingLeft = parseFloat(groupComputed.paddingLeft);
|
|
246
|
+
if (!isNaN(paddingTop)) containerOverrides.paddingTop = paddingTop;
|
|
247
|
+
if (!isNaN(paddingRight)) containerOverrides.paddingRight = paddingRight;
|
|
248
|
+
if (!isNaN(paddingBottom)) containerOverrides.paddingBottom = paddingBottom;
|
|
249
|
+
if (!isNaN(paddingLeft)) containerOverrides.paddingLeft = paddingLeft;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Step 7: Apply extracted styles to all groups in the model
|
|
253
|
+
for (const groupId of model.groupOrder) {
|
|
254
|
+
const group = model.groups.get(groupId);
|
|
255
|
+
if (!group) continue;
|
|
256
|
+
|
|
257
|
+
group.style = { ...group.style, ...styleOverrides };
|
|
258
|
+
group.containerStyle = { ...group.containerStyle, ...containerOverrides };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return model;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Parses a JS array literal containing `{ text, start, end }` objects.
|
|
267
|
+
*
|
|
268
|
+
* Handles:
|
|
269
|
+
* - Double-quoted and single-quoted string values
|
|
270
|
+
* - Trailing commas after the last element or property
|
|
271
|
+
* - Unquoted property keys (standard JS object literal syntax)
|
|
272
|
+
* - Numeric values for start/end
|
|
273
|
+
*/
|
|
274
|
+
function parseTranscriptArray(arrayLiteral: string): TranscriptWord[] {
|
|
275
|
+
// Try parsing as-is first (handles already-valid JSON)
|
|
276
|
+
let parsed: unknown;
|
|
277
|
+
try {
|
|
278
|
+
parsed = JSON.parse(arrayLiteral);
|
|
279
|
+
} catch {
|
|
280
|
+
// Not valid JSON — normalize single quotes, unquoted keys, trailing commas
|
|
281
|
+
let normalized = arrayLiteral;
|
|
282
|
+
normalized = normalized.replace(/'((?:[^'\\]|\\.)*)'/g, (_match, inner) => {
|
|
283
|
+
const escaped = inner.replace(/\\'/g, "'").replace(/"/g, '\\"');
|
|
284
|
+
return `"${escaped}"`;
|
|
285
|
+
});
|
|
286
|
+
normalized = normalized.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":');
|
|
287
|
+
normalized = normalized.replace(/,(\s*[}\]])/g, "$1");
|
|
288
|
+
parsed = JSON.parse(normalized);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!Array.isArray(parsed)) {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const words: TranscriptWord[] = [];
|
|
296
|
+
for (const item of parsed) {
|
|
297
|
+
if (
|
|
298
|
+
item !== null &&
|
|
299
|
+
typeof item === "object" &&
|
|
300
|
+
typeof (item as Record<string, unknown>).text === "string" &&
|
|
301
|
+
typeof (item as Record<string, unknown>).start === "number" &&
|
|
302
|
+
typeof (item as Record<string, unknown>).end === "number"
|
|
303
|
+
) {
|
|
304
|
+
const entry = item as Record<string, unknown>;
|
|
305
|
+
words.push({
|
|
306
|
+
text: entry.text as string,
|
|
307
|
+
start: entry.start as number,
|
|
308
|
+
end: entry.end as number,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return words;
|
|
314
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import {
|
|
3
|
+
CaptionAnimation,
|
|
4
|
+
CaptionAnimationSet,
|
|
5
|
+
CaptionContainerStyle,
|
|
6
|
+
CaptionModel,
|
|
7
|
+
CaptionStyle,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
let nextSplitId = 0;
|
|
11
|
+
|
|
12
|
+
interface CaptionState {
|
|
13
|
+
isEditMode: boolean;
|
|
14
|
+
model: CaptionModel | null;
|
|
15
|
+
selectedSegmentIds: Set<string>;
|
|
16
|
+
selectedGroupId: string | null;
|
|
17
|
+
sourceFilePath: string | null;
|
|
18
|
+
|
|
19
|
+
// Basic
|
|
20
|
+
setEditMode: (active: boolean) => void;
|
|
21
|
+
setModel: (model: CaptionModel | null) => void;
|
|
22
|
+
setSourceFilePath: (path: string | null) => void;
|
|
23
|
+
|
|
24
|
+
// Selection
|
|
25
|
+
selectSegment: (id: string, additive?: boolean) => void;
|
|
26
|
+
selectGroup: (id: string) => void;
|
|
27
|
+
selectAll: () => void;
|
|
28
|
+
clearSelection: () => void;
|
|
29
|
+
|
|
30
|
+
// Segment mutations
|
|
31
|
+
updateSegmentStyle: (segmentId: string, style: Partial<CaptionStyle>) => void;
|
|
32
|
+
updateSegmentText: (segmentId: string, text: string) => void;
|
|
33
|
+
updateSegmentTiming: (segmentId: string, start: number, end: number) => void;
|
|
34
|
+
|
|
35
|
+
// Group mutations
|
|
36
|
+
updateGroupStyle: (groupId: string, style: Partial<CaptionStyle>) => void;
|
|
37
|
+
updateGroupContainer: (groupId: string, container: Partial<CaptionContainerStyle>) => void;
|
|
38
|
+
updateGroupAnimation: (
|
|
39
|
+
groupId: string,
|
|
40
|
+
phase: keyof CaptionAnimationSet,
|
|
41
|
+
animation: Partial<CaptionAnimation>,
|
|
42
|
+
) => void;
|
|
43
|
+
splitGroup: (groupId: string, atSegmentId: string) => void;
|
|
44
|
+
mergeGroups: (groupId1: string, groupId2: string) => void;
|
|
45
|
+
|
|
46
|
+
// Bulk
|
|
47
|
+
updateSelectedStyle: (style: Partial<CaptionStyle>) => void;
|
|
48
|
+
applyAnimationToAll: (animation: CaptionAnimationSet) => void;
|
|
49
|
+
|
|
50
|
+
// Reset
|
|
51
|
+
reset: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const initialState = {
|
|
55
|
+
isEditMode: false,
|
|
56
|
+
model: null,
|
|
57
|
+
selectedSegmentIds: new Set<string>(),
|
|
58
|
+
selectedGroupId: null,
|
|
59
|
+
sourceFilePath: null,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const useCaptionStore = create<CaptionState>((set) => ({
|
|
63
|
+
...initialState,
|
|
64
|
+
|
|
65
|
+
// Basic
|
|
66
|
+
setEditMode: (active) => set({ isEditMode: active }),
|
|
67
|
+
setModel: (model) => set({ model }),
|
|
68
|
+
setSourceFilePath: (path) => set({ sourceFilePath: path }),
|
|
69
|
+
|
|
70
|
+
// Selection
|
|
71
|
+
selectSegment: (id, additive = false) =>
|
|
72
|
+
set((state) => {
|
|
73
|
+
if (additive) {
|
|
74
|
+
const next = new Set(state.selectedSegmentIds);
|
|
75
|
+
if (next.has(id)) {
|
|
76
|
+
next.delete(id);
|
|
77
|
+
} else {
|
|
78
|
+
next.add(id);
|
|
79
|
+
}
|
|
80
|
+
return { selectedSegmentIds: next, selectedGroupId: null };
|
|
81
|
+
}
|
|
82
|
+
return { selectedSegmentIds: new Set([id]), selectedGroupId: null };
|
|
83
|
+
}),
|
|
84
|
+
|
|
85
|
+
selectGroup: (id) =>
|
|
86
|
+
set((state) => {
|
|
87
|
+
const group = state.model?.groups.get(id);
|
|
88
|
+
if (!group) return {};
|
|
89
|
+
return {
|
|
90
|
+
selectedSegmentIds: new Set(group.segmentIds),
|
|
91
|
+
selectedGroupId: id,
|
|
92
|
+
};
|
|
93
|
+
}),
|
|
94
|
+
|
|
95
|
+
selectAll: () =>
|
|
96
|
+
set((state) => {
|
|
97
|
+
if (!state.model) return {};
|
|
98
|
+
return {
|
|
99
|
+
selectedSegmentIds: new Set(state.model.segments.keys()),
|
|
100
|
+
selectedGroupId: null,
|
|
101
|
+
};
|
|
102
|
+
}),
|
|
103
|
+
|
|
104
|
+
clearSelection: () => set({ selectedSegmentIds: new Set(), selectedGroupId: null }),
|
|
105
|
+
|
|
106
|
+
// Segment mutations
|
|
107
|
+
updateSegmentStyle: (segmentId, style) =>
|
|
108
|
+
set((state) => {
|
|
109
|
+
if (!state.model) return {};
|
|
110
|
+
const segment = state.model.segments.get(segmentId);
|
|
111
|
+
if (!segment) return {};
|
|
112
|
+
const segments = new Map(state.model.segments);
|
|
113
|
+
segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
|
|
114
|
+
return { model: { ...state.model, segments } };
|
|
115
|
+
}),
|
|
116
|
+
|
|
117
|
+
updateSegmentText: (segmentId, text) =>
|
|
118
|
+
set((state) => {
|
|
119
|
+
if (!state.model) return {};
|
|
120
|
+
const segment = state.model.segments.get(segmentId);
|
|
121
|
+
if (!segment) return {};
|
|
122
|
+
const segments = new Map(state.model.segments);
|
|
123
|
+
segments.set(segmentId, { ...segment, text });
|
|
124
|
+
return { model: { ...state.model, segments } };
|
|
125
|
+
}),
|
|
126
|
+
|
|
127
|
+
updateSegmentTiming: (segmentId, start, end) =>
|
|
128
|
+
set((state) => {
|
|
129
|
+
if (!state.model) return {};
|
|
130
|
+
const segment = state.model.segments.get(segmentId);
|
|
131
|
+
if (!segment) return {};
|
|
132
|
+
const segments = new Map(state.model.segments);
|
|
133
|
+
segments.set(segmentId, { ...segment, start, end });
|
|
134
|
+
return { model: { ...state.model, segments } };
|
|
135
|
+
}),
|
|
136
|
+
|
|
137
|
+
// Group mutations
|
|
138
|
+
updateGroupStyle: (groupId, style) =>
|
|
139
|
+
set((state) => {
|
|
140
|
+
if (!state.model) return {};
|
|
141
|
+
const group = state.model.groups.get(groupId);
|
|
142
|
+
if (!group) return {};
|
|
143
|
+
const groups = new Map(state.model.groups);
|
|
144
|
+
groups.set(groupId, { ...group, style: { ...group.style, ...style } });
|
|
145
|
+
return { model: { ...state.model, groups } };
|
|
146
|
+
}),
|
|
147
|
+
|
|
148
|
+
updateGroupContainer: (groupId, container) =>
|
|
149
|
+
set((state) => {
|
|
150
|
+
if (!state.model) return {};
|
|
151
|
+
const group = state.model.groups.get(groupId);
|
|
152
|
+
if (!group) return {};
|
|
153
|
+
const groups = new Map(state.model.groups);
|
|
154
|
+
groups.set(groupId, {
|
|
155
|
+
...group,
|
|
156
|
+
containerStyle: { ...group.containerStyle, ...container },
|
|
157
|
+
});
|
|
158
|
+
return { model: { ...state.model, groups } };
|
|
159
|
+
}),
|
|
160
|
+
|
|
161
|
+
updateGroupAnimation: (groupId, phase, animation) =>
|
|
162
|
+
set((state) => {
|
|
163
|
+
if (!state.model) return {};
|
|
164
|
+
const group = state.model.groups.get(groupId);
|
|
165
|
+
if (!group) return {};
|
|
166
|
+
const groups = new Map(state.model.groups);
|
|
167
|
+
const existingPhase = group.animation[phase];
|
|
168
|
+
const mergedPhase =
|
|
169
|
+
existingPhase !== null
|
|
170
|
+
? { ...existingPhase, ...animation }
|
|
171
|
+
: (animation as CaptionAnimation);
|
|
172
|
+
groups.set(groupId, {
|
|
173
|
+
...group,
|
|
174
|
+
animation: { ...group.animation, [phase]: mergedPhase },
|
|
175
|
+
});
|
|
176
|
+
return { model: { ...state.model, groups } };
|
|
177
|
+
}),
|
|
178
|
+
|
|
179
|
+
splitGroup: (groupId, atSegmentId) =>
|
|
180
|
+
set((state) => {
|
|
181
|
+
if (!state.model) return {};
|
|
182
|
+
const group = state.model.groups.get(groupId);
|
|
183
|
+
if (!group) return {};
|
|
184
|
+
|
|
185
|
+
const splitIndex = group.segmentIds.indexOf(atSegmentId);
|
|
186
|
+
if (splitIndex <= 0) return {};
|
|
187
|
+
|
|
188
|
+
const firstIds = group.segmentIds.slice(0, splitIndex);
|
|
189
|
+
const secondIds = group.segmentIds.slice(splitIndex);
|
|
190
|
+
|
|
191
|
+
const newGroupId = `group-split-${nextSplitId++}`;
|
|
192
|
+
const groups = new Map(state.model.groups);
|
|
193
|
+
groups.set(groupId, { ...group, segmentIds: firstIds });
|
|
194
|
+
groups.set(newGroupId, { ...group, id: newGroupId, segmentIds: secondIds });
|
|
195
|
+
|
|
196
|
+
const orderIndex = state.model.groupOrder.indexOf(groupId);
|
|
197
|
+
const groupOrder = [...state.model.groupOrder];
|
|
198
|
+
groupOrder.splice(orderIndex + 1, 0, newGroupId);
|
|
199
|
+
|
|
200
|
+
// Update groupIndex for segments in the new second group
|
|
201
|
+
const segments = new Map(state.model.segments);
|
|
202
|
+
secondIds.forEach((segId, idx) => {
|
|
203
|
+
const seg = segments.get(segId);
|
|
204
|
+
if (seg) {
|
|
205
|
+
segments.set(segId, { ...seg, groupIndex: idx });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return { model: { ...state.model, groups, segments, groupOrder } };
|
|
210
|
+
}),
|
|
211
|
+
|
|
212
|
+
mergeGroups: (groupId1, groupId2) =>
|
|
213
|
+
set((state) => {
|
|
214
|
+
if (!state.model) return {};
|
|
215
|
+
const group1 = state.model.groups.get(groupId1);
|
|
216
|
+
const group2 = state.model.groups.get(groupId2);
|
|
217
|
+
if (!group1 || !group2) return {};
|
|
218
|
+
|
|
219
|
+
const mergedSegmentIds = [...group1.segmentIds, ...group2.segmentIds];
|
|
220
|
+
|
|
221
|
+
const groups = new Map(state.model.groups);
|
|
222
|
+
groups.set(groupId1, { ...group1, segmentIds: mergedSegmentIds });
|
|
223
|
+
groups.delete(groupId2);
|
|
224
|
+
|
|
225
|
+
const groupOrder = state.model.groupOrder.filter((id) => id !== groupId2);
|
|
226
|
+
|
|
227
|
+
// Update groupIndex for segments from group2
|
|
228
|
+
const segments = new Map(state.model.segments);
|
|
229
|
+
group2.segmentIds.forEach((segId, idx) => {
|
|
230
|
+
const seg = segments.get(segId);
|
|
231
|
+
if (seg) {
|
|
232
|
+
segments.set(segId, { ...seg, groupIndex: group1.segmentIds.length + idx });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Clear selection if it referenced group2
|
|
237
|
+
const selectedGroupId = state.selectedGroupId === groupId2 ? null : state.selectedGroupId;
|
|
238
|
+
|
|
239
|
+
return { model: { ...state.model, groups, segments, groupOrder }, selectedGroupId };
|
|
240
|
+
}),
|
|
241
|
+
|
|
242
|
+
// Bulk
|
|
243
|
+
updateSelectedStyle: (style) =>
|
|
244
|
+
set((state) => {
|
|
245
|
+
if (!state.model || state.selectedSegmentIds.size === 0) return {};
|
|
246
|
+
const segments = new Map(state.model.segments);
|
|
247
|
+
for (const segmentId of state.selectedSegmentIds) {
|
|
248
|
+
const segment = segments.get(segmentId);
|
|
249
|
+
if (segment) {
|
|
250
|
+
segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { model: { ...state.model, segments } };
|
|
254
|
+
}),
|
|
255
|
+
|
|
256
|
+
applyAnimationToAll: (animation) =>
|
|
257
|
+
set((state) => {
|
|
258
|
+
if (!state.model) return {};
|
|
259
|
+
const groups = new Map(state.model.groups);
|
|
260
|
+
for (const [id, group] of groups) {
|
|
261
|
+
groups.set(id, { ...group, animation });
|
|
262
|
+
}
|
|
263
|
+
return { model: { ...state.model, groups } };
|
|
264
|
+
}),
|
|
265
|
+
|
|
266
|
+
// Reset
|
|
267
|
+
reset: () =>
|
|
268
|
+
set({
|
|
269
|
+
...initialState,
|
|
270
|
+
selectedSegmentIds: new Set<string>(),
|
|
271
|
+
}),
|
|
272
|
+
}));
|