@bycrux/editor 1.0.2 → 1.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 +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- package/src/engine/__tests__/eligibility.test.ts +97 -0
- package/src/engine/__tests__/engine.test.ts +47 -2
- package/src/engine/__tests__/scheduler.test.ts +385 -4
- package/src/engine/__tests__/source-host.test.ts +165 -0
- package/src/engine/eligibility.ts +37 -1
- package/src/engine/index.ts +165 -29
- package/src/engine/scheduler.ts +345 -23
- package/src/index.ts +8 -0
- package/src/schema.ts +41 -1
- package/src/video/VideoEditor.tsx +106 -10
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
- package/src/video/__tests__/VideoEditor.test.tsx +169 -0
- package/src/video/__tests__/cuts.test.ts +84 -0
- package/src/video/__tests__/exportDurationSec.test.ts +60 -0
- package/src/video/__tests__/markerDropTime.test.ts +25 -0
- package/src/video/captionStyleDefaults.ts +2 -2
- package/src/video/cuts.ts +30 -2
- package/src/video/preview/OverlayItemsLayer.tsx +85 -6
- package/src/video/preview/PreviewPlayer.tsx +28 -1
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +91 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +49 -1
- package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- package/src/video/preview/useVideoPlayback.ts +18 -3
- package/src/video/timeline/Timeline.tsx +49 -1
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
- package/src/video/timeline/__tests__/markers.test.ts +125 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +307 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +100 -4
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
- package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +216 -1
- package/src/video/timeline/canvas/draw.ts +104 -7
- package/src/video/timeline/canvas/hit-test.ts +72 -1
- package/src/video/timeline/canvas/pointer-machine.ts +142 -5
- package/src/video/timeline/markers.ts +109 -0
- package/src/video/timeline/timeline-model.ts +286 -15
|
@@ -11,10 +11,12 @@ import { getOverlayDesignCanvas } from './design-canvas'
|
|
|
11
11
|
import { availableResolutionTiers, availableFpsTiers, currentResolutionTier, maxExportFps } from './export-limits'
|
|
12
12
|
import { applyTheme, defaultMontajTheme, isLightTheme } from '../theme'
|
|
13
13
|
import { collapseGaps, rippleDelete, splitAtTime } from './cuts'
|
|
14
|
+
import { addMarker } from './timeline/markers'
|
|
14
15
|
import { repairCaptionWords } from './captionRepair'
|
|
15
16
|
import { maxCaptionLane, normalizeCaptionLanes } from './captionLanes'
|
|
16
17
|
import Timeline, { type TimelineActions, type TimelineMode } from './timeline/Timeline'
|
|
17
|
-
import {
|
|
18
|
+
import { visualDuration } from '@bycrux/timeline-core'
|
|
19
|
+
import { computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, enabledTrackItems, mapTrackItems, normalizeAudioTracks, trackItems, withEnabledItemTracks } from './timeline/timeline-model'
|
|
18
20
|
import { makeCaptionEdit, type CaptionEditPatch } from './timeline/makeCaptionEdit'
|
|
19
21
|
import PreviewPlayer, { type TransportHandle, type ScrubHandle } from './preview/PreviewPlayer'
|
|
20
22
|
import SocialPreviewMenu, { PlatformGlyph, platformOption } from './preview/SocialPreviewMenu'
|
|
@@ -144,6 +146,56 @@ export function backfillCaptionIds<P extends Project>(project: P): P {
|
|
|
144
146
|
}
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Where a marker lands.
|
|
151
|
+
*
|
|
152
|
+
* With the preview axis ON, the yellow line is what the operator is looking at,
|
|
153
|
+
* so that is where the marker goes; with it OFF — or ON but with the pointer
|
|
154
|
+
* off the timeline, where `hoverScrub` reads null — the playhead is.
|
|
155
|
+
*
|
|
156
|
+
* `??`, never `||`: a hovered 0 is the start of the timeline, a perfectly
|
|
157
|
+
* ordinary place to mark, and `||` would silently reroute it to the playhead.
|
|
158
|
+
*
|
|
159
|
+
* Exported and tested on its own because `hoverScrub` is a private ref with no
|
|
160
|
+
* test seam. `handleSplit` carries the same rule inline
|
|
161
|
+
* (`previewAxis ? (hoverScrub.get() ?? undefined) : undefined`) and is
|
|
162
|
+
* deliberately NOT refactored onto this helper here: that is existing,
|
|
163
|
+
* currently-untested behavior and changing it is outside this plan. If you
|
|
164
|
+
* touch Split's rule later, unify it with this one.
|
|
165
|
+
*/
|
|
166
|
+
export function markerDropTime(previewAxis: boolean, hovered: number | null, playhead: number): number {
|
|
167
|
+
return previewAxis ? (hovered ?? playhead) : playhead
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* How long the export dialog thinks the finished video is.
|
|
172
|
+
*
|
|
173
|
+
* This is what bounds the cover-frame picker, so it has to be the duration the
|
|
174
|
+
* RENDER produces — hence `visualDuration`, timeline-core's port of render.js's
|
|
175
|
+
* own `getTotalDurationSeconds`: the furthest `end` over every item on every
|
|
176
|
+
* track, audio excluded. Anything narrower offers the operator a grid that
|
|
177
|
+
* stops before their video does.
|
|
178
|
+
*
|
|
179
|
+
* It used to be `max(end)` over the track-0 VIDEO items alone (the same list
|
|
180
|
+
* that feeds `keeps`). That reads track 0 as "the footage track", which is only
|
|
181
|
+
* true when there IS footage: an overlay-only project — one track of nothing
|
|
182
|
+
* but overlays, which the animations workflow emits routinely — measured 0, so
|
|
183
|
+
* the picker reported "No frames to pick from" for a project made entirely of
|
|
184
|
+
* pickable frames. The milder version of the same bug cut the grid short
|
|
185
|
+
* whenever an outro overlay outlived the last clip.
|
|
186
|
+
*
|
|
187
|
+
* `keeps` is deliberately NOT widened to match. It answers a different
|
|
188
|
+
* question — which windows contain real footage the SDR curve thumbnails can
|
|
189
|
+
* sample — and an overlay-only project genuinely has none.
|
|
190
|
+
*
|
|
191
|
+
* Enabled tracks only, and via `withEnabledItemTracks` because `visualDuration`
|
|
192
|
+
* wants the flat array-of-arrays shape: a track the operator has switched off
|
|
193
|
+
* is skipped by the render too, so it must not stretch the picker.
|
|
194
|
+
*/
|
|
195
|
+
export function exportDurationSec(project: { tracks?: unknown }): number {
|
|
196
|
+
return visualDuration(withEnabledItemTracks(project))
|
|
197
|
+
}
|
|
198
|
+
|
|
147
199
|
/**
|
|
148
200
|
* `<VideoEditor>` — the assembled, host-agnostic video editor.
|
|
149
201
|
*
|
|
@@ -253,6 +305,18 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
253
305
|
// `onProjectChange` and paying an extra render for it. Chained, the host only
|
|
254
306
|
// ever sees the input or the finished result.
|
|
255
307
|
//
|
|
308
|
+
// A THIRD pass, same treatment, backfills AUDIO track ids
|
|
309
|
+
// (`normalizeAudioTracks`). `audio.tracks[*].id` is optional on disk
|
|
310
|
+
// (`docs/schemas/project.md` only requires `src`) but required by the
|
|
311
|
+
// editor's `AudioTrack` type, and every audio mutation — `updateAudioTrack`,
|
|
312
|
+
// this component's own track-replace path, multi-select mute/delete — keys
|
|
313
|
+
// a track by `id` with `===`. Id-less tracks all read as the same
|
|
314
|
+
// `undefined`, so editing one fanned the edit out to every audio track at
|
|
315
|
+
// once. Chained onto the captions result (not `sync.project` directly) for
|
|
316
|
+
// the same half-normalized-frame reason as the lane pass, and keyed into the
|
|
317
|
+
// deps below on `sync.project.audio` so an id-less track arriving via SSE
|
|
318
|
+
// mid-session self-heals too, not just on first mount.
|
|
319
|
+
//
|
|
256
320
|
// `applyExternal` — no save, no undo push: this is normalization of loaded
|
|
257
321
|
// data, not a user edit, so it must not dirty the project or contend with the
|
|
258
322
|
// undo stack; the ids and lanes persist naturally the next time the operator
|
|
@@ -267,9 +331,10 @@ export default function VideoEditor<P extends Project = Project>({
|
|
|
267
331
|
const captions = captionGestureRef.current
|
|
268
332
|
? backfilled.captions
|
|
269
333
|
: normalizeCaptionLanes(backfilled.captions)
|
|
270
|
-
const
|
|
334
|
+
const withCaptions = captions === backfilled.captions ? backfilled : { ...backfilled, captions }
|
|
335
|
+
const normalized = normalizeAudioTracks(withCaptions)
|
|
271
336
|
if (normalized !== sync.project) sync.applyExternal(normalized)
|
|
272
|
-
}, [sync.project.id, sync.project.captions])
|
|
337
|
+
}, [sync.project.id, sync.project.captions, sync.project.audio])
|
|
273
338
|
|
|
274
339
|
// Notify the host of every authoritative change — edits, undo/redo, and SSE
|
|
275
340
|
// frames — so its non-editor chrome (title, status pill) stays in sync. Mirrors
|
|
@@ -1165,7 +1230,10 @@ function ReviewSurface<P extends Project>({
|
|
|
1165
1230
|
keeps: renderKeeps,
|
|
1166
1231
|
imageTone: { value: currentImageTone ?? 'vivid', set: handleImageToneChange },
|
|
1167
1232
|
name: project.name?.trim() || undefined,
|
|
1168
|
-
|
|
1233
|
+
// NOT derived from `renderKeeps` — see `exportDurationSec`. Keeps covers
|
|
1234
|
+
// the footage windows the curve thumbnails can sample; the cover picker
|
|
1235
|
+
// has to span the whole rendered video, overlay-only projects included.
|
|
1236
|
+
durationSec: exportDurationSec(project),
|
|
1169
1237
|
aspectRatio: (() => {
|
|
1170
1238
|
const r = project.settings?.resolution
|
|
1171
1239
|
return r && r[0] > 0 && r[1] > 0 ? r[0] / r[1] : undefined
|
|
@@ -1181,7 +1249,13 @@ function ReviewSurface<P extends Project>({
|
|
|
1181
1249
|
set: handleExportFpsChange,
|
|
1182
1250
|
},
|
|
1183
1251
|
}), [
|
|
1184
|
-
|
|
1252
|
+
// `project.tracks` is listed for `exportDurationSec`, which reads every
|
|
1253
|
+
// track rather than just the footage keeps. `renderKeeps` already changes
|
|
1254
|
+
// identity with it, so this is belt-and-braces — but relying on that would
|
|
1255
|
+
// leave the duration silently stale the day keeps stops being memoized on
|
|
1256
|
+
// tracks. Deliberately NOT the whole `project`: this memo is kept off
|
|
1257
|
+
// unrelated project mutations on purpose (see `availableRes` above).
|
|
1258
|
+
isHdrProject, renderKeeps, project.tracks, currentImageTone, handleImageToneChange,
|
|
1185
1259
|
project.name, project.settings?.resolution, project.settings?.fps,
|
|
1186
1260
|
availableRes, availableFpsList, handleExportResolutionChange, handleExportFpsChange,
|
|
1187
1261
|
])
|
|
@@ -1385,13 +1459,20 @@ function ReviewSurface<P extends Project>({
|
|
|
1385
1459
|
*/
|
|
1386
1460
|
function commitTimelineEdit(p: Project) {
|
|
1387
1461
|
captionGestureRef.current = false
|
|
1388
|
-
// Fold
|
|
1462
|
+
// Fold BOTH derived crossfades into the SAME commit as the gesture, so a
|
|
1389
1463
|
// drag/trim that ends overlapping a neighbour lands as ONE undo step (the
|
|
1390
1464
|
// move and its derived fade together) rather than the move here plus a
|
|
1391
|
-
// separate fade commit.
|
|
1392
|
-
//
|
|
1393
|
-
|
|
1394
|
-
|
|
1465
|
+
// separate fade commit. Both passes are idempotent — a gesture that creates
|
|
1466
|
+
// no overlap gets its project back unchanged — so a move that overlaps
|
|
1467
|
+
// nothing is untouched by either.
|
|
1468
|
+
//
|
|
1469
|
+
// Audio first, then visual, on the audio pass's OUTPUT: the two touch
|
|
1470
|
+
// disjoint parts of the project (`audio.tracks` vs `tracks`), so the order
|
|
1471
|
+
// is not load-bearing, but chaining them means one commit carries both
|
|
1472
|
+
// rather than the second silently dropping the first's work.
|
|
1473
|
+
const withAudio = computeAutoCrossfade(p) ?? p
|
|
1474
|
+
const withVisual = computeVisualCrossfade(withAudio) ?? withAudio
|
|
1475
|
+
sync.mutateTransient(() => withVisual as P)
|
|
1395
1476
|
void sync.commit()
|
|
1396
1477
|
}
|
|
1397
1478
|
|
|
@@ -1601,6 +1682,13 @@ function ReviewSurface<P extends Project>({
|
|
|
1601
1682
|
void sync.mutate(() => updated as P)
|
|
1602
1683
|
}
|
|
1603
1684
|
|
|
1685
|
+
function handleAddMarker(at?: number) {
|
|
1686
|
+
const base = syncProjectRef.current
|
|
1687
|
+
const updated = addMarker(base, at ?? clock.get())
|
|
1688
|
+
if (updated === base) return // key repeat inside the half-frame window
|
|
1689
|
+
void sync.mutate(() => updated as P)
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1604
1692
|
function handleRippleToggle() {
|
|
1605
1693
|
const next = !rippleMode
|
|
1606
1694
|
setRippleMode(next)
|
|
@@ -1737,6 +1825,13 @@ function ReviewSurface<P extends Project>({
|
|
|
1737
1825
|
// is selected (a no-op if the axis isn't over the item).
|
|
1738
1826
|
action: () => handleSplit(previewAxis ? (hoverScrub.get() ?? undefined) : undefined),
|
|
1739
1827
|
},
|
|
1828
|
+
{
|
|
1829
|
+
id: 'video.add-marker',
|
|
1830
|
+
description: 'Drop a marker',
|
|
1831
|
+
keyHint: ['M'],
|
|
1832
|
+
matches: matchesPlainKey('m'),
|
|
1833
|
+
action: () => handleAddMarker(markerDropTime(previewAxis, hoverScrub.get(), clock.get())),
|
|
1834
|
+
},
|
|
1740
1835
|
{
|
|
1741
1836
|
id: 'video.undo',
|
|
1742
1837
|
description: 'Undo',
|
|
@@ -1877,6 +1972,7 @@ function ReviewSurface<P extends Project>({
|
|
|
1877
1972
|
const paletteCommands: PaletteCommand[] = [
|
|
1878
1973
|
{ id: 'play-pause', label: 'Play/Pause', keyHint: ['Space'], run: () => transportRef.current?.togglePlay() },
|
|
1879
1974
|
{ id: 'split', label: 'Split at playhead', keyHint: ['S'], run: () => handleSplit() },
|
|
1975
|
+
{ id: 'add-marker', label: 'Drop a marker', keyHint: ['M'], run: () => handleAddMarker() },
|
|
1880
1976
|
]
|
|
1881
1977
|
if (primarySelectedId) {
|
|
1882
1978
|
paletteCommands.push({ id: 'ripple-delete', label: 'Ripple-delete selection', keyHint: ['⇧', 'Delete'], run: () => handleRippleDelete() })
|
|
@@ -386,6 +386,70 @@ describe('VideoEditor — preview axis toggle', () => {
|
|
|
386
386
|
})
|
|
387
387
|
})
|
|
388
388
|
|
|
389
|
+
describe('VideoEditor — M drops a marker', () => {
|
|
390
|
+
it('drops one at the playhead with an auto-number label', async () => {
|
|
391
|
+
const adapter = makeFakeAdapter()
|
|
392
|
+
const onProjectChange = vi.fn()
|
|
393
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
|
|
394
|
+
await screen.findByLabelText('Preview axis')
|
|
395
|
+
|
|
396
|
+
await act(async () => {
|
|
397
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' }))
|
|
398
|
+
})
|
|
399
|
+
const next = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0]
|
|
400
|
+
expect(next.markers).toHaveLength(1)
|
|
401
|
+
expect(next.markers[0]).toMatchObject({ t: 0, label: '1' }) // playhead starts at 0
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('does not fire while typing in a text surface', async () => {
|
|
405
|
+
const adapter = makeFakeAdapter()
|
|
406
|
+
const onProjectChange = vi.fn()
|
|
407
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
|
|
408
|
+
await screen.findByLabelText('Preview axis')
|
|
409
|
+
onProjectChange.mockClear() // drop the mount-time "notify host" call — see the Space test above
|
|
410
|
+
|
|
411
|
+
const input = document.createElement('input')
|
|
412
|
+
document.body.appendChild(input)
|
|
413
|
+
await act(async () => {
|
|
414
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'm', bubbles: true }))
|
|
415
|
+
})
|
|
416
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
417
|
+
input.remove()
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('Cmd+M is left alone — the shortcut is the bare letter', async () => {
|
|
421
|
+
// Cmd+M minimises the window on macOS; swallowing it would be hostile.
|
|
422
|
+
const adapter = makeFakeAdapter()
|
|
423
|
+
const onProjectChange = vi.fn()
|
|
424
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
|
|
425
|
+
await screen.findByLabelText('Preview axis')
|
|
426
|
+
onProjectChange.mockClear() // drop the mount-time "notify host" call — see the Space test above
|
|
427
|
+
|
|
428
|
+
await act(async () => {
|
|
429
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'm', metaKey: true }))
|
|
430
|
+
})
|
|
431
|
+
expect(onProjectChange).not.toHaveBeenCalled()
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('holding M does not spray markers', async () => {
|
|
435
|
+
// Key repeat fires keydown over and over at the same playhead; the
|
|
436
|
+
// half-frame guard in addMarker must make every repeat after the first a
|
|
437
|
+
// no-op that never reaches onProjectChange.
|
|
438
|
+
const adapter = makeFakeAdapter()
|
|
439
|
+
const onProjectChange = vi.fn()
|
|
440
|
+
render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
|
|
441
|
+
await screen.findByLabelText('Preview axis')
|
|
442
|
+
|
|
443
|
+
for (let i = 0; i < 5; i++) {
|
|
444
|
+
await act(async () => {
|
|
445
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' }))
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0]
|
|
449
|
+
expect(last.markers).toHaveLength(1)
|
|
450
|
+
})
|
|
451
|
+
})
|
|
452
|
+
|
|
389
453
|
// ── T2 — copy / paste / duplicate / paste-attributes ────────────────────
|
|
390
454
|
//
|
|
391
455
|
// Persistence assertions read `adapter.saveProject`'s SECOND argument (the
|
|
@@ -12,6 +12,8 @@ import type {
|
|
|
12
12
|
import type { Captions, VisualItem } from '../../schema'
|
|
13
13
|
import type { OverlayChanges } from '../preview/useDragOverlay'
|
|
14
14
|
import VideoEditor from '../VideoEditor'
|
|
15
|
+
import { CROSSFADE_COMMIT_DELAY_MS } from '../timeline/Timeline'
|
|
16
|
+
import { trackItems } from '../timeline/timeline-model'
|
|
15
17
|
import { dragCanvasItem, installCanvasHarness, selectCanvasItem } from '../timeline/__tests__/_canvasSelect'
|
|
16
18
|
|
|
17
19
|
// ── Fake adapter ──────────────────────────────────────────────────────────────
|
|
@@ -839,4 +841,171 @@ describe('VideoEditor — editor-package integration', () => {
|
|
|
839
841
|
|
|
840
842
|
expect(queryByText('Compare')).toBeNull()
|
|
841
843
|
})
|
|
844
|
+
|
|
845
|
+
// ── Derived OVERLAY crossfades: the wiring, not the function ─────────────
|
|
846
|
+
//
|
|
847
|
+
// `computeVisualCrossfade` (timeline-model.ts, unit-tested in
|
|
848
|
+
// timeline/__tests__/timeline-model.test.ts) ships INERT without two call
|
|
849
|
+
// sites, exactly as `computeAutoCrossfade` needs two: the gesture commit
|
|
850
|
+
// (`commitTimelineEdit` here in VideoEditor) and Timeline.tsx's debounced
|
|
851
|
+
// catch-all for overlay timing that changes without a gesture. One test each.
|
|
852
|
+
//
|
|
853
|
+
// Both assert on the ADAPTER's saveProject payload rather than on local
|
|
854
|
+
// `onProjectChange` state, for the reason
|
|
855
|
+
// VideoEditor.rippleDeleteCaptions.test.tsx records in full: only a real
|
|
856
|
+
// commit reaches `saveProject`, so an assertion on client state can pass on
|
|
857
|
+
// a transient preview that was never persisted.
|
|
858
|
+
|
|
859
|
+
/** The `opacity` keyframe track on `itemId` in a saved project, wherever the
|
|
860
|
+
* item lives. `undefined` when the item carries no opacity curve at all. */
|
|
861
|
+
function opacityTrackOf(project: Project, itemId: string) {
|
|
862
|
+
for (const items of trackItems(project)) {
|
|
863
|
+
const item = items.find((i) => i.id === itemId)
|
|
864
|
+
if (item) return item.keyframes?.find((k) => k.prop === 'opacity')
|
|
865
|
+
}
|
|
866
|
+
return undefined
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** The item itself, for the span assertions. */
|
|
870
|
+
function itemOf(project: Project, itemId: string): VisualItem | undefined {
|
|
871
|
+
for (const items of trackItems(project)) {
|
|
872
|
+
const item = items.find((i) => i.id === itemId)
|
|
873
|
+
if (item) return item
|
|
874
|
+
}
|
|
875
|
+
return undefined
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
it('a trim that creates an overlay overlap commits the fade in ONE undo step', async () => {
|
|
879
|
+
onTestFinished(installCanvasHarness())
|
|
880
|
+
// Fake timers, never advanced: Timeline.tsx's debounced catch-all cannot
|
|
881
|
+
// fire, so a fade in the saved project can ONLY have come from the gesture
|
|
882
|
+
// commit. Without this the two call sites are indistinguishable.
|
|
883
|
+
vi.useFakeTimers()
|
|
884
|
+
onTestFinished(() => { vi.useRealTimers() })
|
|
885
|
+
|
|
886
|
+
const adapter = makeFakeAdapter()
|
|
887
|
+
// The overlays start APART — the gesture is what creates the overlap.
|
|
888
|
+
const initial = makeVideoProject({
|
|
889
|
+
tracks: [
|
|
890
|
+
{
|
|
891
|
+
id: 'trk-0',
|
|
892
|
+
items: [
|
|
893
|
+
{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 40 },
|
|
894
|
+
],
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
id: 'trk-1',
|
|
898
|
+
items: [
|
|
899
|
+
{ id: 'ov-a', type: 'overlay', src: 'A.jsx', start: 0, end: 4 },
|
|
900
|
+
{ id: 'ov-b', type: 'overlay', src: 'B.jsx', start: 5, end: 9 },
|
|
901
|
+
],
|
|
902
|
+
},
|
|
903
|
+
],
|
|
904
|
+
})
|
|
905
|
+
const { container } = render(
|
|
906
|
+
<VideoEditor project={initial} adapter={adapter} slots={{ exportActions: <div /> }} />,
|
|
907
|
+
)
|
|
908
|
+
await act(async () => { await Promise.resolve() })
|
|
909
|
+
|
|
910
|
+
// Drag ov-a's OUT edge from t=4 to t=6, so it runs 1s into ov-b. A trim on
|
|
911
|
+
// a non-zero track is allowed past a neighbour's near boundary (a partial
|
|
912
|
+
// overlap is a transition) but not past its far one.
|
|
913
|
+
dragCanvasItem(container, initial, { id: 'ov-a' }, { fromTime: 4, toTime: 6 })
|
|
914
|
+
await act(async () => { await Promise.resolve() })
|
|
915
|
+
|
|
916
|
+
expect(adapter.saveCalls.length).toBe(1)
|
|
917
|
+
const trimmed = adapter.saveCalls[0].project
|
|
918
|
+
expect(itemOf(trimmed, 'ov-a')!.end).toBeCloseTo(6, 5)
|
|
919
|
+
|
|
920
|
+
// ov-a fades 1 -> 0 across the overlap, in ITS OWN item-relative seconds.
|
|
921
|
+
const fadeOut = opacityTrackOf(trimmed, 'ov-a')!
|
|
922
|
+
expect(fadeOut.origin).toBe('crossfade')
|
|
923
|
+
expect(fadeOut.points.length).toBe(2)
|
|
924
|
+
expect(fadeOut.points[0]).toEqual({ t: 5, value: 1 })
|
|
925
|
+
expect(fadeOut.points[1].value).toBe(0)
|
|
926
|
+
expect(fadeOut.points[1].t).toBeCloseTo(6, 5)
|
|
927
|
+
|
|
928
|
+
// ov-b fades 0 -> 1 across the same span, measured from ITS start (t=5).
|
|
929
|
+
const fadeIn = opacityTrackOf(trimmed, 'ov-b')!
|
|
930
|
+
expect(fadeIn.origin).toBe('crossfade')
|
|
931
|
+
expect(fadeIn.points[0]).toEqual({ t: 0, value: 0 })
|
|
932
|
+
expect(fadeIn.points[1].value).toBe(1)
|
|
933
|
+
expect(fadeIn.points[1].t).toBeCloseTo(1, 5)
|
|
934
|
+
|
|
935
|
+
// ONE undo takes back the trim AND its derived fade together. The audio
|
|
936
|
+
// version of this bug recorded dozens of entries for a single drag; a
|
|
937
|
+
// second undo being needed here, or either curve surviving the first, is
|
|
938
|
+
// the regression.
|
|
939
|
+
await act(async () => {
|
|
940
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
941
|
+
})
|
|
942
|
+
expect(adapter.saveCalls.length).toBe(2)
|
|
943
|
+
const undone = adapter.saveCalls[1].project
|
|
944
|
+
expect(itemOf(undone, 'ov-a')!.end).toBe(4)
|
|
945
|
+
expect(opacityTrackOf(undone, 'ov-a')).toBeUndefined()
|
|
946
|
+
expect(opacityTrackOf(undone, 'ov-b')).toBeUndefined()
|
|
947
|
+
})
|
|
948
|
+
|
|
949
|
+
it('a ripple-delete that creates an overlay overlap commits a fade without a gesture', async () => {
|
|
950
|
+
onTestFinished(installCanvasHarness())
|
|
951
|
+
vi.useFakeTimers()
|
|
952
|
+
onTestFinished(() => { vi.useRealTimers() })
|
|
953
|
+
|
|
954
|
+
const adapter = makeFakeAdapter()
|
|
955
|
+
// Shift+Delete on clip-A (0-2s) shifts everything starting at or after 2s
|
|
956
|
+
// left by 2s — across EVERY track. ov-b travels 4->2 while ov-a stays at
|
|
957
|
+
// 0-4, so the two overlap by 2s without any pointer ever touching them.
|
|
958
|
+
const initial = makeVideoProject({
|
|
959
|
+
tracks: [
|
|
960
|
+
{
|
|
961
|
+
id: 'trk-0',
|
|
962
|
+
items: [
|
|
963
|
+
{ id: 'clip-A', type: 'video', src: 'a.mp4', start: 0, end: 2, inPoint: 0, outPoint: 2 },
|
|
964
|
+
{ id: 'clip-B', type: 'video', src: 'a.mp4', start: 2, end: 6, inPoint: 2, outPoint: 6 },
|
|
965
|
+
],
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
id: 'trk-1',
|
|
969
|
+
items: [
|
|
970
|
+
{ id: 'ov-a', type: 'overlay', src: 'A.jsx', start: 0, end: 4 },
|
|
971
|
+
{ id: 'ov-b', type: 'overlay', src: 'B.jsx', start: 4, end: 8 },
|
|
972
|
+
],
|
|
973
|
+
},
|
|
974
|
+
],
|
|
975
|
+
})
|
|
976
|
+
const { container } = render(
|
|
977
|
+
<VideoEditor project={initial} adapter={adapter} slots={{ exportActions: <div /> }} />,
|
|
978
|
+
)
|
|
979
|
+
await act(async () => { await Promise.resolve() })
|
|
980
|
+
|
|
981
|
+
selectCanvasItem(container, initial, { id: 'clip-A' })
|
|
982
|
+
await act(async () => {
|
|
983
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', shiftKey: true }))
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
// `handleRippleDelete` goes straight to `sync.mutate` — it never passes
|
|
987
|
+
// through `commitTimelineEdit`, so its own commit carries no fade. That is
|
|
988
|
+
// precisely the hole the debounced pass fills.
|
|
989
|
+
expect(adapter.saveCalls.length).toBe(1)
|
|
990
|
+
const rippled = adapter.saveCalls[0].project
|
|
991
|
+
expect(itemOf(rippled, 'ov-b')!.start).toBe(2)
|
|
992
|
+
expect(opacityTrackOf(rippled, 'ov-a')).toBeUndefined()
|
|
993
|
+
expect(opacityTrackOf(rippled, 'ov-b')).toBeUndefined()
|
|
994
|
+
|
|
995
|
+
await act(async () => { vi.advanceTimersByTime(CROSSFADE_COMMIT_DELAY_MS) })
|
|
996
|
+
|
|
997
|
+
expect(adapter.saveCalls.length).toBe(2)
|
|
998
|
+
const faded = adapter.saveCalls[1].project
|
|
999
|
+
expect(opacityTrackOf(faded, 'ov-a')!.points).toEqual([
|
|
1000
|
+
{ t: 2, value: 1 }, { t: 4, value: 0 },
|
|
1001
|
+
])
|
|
1002
|
+
expect(opacityTrackOf(faded, 'ov-b')!.points).toEqual([
|
|
1003
|
+
{ t: 0, value: 0 }, { t: 2, value: 1 },
|
|
1004
|
+
])
|
|
1005
|
+
|
|
1006
|
+
// Idempotent: the pass re-runs off its own commit and must find nothing,
|
|
1007
|
+
// or it would commit forever.
|
|
1008
|
+
await act(async () => { vi.advanceTimersByTime(CROSSFADE_COMMIT_DELAY_MS * 4) })
|
|
1009
|
+
expect(adapter.saveCalls.length).toBe(2)
|
|
1010
|
+
})
|
|
842
1011
|
})
|
|
@@ -310,6 +310,90 @@ describe('splitAtTime', () => {
|
|
|
310
310
|
expect(lw.duration + rw.duration).toBeCloseTo(10)
|
|
311
311
|
})
|
|
312
312
|
|
|
313
|
+
// A fade belongs to an EDGE. Both halves used to inherit BOTH fades from the
|
|
314
|
+
// spread, so every cut manufactured a fade-out into the razor and a fade-in
|
|
315
|
+
// out of it — on a voiceover with the usual trailing fade, each cut left a
|
|
316
|
+
// fragment ramping to silence at the cut that the operator had to zero by
|
|
317
|
+
// hand.
|
|
318
|
+
it('does not manufacture a fade-out at the cut on the left half', () => {
|
|
319
|
+
const p = makeProject({
|
|
320
|
+
tracks: vtracks([]),
|
|
321
|
+
audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 100, fadeOut: 0.4 }] },
|
|
322
|
+
})
|
|
323
|
+
const [left, right] = splitAtTime(p, 40, null).audio!.tracks
|
|
324
|
+
expect(left.fadeOut).toBeUndefined() // the cut edge — no fade was asked for
|
|
325
|
+
expect(right.fadeOut).toBe(0.4) // still the original END, keeps its fade
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('does not manufacture a fade-in at the cut on the right half', () => {
|
|
329
|
+
const p = makeProject({
|
|
330
|
+
tracks: vtracks([]),
|
|
331
|
+
audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 100, fadeIn: 0.3 }] },
|
|
332
|
+
})
|
|
333
|
+
const [left, right] = splitAtTime(p, 40, null).audio!.tracks
|
|
334
|
+
expect(left.fadeIn).toBe(0.3) // still the original START, keeps its fade
|
|
335
|
+
expect(right.fadeIn).toBeUndefined() // the cut edge
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('drops the curve along with the fade it described', () => {
|
|
339
|
+
// A `fadeOutCurve` on a half with no fade-out is dead data, and the next
|
|
340
|
+
// hand-drawn fade there should start from the default shape.
|
|
341
|
+
const p = makeProject({
|
|
342
|
+
tracks: vtracks([]),
|
|
343
|
+
audio: { tracks: [{
|
|
344
|
+
id: 'au', src: 'a.mp3', start: 0, end: 100,
|
|
345
|
+
fadeIn: 0.3, fadeInCurve: 'linear', fadeOut: 0.4, fadeOutCurve: 'log',
|
|
346
|
+
}] },
|
|
347
|
+
})
|
|
348
|
+
const [left, right] = splitAtTime(p, 40, null).audio!.tracks
|
|
349
|
+
expect(left.fadeInCurve).toBe('linear')
|
|
350
|
+
expect(left.fadeOutCurve).toBeUndefined()
|
|
351
|
+
expect(right.fadeOutCurve).toBe('log')
|
|
352
|
+
expect(right.fadeInCurve).toBeUndefined()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('splitting twice never reintroduces a fade at either cut', () => {
|
|
356
|
+
// The shape that surfaced this: successive razors down one voiceover. The
|
|
357
|
+
// middle fragment owns neither original edge, so it must carry no fade at
|
|
358
|
+
// all — before the fix it carried both.
|
|
359
|
+
const p = makeProject({
|
|
360
|
+
tracks: vtracks([]),
|
|
361
|
+
audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 100, fadeIn: 0.3, fadeOut: 0.4 }] },
|
|
362
|
+
})
|
|
363
|
+
const once = splitAtTime(p, 40, null)
|
|
364
|
+
const twice = splitAtTime(once, 70, once.audio!.tracks[1].id)
|
|
365
|
+
const [a, b, c] = twice.audio!.tracks
|
|
366
|
+
expect([a.fadeIn, a.fadeOut]).toEqual([0.3, undefined])
|
|
367
|
+
expect([b.fadeIn, b.fadeOut]).toEqual([undefined, undefined])
|
|
368
|
+
expect([c.fadeIn, c.fadeOut]).toEqual([undefined, 0.4])
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('clamps a kept fade that no longer fits its own fragment', () => {
|
|
372
|
+
// A 3s fade-out is fine on the whole 20s track; the fragment it lands on is
|
|
373
|
+
// 1s long. Nothing downstream re-clamps — the renderer just anchors
|
|
374
|
+
// `st = end - fadeOut`, which would start the fade before this fragment's
|
|
375
|
+
// audio begins.
|
|
376
|
+
const p = makeProject({
|
|
377
|
+
tracks: vtracks([]),
|
|
378
|
+
audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 20, fadeIn: 3, fadeOut: 3 }] },
|
|
379
|
+
})
|
|
380
|
+
const [left, right] = splitAtTime(p, 19, null).audio!.tracks
|
|
381
|
+
expect(right.fadeOut).toBe(1) // clamped to the 1s fragment
|
|
382
|
+
expect(left.fadeIn).toBe(3) // still fits the 19s fragment, untouched
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('leaves a track with no fades alone', () => {
|
|
386
|
+
const p = makeProject({
|
|
387
|
+
tracks: vtracks([]),
|
|
388
|
+
audio: { tracks: [{ id: 'au', src: 'a.mp3', start: 0, end: 10 }] },
|
|
389
|
+
})
|
|
390
|
+
const [left, right] = splitAtTime(p, 4, null).audio!.tracks
|
|
391
|
+
for (const half of [left, right]) {
|
|
392
|
+
expect(half).not.toHaveProperty('fadeIn')
|
|
393
|
+
expect(half).not.toHaveProperty('fadeOut')
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
|
|
313
397
|
it('carries an existing trim window across a split (both halves stay within source)', () => {
|
|
314
398
|
// A pre-trimmed track: source window [2,12] mapped onto timeline [0,10].
|
|
315
399
|
const p = makeProject({
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The export dialog's duration — what the cover-frame picker samples across.
|
|
3
|
+
*
|
|
4
|
+
* This used to be derived from the track-0 VIDEO items alone, which is a safe
|
|
5
|
+
* reading only when track 0 holds footage. An overlay-only project (the
|
|
6
|
+
* animations workflow emits exactly that: one track of nothing but overlays)
|
|
7
|
+
* therefore reported 0, and the picker had no frames to offer — on a project
|
|
8
|
+
* that renders perfectly well and is entirely made of pickable frames.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import { exportDurationSec } from '../VideoEditor'
|
|
12
|
+
import type { EditorProject, VisualItem } from '../../schema'
|
|
13
|
+
|
|
14
|
+
const video = (id: string, start: number, end: number): VisualItem =>
|
|
15
|
+
({ id, type: 'video', src: `/${id}.mp4`, start, end }) as VisualItem
|
|
16
|
+
const overlay = (id: string, start: number, end: number): VisualItem =>
|
|
17
|
+
({ id, type: 'overlay', src: `/${id}.jsx`, start, end }) as VisualItem
|
|
18
|
+
|
|
19
|
+
function proj(tracks: { items: VisualItem[]; enabled?: boolean }[]): EditorProject {
|
|
20
|
+
return {
|
|
21
|
+
id: 'p', status: 'draft', settings: { resolution: [1920, 1080], fps: 30 },
|
|
22
|
+
tracks: tracks.map((t, i) => ({ id: `trk-${i}`, ...t })),
|
|
23
|
+
} as EditorProject
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('exportDurationSec', () => {
|
|
27
|
+
it('spans an overlay-only project, which used to report 0', () => {
|
|
28
|
+
// The Daubert-demo shape: one track, overlays only, no video anywhere.
|
|
29
|
+
expect(exportDurationSec(proj([{ items: [overlay('o1', 0, 5), overlay('o2', 5, 12)] }]))).toBe(12)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('reaches an overlay that outlives the last video clip', () => {
|
|
33
|
+
// An outro card after the final clip is ordinary, and its frames are just
|
|
34
|
+
// as pickable as the footage before it — the render encodes to 14, so the
|
|
35
|
+
// picker has to offer the whole 14.
|
|
36
|
+
expect(exportDurationSec(proj([
|
|
37
|
+
{ items: [video('v', 0, 8)] },
|
|
38
|
+
{ items: [overlay('outro', 8, 14)] },
|
|
39
|
+
]))).toBe(14)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('still matches the video end when video is the longest thing', () => {
|
|
43
|
+
// The common case must not move.
|
|
44
|
+
expect(exportDurationSec(proj([
|
|
45
|
+
{ items: [video('a', 0, 4), video('b', 4, 9)] },
|
|
46
|
+
{ items: [overlay('o', 0, 3)] },
|
|
47
|
+
]))).toBe(9)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('ignores a disabled track, which the render also skips', () => {
|
|
51
|
+
expect(exportDurationSec(proj([
|
|
52
|
+
{ items: [video('v', 0, 6)] },
|
|
53
|
+
{ items: [overlay('o', 0, 30)], enabled: false },
|
|
54
|
+
]))).toBe(6)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('is 0 for a project with nothing on any track', () => {
|
|
58
|
+
expect(exportDurationSec(proj([{ items: [] }]))).toBe(0)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { markerDropTime } from '../VideoEditor'
|
|
3
|
+
|
|
4
|
+
describe('markerDropTime', () => {
|
|
5
|
+
it('uses the playhead when the preview axis is off', () => {
|
|
6
|
+
expect(markerDropTime(false, 12, 4)).toBe(4)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('uses the hovered time when the axis is on', () => {
|
|
10
|
+
// The yellow line is what the operator is looking at, so that is where the
|
|
11
|
+
// marker goes.
|
|
12
|
+
expect(markerDropTime(true, 12, 4)).toBe(12)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('falls back to the playhead when the axis is on but nothing is hovered', () => {
|
|
16
|
+
// Axis on with the pointer off the timeline: hoverScrub reads null.
|
|
17
|
+
expect(markerDropTime(true, null, 4)).toBe(4)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('treats a hovered 0 as a real position, not as absent', () => {
|
|
21
|
+
// The bug a `||` instead of `??` would introduce: the very start of the
|
|
22
|
+
// timeline is a perfectly ordinary place to mark.
|
|
23
|
+
expect(markerDropTime(true, 0, 4)).toBe(0)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* highlight-box.jsx, outline.jsx
|
|
23
23
|
* (all under montaj_assets/render/templates/captions/).
|
|
24
24
|
*/
|
|
25
|
-
import type { Captions } from '../schema'
|
|
25
|
+
import type { Captions, CaptionTextTransform } from '../schema'
|
|
26
26
|
|
|
27
27
|
type Style = Captions['style']
|
|
28
28
|
|
|
@@ -95,6 +95,6 @@ export const CAPTION_STYLE_TEXT_ALIGN: Record<Style, string> = {
|
|
|
95
95
|
* `renderSegment` call site); the no-timestamps fallback branch renders the
|
|
96
96
|
* segment text as-is, never uppercased. Every other style has no default in
|
|
97
97
|
* any branch. */
|
|
98
|
-
export const CAPTION_STYLE_TEXT_TRANSFORM: Partial<Record<Style,
|
|
98
|
+
export const CAPTION_STYLE_TEXT_TRANSFORM: Partial<Record<Style, CaptionTextTransform>> = {
|
|
99
99
|
outline: 'uppercase',
|
|
100
100
|
}
|