@devbycrux/editor 0.1.0 → 0.2.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/package.json +1 -1
- package/src/__tests__/video-adapter-contract.test.ts +89 -0
- package/src/index.ts +23 -1
- package/src/types.ts +141 -0
- package/src/video/RenderModal.tsx +252 -0
- package/src/video/VersionPanel.tsx +83 -0
- package/src/video/VideoEditor.tsx +508 -0
- package/src/video/__tests__/VideoEditor.test.tsx +213 -0
- package/src/video/__tests__/captionRepair.test.ts +134 -0
- package/src/video/__tests__/cuts.test.ts +198 -0
- package/src/video/captionRepair.ts +41 -0
- package/src/video/cuts.ts +369 -0
- package/src/video/design-canvas.ts +11 -0
- package/src/video/preview/CaptionPreview.tsx +83 -0
- package/src/video/preview/CarouselPreview.tsx +35 -0
- package/src/video/preview/OverlayItemsLayer.tsx +603 -0
- package/src/video/preview/PreviewPlayer.tsx +178 -0
- package/src/video/preview/useDragOverlay.ts +167 -0
- package/src/video/preview/useVideoPlayback.ts +761 -0
- package/src/video/timeline/AudioTrackRow.tsx +406 -0
- package/src/video/timeline/AudioWaveformLayer.tsx +117 -0
- package/src/video/timeline/EditableSegment.tsx +30 -0
- package/src/video/timeline/Scrubber.tsx +184 -0
- package/src/video/timeline/Timeline.tsx +375 -0
- package/src/video/timeline/TimelineContext.ts +25 -0
- package/src/video/timeline/TranscriptModal.tsx +63 -0
- package/src/video/timeline/TranscriptPanel.tsx +86 -0
- package/src/video/timeline/VisualTrackRow.tsx +293 -0
- package/src/video/timeline/makeCaptionEdit.ts +32 -0
- package/src/video/timeline/multiSelectOps.ts +157 -0
- package/src/video/timeline/useItemDragDrop.ts +190 -0
- package/src/video/timeline/useTimelineZoom.ts +48 -0
- package/src/video/timeline/utils.ts +17 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import type { Project } from '../types'
|
|
2
|
+
import type { VisualItem, AudioTrack, CaptionSegment, Word } from '../schema'
|
|
3
|
+
|
|
4
|
+
/** A time range to excise from the timeline. */
|
|
5
|
+
export interface Cut {
|
|
6
|
+
start: number
|
|
7
|
+
end: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// ── ID generation ───────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function uniqueId(base: string): string {
|
|
13
|
+
return `${base}_split_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── Single base-clip helpers ────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function trimClipEnd(item: VisualItem, at: number): VisualItem {
|
|
19
|
+
const trimmedDur = at - item.start
|
|
20
|
+
return {
|
|
21
|
+
...item,
|
|
22
|
+
end: at,
|
|
23
|
+
...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + trimmedDur } : {}),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function trimClipStart(item: VisualItem, at: number): VisualItem {
|
|
28
|
+
// Lift-style: clip start advances to the cut end; inPoint advances by the same amount.
|
|
29
|
+
// The resulting gap before the clip's new start position is intentional.
|
|
30
|
+
const sourceOffset = at - item.start
|
|
31
|
+
return {
|
|
32
|
+
...item,
|
|
33
|
+
start: at,
|
|
34
|
+
...(item.inPoint !== undefined ? { inPoint: (item.inPoint ?? 0) + sourceOffset } : {}),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function splitClip(item: VisualItem, cut: Cut): [VisualItem, VisualItem] {
|
|
39
|
+
const leftDur = cut.start - item.start
|
|
40
|
+
const rightSourceOffset = cut.end - item.start
|
|
41
|
+
|
|
42
|
+
const left: VisualItem = {
|
|
43
|
+
...item,
|
|
44
|
+
end: cut.start,
|
|
45
|
+
...(item.outPoint !== undefined ? { outPoint: (item.inPoint ?? 0) + leftDur } : {}),
|
|
46
|
+
}
|
|
47
|
+
const right: VisualItem = {
|
|
48
|
+
...item,
|
|
49
|
+
id: uniqueId(item.id),
|
|
50
|
+
start: cut.end, // lift: right fragment stays at its original timeline position
|
|
51
|
+
...(item.inPoint !== undefined ? { inPoint: (item.inPoint ?? 0) + rightSourceOffset } : {}),
|
|
52
|
+
}
|
|
53
|
+
return [left, right]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function applyCutToBaseClip(item: VisualItem, cut: Cut): VisualItem[] {
|
|
57
|
+
const { start: A, end: B } = cut
|
|
58
|
+
|
|
59
|
+
if (item.end <= A) return [item] // fully before — unchanged
|
|
60
|
+
if (item.start >= B) return [item] // fully after — unchanged (lift, no shift)
|
|
61
|
+
if (item.start >= A && item.end <= B) return [] // fully within — deleted
|
|
62
|
+
|
|
63
|
+
if (item.start < A && item.end <= B) return [trimClipEnd(item, A)] // overlaps left
|
|
64
|
+
if (item.start >= A && item.start < B) return [trimClipStart(item, B)] // overlaps right
|
|
65
|
+
return splitClip(item, cut) // spans
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Caption helpers (captions shift — they're anchored to audio timing) ────
|
|
69
|
+
|
|
70
|
+
function applyCutToWords(words: Word[], cut: Cut): Word[] {
|
|
71
|
+
const cutDur = cut.end - cut.start
|
|
72
|
+
return words
|
|
73
|
+
.filter(w => !(w.start >= cut.start && w.end <= cut.end))
|
|
74
|
+
.map(w => {
|
|
75
|
+
if (w.end <= cut.start) return w
|
|
76
|
+
if (w.start >= cut.end) return { ...w, start: w.start - cutDur, end: w.end - cutDur }
|
|
77
|
+
if (w.start < cut.start) return { ...w, end: cut.start }
|
|
78
|
+
return { ...w, start: cut.start, end: w.end - cutDur }
|
|
79
|
+
})
|
|
80
|
+
.filter(w => w.end > w.start)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function applyCutToCaptions(segments: CaptionSegment[], cut: Cut): CaptionSegment[] {
|
|
84
|
+
const cutDur = cut.end - cut.start
|
|
85
|
+
const result: CaptionSegment[] = []
|
|
86
|
+
|
|
87
|
+
for (const seg of segments) {
|
|
88
|
+
if (seg.end <= cut.start) { result.push(seg); continue }
|
|
89
|
+
if (seg.start >= cut.end) {
|
|
90
|
+
result.push({
|
|
91
|
+
...seg,
|
|
92
|
+
start: seg.start - cutDur,
|
|
93
|
+
end: seg.end - cutDur,
|
|
94
|
+
words: seg.words?.map(w => ({ ...w, start: w.start - cutDur, end: w.end - cutDur })),
|
|
95
|
+
})
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
if (seg.start >= cut.start && seg.end <= cut.end) continue // deleted
|
|
99
|
+
|
|
100
|
+
// Partial overlap or spanning: trim to the kept portion
|
|
101
|
+
const newStart = seg.start < cut.start ? seg.start : cut.start
|
|
102
|
+
const newEnd = seg.end > cut.end ? seg.end - cutDur : cut.start
|
|
103
|
+
if (newEnd <= newStart) continue
|
|
104
|
+
result.push({
|
|
105
|
+
...seg,
|
|
106
|
+
start: newStart,
|
|
107
|
+
end: newEnd,
|
|
108
|
+
words: seg.words ? applyCutToWords(seg.words, cut).filter(w => w.end > w.start) : undefined,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
return result
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Per-item collapse cut ────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Collapse a single item around a cut.
|
|
118
|
+
* `cut` must already be clamped to [item.start, item.end].
|
|
119
|
+
* Right fragment starts at cut.start (item shrinks; gap appears at item tail).
|
|
120
|
+
*/
|
|
121
|
+
function cutSingleItem(item: VisualItem, cut: Cut): VisualItem[] {
|
|
122
|
+
const inPoint = item.inPoint ?? 0
|
|
123
|
+
const outPoint = item.outPoint ?? (inPoint + (item.end - item.start))
|
|
124
|
+
|
|
125
|
+
const physStart = inPoint + (cut.start - item.start)
|
|
126
|
+
const physEnd = inPoint + (cut.end - item.start)
|
|
127
|
+
|
|
128
|
+
const result: VisualItem[] = []
|
|
129
|
+
|
|
130
|
+
if (physStart > inPoint) {
|
|
131
|
+
result.push({
|
|
132
|
+
...item,
|
|
133
|
+
end: cut.start,
|
|
134
|
+
...(item.outPoint !== undefined ? { outPoint: physStart } : {}),
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
if (outPoint - physEnd > 0.001) {
|
|
138
|
+
result.push({
|
|
139
|
+
...item,
|
|
140
|
+
id: uniqueId(item.id),
|
|
141
|
+
start: cut.start,
|
|
142
|
+
end: cut.start + (outPoint - physEnd),
|
|
143
|
+
...(item.inPoint !== undefined ? { inPoint: physEnd } : {}),
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return result
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Apply a lift-style cut to a project.
|
|
154
|
+
*
|
|
155
|
+
* Only tracks[0] (primary clips) and captions are mutated.
|
|
156
|
+
* tracks[1+] overlay items are passed through unchanged — their start/end are
|
|
157
|
+
* absolute and they intentionally sit over any gap left in the primary track.
|
|
158
|
+
*
|
|
159
|
+
* Returns a new Project — no re-encoding, pure JSON.
|
|
160
|
+
*/
|
|
161
|
+
export function applyCutToTracks<P extends Project>(project: P, cut: Cut): P {
|
|
162
|
+
if (cut.end <= cut.start) return project
|
|
163
|
+
|
|
164
|
+
const [primaryTrack = [], ...overlayTracks] = project.tracks ?? []
|
|
165
|
+
const newPrimaryTrack = primaryTrack.flatMap(item => applyCutToBaseClip(item, cut))
|
|
166
|
+
|
|
167
|
+
const newCaptions = project.captions
|
|
168
|
+
? { ...project.captions, segments: applyCutToCaptions(project.captions.segments, cut) }
|
|
169
|
+
: project.captions
|
|
170
|
+
|
|
171
|
+
return { ...project, tracks: [newPrimaryTrack, ...overlayTracks], captions: newCaptions }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Close all gaps between primary clips by shifting each clip left to butt
|
|
176
|
+
* against the previous one. Captions and all other tracks are remapped to
|
|
177
|
+
* follow using the same shifts.
|
|
178
|
+
*
|
|
179
|
+
* The primary track is the first track containing video clips; falls back to
|
|
180
|
+
* tracks[0] if no video track exists.
|
|
181
|
+
*
|
|
182
|
+
* Returns the same project reference if no gaps exist (safe to call always).
|
|
183
|
+
*/
|
|
184
|
+
export function collapseGaps<P extends Project>(project: P): P {
|
|
185
|
+
const tracks = project.tracks ?? []
|
|
186
|
+
|
|
187
|
+
const primaryIdx = tracks.findIndex(t => t.some(c => c.type === 'video'))
|
|
188
|
+
const effectiveIdx = primaryIdx >= 0 ? primaryIdx : 0
|
|
189
|
+
|
|
190
|
+
const primaryTrack = tracks[effectiveIdx] ?? []
|
|
191
|
+
if (primaryTrack.length < 2) return project
|
|
192
|
+
|
|
193
|
+
const sorted = [...primaryTrack].sort((a, b) => a.start - b.start)
|
|
194
|
+
|
|
195
|
+
let cursor = sorted[0].start
|
|
196
|
+
let anyGap = false
|
|
197
|
+
const shifts: Array<{ oldStart: number; oldEnd: number; delta: number }> = []
|
|
198
|
+
|
|
199
|
+
const compacted = sorted.map(clip => {
|
|
200
|
+
const duration = clip.end - clip.start
|
|
201
|
+
const delta = cursor - clip.start
|
|
202
|
+
if (delta !== 0) anyGap = true
|
|
203
|
+
shifts.push({ oldStart: clip.start, oldEnd: clip.end, delta })
|
|
204
|
+
const out = { ...clip, start: cursor, end: cursor + duration }
|
|
205
|
+
cursor += duration
|
|
206
|
+
return out
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
if (!anyGap) return project
|
|
210
|
+
|
|
211
|
+
function applyShift(start: number, end: number): number {
|
|
212
|
+
const mid = (start + end) / 2
|
|
213
|
+
const entry = shifts.find(s => mid >= s.oldStart && mid < s.oldEnd)
|
|
214
|
+
return entry?.delta ?? 0
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const newTracks = tracks.map((track, i) => {
|
|
218
|
+
if (i === effectiveIdx) return compacted
|
|
219
|
+
return track.map(clip => {
|
|
220
|
+
const d = applyShift(clip.start, clip.end)
|
|
221
|
+
if (d === 0) return clip
|
|
222
|
+
return { ...clip, start: clip.start + d, end: clip.end + d }
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
let newCaptions = project.captions
|
|
227
|
+
if (newCaptions) {
|
|
228
|
+
const segments = newCaptions.segments.map(seg => {
|
|
229
|
+
const d = applyShift(seg.start, seg.end)
|
|
230
|
+
if (d === 0) return seg
|
|
231
|
+
return {
|
|
232
|
+
...seg,
|
|
233
|
+
start: seg.start + d,
|
|
234
|
+
end: seg.end + d,
|
|
235
|
+
words: seg.words?.map(w => ({ ...w, start: w.start + d, end: w.end + d })),
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
newCaptions = { ...newCaptions, segments }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { ...project, tracks: newTracks, captions: newCaptions }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Apply a collapse-style cut to a single item identified by `itemId`.
|
|
246
|
+
*
|
|
247
|
+
* - If the item is in `tracks[0]`, captions are adjusted for the clamped cut,
|
|
248
|
+
* but only for segments within [item.start, item.end] — other clips' captions
|
|
249
|
+
* are left untouched.
|
|
250
|
+
* - If the item is in an overlay track, captions are not touched.
|
|
251
|
+
* - If `itemId` is not found, the project is returned unchanged.
|
|
252
|
+
*
|
|
253
|
+
* Returns a new Project — no re-encoding, pure JSON.
|
|
254
|
+
*/
|
|
255
|
+
export function applyCutToItem<P extends Project>(project: P, itemId: string, cut: Cut): P {
|
|
256
|
+
if (cut.end <= cut.start) return project
|
|
257
|
+
|
|
258
|
+
const [primaryTrack = [], ...overlayTracks] = project.tracks ?? []
|
|
259
|
+
|
|
260
|
+
// ── Primary track ──
|
|
261
|
+
const primaryIdx = primaryTrack.findIndex(item => item.id === itemId)
|
|
262
|
+
if (primaryIdx !== -1) {
|
|
263
|
+
const item = primaryTrack[primaryIdx]
|
|
264
|
+
const cutStart = Math.max(cut.start, item.start)
|
|
265
|
+
const cutEnd = Math.min(cut.end, item.end)
|
|
266
|
+
if (cutEnd <= cutStart) return project
|
|
267
|
+
|
|
268
|
+
const clamped = { start: cutStart, end: cutEnd }
|
|
269
|
+
const newPrimary = [
|
|
270
|
+
...primaryTrack.slice(0, primaryIdx),
|
|
271
|
+
...cutSingleItem(item, clamped),
|
|
272
|
+
...primaryTrack.slice(primaryIdx + 1),
|
|
273
|
+
]
|
|
274
|
+
// Only adjust captions within this clip's timeline window [item.start, item.end].
|
|
275
|
+
// Captions belonging to other clips must not be shifted — applyCutToCaptions shifts
|
|
276
|
+
// everything after cutEnd, which would misalign adjacent clips.
|
|
277
|
+
let newCaptions = project.captions
|
|
278
|
+
if (newCaptions) {
|
|
279
|
+
const inner = newCaptions.segments.filter(s => s.end > item.start && s.start < item.end)
|
|
280
|
+
const outer = newCaptions.segments.filter(s => !(s.end > item.start && s.start < item.end))
|
|
281
|
+
const adjusted = applyCutToCaptions(inner, clamped)
|
|
282
|
+
const merged = [...adjusted, ...outer].sort((a, b) => a.start - b.start)
|
|
283
|
+
newCaptions = { ...newCaptions, segments: merged }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { ...project, tracks: [newPrimary, ...overlayTracks], captions: newCaptions }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Overlay tracks ──
|
|
290
|
+
for (let ti = 0; ti < overlayTracks.length; ti++) {
|
|
291
|
+
const track = overlayTracks[ti]
|
|
292
|
+
const itemIdx = track.findIndex(item => item.id === itemId)
|
|
293
|
+
if (itemIdx === -1) continue
|
|
294
|
+
|
|
295
|
+
const item = track[itemIdx]
|
|
296
|
+
const cutStart = Math.max(cut.start, item.start)
|
|
297
|
+
const cutEnd = Math.min(cut.end, item.end)
|
|
298
|
+
if (cutEnd <= cutStart) return project
|
|
299
|
+
|
|
300
|
+
const clamped = { start: cutStart, end: cutEnd }
|
|
301
|
+
const newTrack = [
|
|
302
|
+
...track.slice(0, itemIdx),
|
|
303
|
+
...cutSingleItem(item, clamped),
|
|
304
|
+
...track.slice(itemIdx + 1),
|
|
305
|
+
]
|
|
306
|
+
const newOverlays = overlayTracks.map((t, i) => (i === ti ? newTrack : t))
|
|
307
|
+
return { ...project, tracks: [primaryTrack, ...newOverlays] }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return project // itemId not found
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Audio split helper ────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
function splitAudioTrack(track: AudioTrack, at: number): [AudioTrack, AudioTrack] {
|
|
316
|
+
const inPoint = track.inPoint ?? 0
|
|
317
|
+
const sourceOffset = at - track.start
|
|
318
|
+
|
|
319
|
+
const left: AudioTrack = {
|
|
320
|
+
...track,
|
|
321
|
+
end: at,
|
|
322
|
+
outPoint: inPoint + sourceOffset,
|
|
323
|
+
}
|
|
324
|
+
const right: AudioTrack = {
|
|
325
|
+
...track,
|
|
326
|
+
id: uniqueId(track.id),
|
|
327
|
+
start: at,
|
|
328
|
+
inPoint: inPoint + sourceOffset,
|
|
329
|
+
}
|
|
330
|
+
return [left, right]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Split a clip at a single point in time, producing two adjacent clips with no gap.
|
|
335
|
+
*
|
|
336
|
+
* - If `itemId` is provided, only that item is split (must contain `at`).
|
|
337
|
+
* Works for both visual items (tracks[][]) and audio tracks (audio.tracks[]).
|
|
338
|
+
* - If `itemId` is null, every clip across all tracks that contains `at` is split.
|
|
339
|
+
* - Returns the same project reference if nothing was split.
|
|
340
|
+
*/
|
|
341
|
+
export function splitAtTime<P extends Project>(project: P, at: number, itemId: string | null): P {
|
|
342
|
+
let changed = false
|
|
343
|
+
|
|
344
|
+
const newTracks = (project.tracks ?? []).map(track => {
|
|
345
|
+
const next = track.flatMap(item => {
|
|
346
|
+
if (itemId !== null && item.id !== itemId) return [item]
|
|
347
|
+
if (at <= item.start || at >= item.end) return [item] // playhead not inside this clip
|
|
348
|
+
changed = true
|
|
349
|
+
return splitClip(item, { start: at, end: at })
|
|
350
|
+
})
|
|
351
|
+
return next
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// Also split audio tracks
|
|
355
|
+
const audioTracks = project.audio?.tracks ?? []
|
|
356
|
+
const newAudioTracks = audioTracks.flatMap(track => {
|
|
357
|
+
if (itemId !== null && track.id !== itemId) return [track]
|
|
358
|
+
if (at <= track.start || at >= track.end) return [track]
|
|
359
|
+
changed = true
|
|
360
|
+
return splitAudioTrack(track, at)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
if (!changed) return project
|
|
364
|
+
return {
|
|
365
|
+
...project,
|
|
366
|
+
tracks: newTracks,
|
|
367
|
+
audio: { ...project.audio, tracks: newAudioTracks },
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Overlay design canvas — must match montaj_assets/render/render.js.
|
|
2
|
+
// Overlays are authored in 1080-short-edge design coordinates regardless of
|
|
3
|
+
// the project's output resolution; the renderer upscales to settings.resolution
|
|
4
|
+
// at compose time via pixelRatio. The preview mirrors that here.
|
|
5
|
+
export function getOverlayDesignCanvas(
|
|
6
|
+
resolution: [number, number] | undefined | null,
|
|
7
|
+
): [number, number] {
|
|
8
|
+
const [w, h] = resolution ?? [1080, 1920]
|
|
9
|
+
const ratio = 1080 / Math.min(w, h)
|
|
10
|
+
return [Math.round((w * ratio) / 2) * 2, Math.round((h * ratio) / 2) * 2]
|
|
11
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CaptionPreview — renders the active caption style on top of the video player.
|
|
3
|
+
*
|
|
4
|
+
* Loads the exact same JSX template used by the render engine
|
|
5
|
+
* (render/templates/captions/<style>.jsx) so preview and final output
|
|
6
|
+
* are a single source of truth. Receives `compileOverlay` as a prop from
|
|
7
|
+
* PreviewPlayer (sourced from adapter.compileOverlay) so the package has no
|
|
8
|
+
* direct dependency on the host's overlay-eval module.
|
|
9
|
+
*
|
|
10
|
+
* The caption layer is sized at the native render resolution (1080 × 1920) and
|
|
11
|
+
* scaled down to fit the player via ResizeObserver so pixel values are 1:1 with
|
|
12
|
+
* the render output.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useRef, useState } from 'react'
|
|
16
|
+
import type { Captions } from '../../schema'
|
|
17
|
+
import type { OverlayFactory } from '../../types'
|
|
18
|
+
import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
|
|
19
|
+
|
|
20
|
+
const RENDER_W = 1080
|
|
21
|
+
const RENDER_H = 1920
|
|
22
|
+
|
|
23
|
+
interface CaptionPreviewProps {
|
|
24
|
+
track: Captions
|
|
25
|
+
currentTime: number
|
|
26
|
+
fps: number
|
|
27
|
+
compileOverlay: (src: string) => Promise<OverlayFactory>
|
|
28
|
+
resolveCaptionTemplate?: (style: string) => string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function CaptionPreview({ track, currentTime, fps, compileOverlay, resolveCaptionTemplate }: CaptionPreviewProps) {
|
|
32
|
+
const wrapRef = useRef<HTMLDivElement>(null)
|
|
33
|
+
const [scale, setScale] = useState<number | null>(null)
|
|
34
|
+
const [factory, setFactory] = useState<OverlayFactory | null>(null)
|
|
35
|
+
|
|
36
|
+
// Scale the 1080×1920 render layer to fit the actual player size
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const el = wrapRef.current
|
|
39
|
+
if (!el) return
|
|
40
|
+
const obs = new ResizeObserver(([entry]) => {
|
|
41
|
+
setScale(entry.contentRect.width / RENDER_W)
|
|
42
|
+
})
|
|
43
|
+
obs.observe(el)
|
|
44
|
+
return () => obs.disconnect()
|
|
45
|
+
}, [])
|
|
46
|
+
|
|
47
|
+
// Load the render-engine template for the active style.
|
|
48
|
+
// If the host did not supply resolveCaptionTemplate, render nothing (graceful
|
|
49
|
+
// no-op — host does not support captions).
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
setFactory(null)
|
|
52
|
+
if (!resolveCaptionTemplate) return
|
|
53
|
+
const templateSrc = resolveCaptionTemplate(track.style)
|
|
54
|
+
compileOverlay(templateSrc)
|
|
55
|
+
.then(f => setFactory(() => f))
|
|
56
|
+
.catch(e => console.warn('[CaptionPreview] failed to load template:', e))
|
|
57
|
+
}, [track.style, compileOverlay, resolveCaptionTemplate])
|
|
58
|
+
|
|
59
|
+
const frame = Math.round(currentTime * fps)
|
|
60
|
+
const lastSeg = track.segments[track.segments.length - 1]
|
|
61
|
+
const element = (factory && scale !== null)
|
|
62
|
+
? factory(frame, fps, Math.round((lastSeg?.end ?? 0) * fps), { segments: track.segments })
|
|
63
|
+
: null
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div ref={wrapRef} className="absolute inset-0 pointer-events-none overflow-hidden">
|
|
67
|
+
<OverlayErrorBoundary label={`caption: ${track.style}`} resetKey={track.style}>
|
|
68
|
+
{element && scale !== null && (
|
|
69
|
+
<div style={{
|
|
70
|
+
position: 'absolute',
|
|
71
|
+
top: 0, left: 0,
|
|
72
|
+
width: RENDER_W,
|
|
73
|
+
height: RENDER_H,
|
|
74
|
+
transform: `scale(${scale})`,
|
|
75
|
+
transformOrigin: 'top left',
|
|
76
|
+
}}>
|
|
77
|
+
{element}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</OverlayErrorBoundary>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { EditorProject as Project } from '../../schema'
|
|
2
|
+
import SlideCanvas from '../../carousel/SlideCanvas'
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
project: Project
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function CarouselPreview({ project }: Props) {
|
|
9
|
+
const slides = project.slides ?? []
|
|
10
|
+
const [w, h] = project.settings.resolution
|
|
11
|
+
const targetWidth = 240
|
|
12
|
+
const scale = targetWidth / w
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="grid grid-cols-3 gap-3 p-4 overflow-y-auto">
|
|
16
|
+
{slides.map((slide, i) => (
|
|
17
|
+
<div key={slide.id} className="flex flex-col items-center">
|
|
18
|
+
<SlideCanvas
|
|
19
|
+
slide={slide}
|
|
20
|
+
width={w}
|
|
21
|
+
height={h}
|
|
22
|
+
interactive={false}
|
|
23
|
+
scale={scale}
|
|
24
|
+
/>
|
|
25
|
+
<div className="text-xs text-gray-500 mt-1">Slide {i + 1}</div>
|
|
26
|
+
</div>
|
|
27
|
+
))}
|
|
28
|
+
{slides.length === 0 && (
|
|
29
|
+
<div className="col-span-3 text-center text-gray-600 text-sm py-8">
|
|
30
|
+
No slides yet
|
|
31
|
+
</div>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|