@bycrux/editor 1.1.0 → 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__/scheduler.test.ts +21 -1
- package/src/engine/scheduler.ts +14 -4
- package/src/index.ts +4 -0
- package/src/schema.ts +21 -0
- package/src/video/VideoEditor.tsx +79 -3
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -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/cuts.ts +30 -2
- 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 +6 -0
- 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/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 +100 -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 +83 -4
- package/src/video/timeline/markers.ts +109 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bycrux/editor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"lint": "eslint src"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@bycrux/timeline-core": "^0.2.
|
|
20
|
+
"@bycrux/timeline-core": "^0.2.1",
|
|
21
21
|
"class-variance-authority": "^0.7",
|
|
22
22
|
"clsx": "^2",
|
|
23
23
|
"lucide-react": "^0.400",
|
|
@@ -309,6 +309,7 @@ export const VIDEO_CONTROLS: ControlSection[] = [
|
|
|
309
309
|
heading: 'Keyboard',
|
|
310
310
|
entries: [
|
|
311
311
|
{ keys: ['S'], label: 'Split at the playhead' },
|
|
312
|
+
{ keys: ['M'], label: 'Drop a marker at the playhead (or the preview axis)' },
|
|
312
313
|
{ keys: ['⌘/Ctrl', 'A'], label: 'Toggle the preview axis' },
|
|
313
314
|
{ keys: ['⇧', 'Delete'], label: 'Ripple-delete the selection' },
|
|
314
315
|
{ keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
|
|
@@ -483,7 +483,27 @@ describe('transportEndFor', () => {
|
|
|
483
483
|
expect(transportEndFor(p)).toBe(8)
|
|
484
484
|
})
|
|
485
485
|
|
|
486
|
-
it('
|
|
486
|
+
it('counts track 0 in a canvas project — an overlay-only single track is not a zero-length transport', () => {
|
|
487
|
+
// The shape an animations-workflow project actually has: ONE track, holding
|
|
488
|
+
// nothing but overlays, and no captions. Reading the ceiling off
|
|
489
|
+
// `tracks.slice(1)` made this 0, so play/space started the transport and
|
|
490
|
+
// stopped it in the same tick — the picture never moved.
|
|
491
|
+
const p = project([overlay('o1', 0, 5), overlay('o2', 5, 12)])
|
|
492
|
+
expect(p.tracks).toHaveLength(1)
|
|
493
|
+
expect(transportEndFor(p)).toBe(12)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('counts track 0 images in a canvas project, even past the overlay tracks', () => {
|
|
497
|
+
// Same defect, other content kind: a background image outlasting every
|
|
498
|
+
// overlay used to be invisible to the ceiling.
|
|
499
|
+
const p = project(
|
|
500
|
+
[{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 9 }],
|
|
501
|
+
[overlay('o', 0, 4)],
|
|
502
|
+
)
|
|
503
|
+
expect(transportEndFor(p)).toBe(9)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('uses max(visualEnd, captionEnd) for a canvas project — audio EXCLUDED', () => {
|
|
487
507
|
// The legacy canvas rAF's ceiling is `canvasMaxEndRef`, which really does
|
|
488
508
|
// leave audio out. Faithful, not unified.
|
|
489
509
|
const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 3 }], [
|
package/src/engine/scheduler.ts
CHANGED
|
@@ -517,9 +517,9 @@ function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): Visua
|
|
|
517
517
|
* Two formulas, because the legacy hook has two and they legitimately differ:
|
|
518
518
|
*
|
|
519
519
|
* - **Canvas projects** (no track-0 video) run the `isCanvasProject` rAF,
|
|
520
|
-
* whose ceiling is `canvasMaxEndRef` = `max(
|
|
520
|
+
* whose ceiling is `canvasMaxEndRef` = `max(visualEnd, captionEnd)`. Note
|
|
521
521
|
* what is NOT in it: audio. A canvas project whose music outlasts its
|
|
522
|
-
*
|
|
522
|
+
* visuals stops at the visuals today.
|
|
523
523
|
* - **Video projects** use `projectEnd` = `max(videoEnd, overlayEnd,
|
|
524
524
|
* audioEnd)` — captions excluded, audio included — which timeline-core
|
|
525
525
|
* already ports verbatim (including its two documented faithfulness warts:
|
|
@@ -527,18 +527,28 @@ function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): Visua
|
|
|
527
527
|
*
|
|
528
528
|
* Unifying them would be a behavior change in one mode or the other, so both
|
|
529
529
|
* are kept and the divergence is named here rather than smoothed over.
|
|
530
|
+
*
|
|
531
|
+
* `visualEnd` spans EVERY enabled track, track 0 included — it is deliberately
|
|
532
|
+
* not the video path's `overlayEnd` (`tracks.slice(1)`). In canvas mode track 0
|
|
533
|
+
* is a content track like any other: it carries the background images, and an
|
|
534
|
+
* agent-authored project can put its overlays there too (an animations-workflow
|
|
535
|
+
* project is frequently ONE track holding nothing but overlays). Skipping it
|
|
536
|
+
* made the ceiling collapse to 0 for exactly those projects, so play/space
|
|
537
|
+
* started the transport and stopped it in the same tick. `OverlayItemsLayer`
|
|
538
|
+
* has always drawn track 0 in canvas mode (`isCanvasProject ? enabledTrackItems
|
|
539
|
+
* : overlayTracks`); this is the transport agreeing with what is on screen.
|
|
530
540
|
*/
|
|
531
541
|
export function transportEndFor(project: Project): number {
|
|
532
542
|
const clips = track0VideoItems(project)
|
|
533
543
|
if (clips.length > 0) return timelineProjectEnd(withEnabledItemTracks(project))
|
|
534
|
-
const
|
|
544
|
+
const visualEnd = enabledTrackItems(project)
|
|
535
545
|
.flat()
|
|
536
546
|
.reduce((m, i) => Math.max(m, i?.end ?? 0), 0)
|
|
537
547
|
const captionEnd = (project.captions?.segments ?? []).reduce(
|
|
538
548
|
(m: number, s) => Math.max(m, s.end ?? 0),
|
|
539
549
|
0,
|
|
540
550
|
)
|
|
541
|
-
return Math.max(
|
|
551
|
+
return Math.max(visualEnd, captionEnd)
|
|
542
552
|
}
|
|
543
553
|
|
|
544
554
|
/**
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type {
|
|
|
19
19
|
CarouselElement,
|
|
20
20
|
Slide,
|
|
21
21
|
EditorProject,
|
|
22
|
+
Marker,
|
|
22
23
|
} from './schema'
|
|
23
24
|
|
|
24
25
|
// ── Contracts (adapter, theme, render, media, component props) ────────────────
|
|
@@ -103,6 +104,9 @@ export {
|
|
|
103
104
|
// "land where you dropped it, without stomping existing footage".
|
|
104
105
|
export { placeDroppedClip, resolveDropTrackIndex } from './video/timeline/placement'
|
|
105
106
|
export type { DroppedClipPlacement, PlacedClipResult } from './video/timeline/placement'
|
|
107
|
+
// Marker model — pure mutations over `project.markers` (see markers.ts's file
|
|
108
|
+
// header for the "same reference when unchanged" contract they all share).
|
|
109
|
+
export { addMarker, moveMarker, renameMarker, removeMarkers, nextMarkerLabel } from './video/timeline/markers'
|
|
106
110
|
|
|
107
111
|
// ── Image tone (HDR image color mapping) ─────────────────────────────────────
|
|
108
112
|
// The picker component is exported so hosts using `onProvideImageTone` can
|
package/src/schema.ts
CHANGED
|
@@ -290,6 +290,23 @@ export interface Asset {
|
|
|
290
290
|
name?: string
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/**
|
|
294
|
+
* An operator's flag on the timeline — a moment worth coming back to.
|
|
295
|
+
*
|
|
296
|
+
* Markers are an EDITING and COMMUNICATION aid: they are drawn in the editor's
|
|
297
|
+
* marker strip and handed to the agent through the context endpoint, and the
|
|
298
|
+
* renderer ignores them completely. Nothing about a marker reaches the export.
|
|
299
|
+
*
|
|
300
|
+
* `label` is always present. A new marker gets an auto-number so dropping one
|
|
301
|
+
* never interrupts the edit to type; renaming it is a separate, deliberate act.
|
|
302
|
+
*/
|
|
303
|
+
export interface Marker {
|
|
304
|
+
id: string
|
|
305
|
+
/** Timeline position in seconds. Never negative. */
|
|
306
|
+
t: number
|
|
307
|
+
label: string
|
|
308
|
+
}
|
|
309
|
+
|
|
293
310
|
// ── Carousel types ─────────────────────────────────────────────────────────
|
|
294
311
|
export interface ImageElement {
|
|
295
312
|
id: string
|
|
@@ -385,6 +402,10 @@ export interface EditorProject {
|
|
|
385
402
|
captions?: Captions
|
|
386
403
|
audio?: { tracks: AudioTrack[] }
|
|
387
404
|
assets?: Asset[]
|
|
405
|
+
/** Operator markers, kept sorted by `t`. Absent — not `[]` — when the
|
|
406
|
+
* project has none, so a marker-less project is byte-identical to one from
|
|
407
|
+
* before the feature existed (the same discipline `captions` follows). */
|
|
408
|
+
markers?: Marker[]
|
|
388
409
|
carousel?: { aspect: string }
|
|
389
410
|
profile?: string
|
|
390
411
|
derivedFrom?: string // ID of the source project this was derived from (e.g. clips workflow)
|
|
@@ -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
|
*
|
|
@@ -1178,7 +1230,10 @@ function ReviewSurface<P extends Project>({
|
|
|
1178
1230
|
keeps: renderKeeps,
|
|
1179
1231
|
imageTone: { value: currentImageTone ?? 'vivid', set: handleImageToneChange },
|
|
1180
1232
|
name: project.name?.trim() || undefined,
|
|
1181
|
-
|
|
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),
|
|
1182
1237
|
aspectRatio: (() => {
|
|
1183
1238
|
const r = project.settings?.resolution
|
|
1184
1239
|
return r && r[0] > 0 && r[1] > 0 ? r[0] / r[1] : undefined
|
|
@@ -1194,7 +1249,13 @@ function ReviewSurface<P extends Project>({
|
|
|
1194
1249
|
set: handleExportFpsChange,
|
|
1195
1250
|
},
|
|
1196
1251
|
}), [
|
|
1197
|
-
|
|
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,
|
|
1198
1259
|
project.name, project.settings?.resolution, project.settings?.fps,
|
|
1199
1260
|
availableRes, availableFpsList, handleExportResolutionChange, handleExportFpsChange,
|
|
1200
1261
|
])
|
|
@@ -1621,6 +1682,13 @@ function ReviewSurface<P extends Project>({
|
|
|
1621
1682
|
void sync.mutate(() => updated as P)
|
|
1622
1683
|
}
|
|
1623
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
|
+
|
|
1624
1692
|
function handleRippleToggle() {
|
|
1625
1693
|
const next = !rippleMode
|
|
1626
1694
|
setRippleMode(next)
|
|
@@ -1757,6 +1825,13 @@ function ReviewSurface<P extends Project>({
|
|
|
1757
1825
|
// is selected (a no-op if the axis isn't over the item).
|
|
1758
1826
|
action: () => handleSplit(previewAxis ? (hoverScrub.get() ?? undefined) : undefined),
|
|
1759
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
|
+
},
|
|
1760
1835
|
{
|
|
1761
1836
|
id: 'video.undo',
|
|
1762
1837
|
description: 'Undo',
|
|
@@ -1897,6 +1972,7 @@ function ReviewSurface<P extends Project>({
|
|
|
1897
1972
|
const paletteCommands: PaletteCommand[] = [
|
|
1898
1973
|
{ id: 'play-pause', label: 'Play/Pause', keyHint: ['Space'], run: () => transportRef.current?.togglePlay() },
|
|
1899
1974
|
{ id: 'split', label: 'Split at playhead', keyHint: ['S'], run: () => handleSplit() },
|
|
1975
|
+
{ id: 'add-marker', label: 'Drop a marker', keyHint: ['M'], run: () => handleAddMarker() },
|
|
1900
1976
|
]
|
|
1901
1977
|
if (primarySelectedId) {
|
|
1902
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
|
|
@@ -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
|
+
})
|
package/src/video/cuts.ts
CHANGED
|
@@ -481,19 +481,47 @@ function splitAudioTrack(track: AudioTrack, at: number): [AudioTrack, AudioTrack
|
|
|
481
481
|
const outPoint = track.outPoint ?? track.sourceDuration ?? (track.end - track.start)
|
|
482
482
|
const splitPoint = inPoint + (at - track.start)
|
|
483
483
|
|
|
484
|
+
// A FADE BELONGS TO AN EDGE, NOT TO THE CLIP. Spreading `...track` into both
|
|
485
|
+
// halves gave both fragments both fades, which is wrong on the two edges the
|
|
486
|
+
// split just created: the left half fades OUT into the cut and the right half
|
|
487
|
+
// fades IN out of it, neither of which anyone asked for. On a voiceover
|
|
488
|
+
// carrying the usual trailing fade-out that is very visible — every cut
|
|
489
|
+
// produced a fragment that ramps to silence at the razor, and the operator
|
|
490
|
+
// has to zero it by hand each time. Fades are added deliberately; a cut is
|
|
491
|
+
// not a request for one.
|
|
492
|
+
//
|
|
493
|
+
// So each half keeps only the fade whose EDGE it still owns:
|
|
494
|
+
// • the left half keeps the original START, so it keeps `fadeIn`;
|
|
495
|
+
// • the right half keeps the original END, so it keeps `fadeOut`;
|
|
496
|
+
// • the two new edges at the cut carry no fade at all.
|
|
497
|
+
// The paired curve goes with its fade — a `fadeOutCurve` describing a
|
|
498
|
+
// fade-out that no longer exists is dead data, and the operator's next
|
|
499
|
+
// hand-drawn fade should start from the default shape like any other new one.
|
|
500
|
+
//
|
|
501
|
+
// The kept fade is then clamped to its own fragment's span. A 3s fade-out on
|
|
502
|
+
// a 20s track is fine; split that track 1s from its end and the 3s fade no
|
|
503
|
+
// longer fits inside the 1s fragment it lands on. Nothing downstream
|
|
504
|
+
// re-clamps for us — the pointer-machine and the properties panel each clamp
|
|
505
|
+
// their OWN edits, and the renderer just anchors `st = end - fadeOut`, which
|
|
506
|
+
// for an oversized fade starts before the fragment's audio does.
|
|
507
|
+
const { fadeOut: _outAmt, fadeOutCurve: _outCurve, ...leftBase } = track
|
|
508
|
+
const { fadeIn: _inAmt, fadeInCurve: _inCurve, ...rightBase } = track
|
|
509
|
+
|
|
484
510
|
const left: AudioTrack = {
|
|
485
|
-
...
|
|
511
|
+
...leftBase,
|
|
486
512
|
end: at,
|
|
487
513
|
inPoint,
|
|
488
514
|
outPoint: splitPoint,
|
|
489
515
|
}
|
|
490
516
|
const right: AudioTrack = {
|
|
491
|
-
...
|
|
517
|
+
...rightBase,
|
|
492
518
|
id: uniqueId(track.id),
|
|
493
519
|
start: at,
|
|
494
520
|
inPoint: splitPoint,
|
|
495
521
|
outPoint,
|
|
496
522
|
}
|
|
523
|
+
if (left.fadeIn !== undefined) left.fadeIn = Math.min(left.fadeIn, at - track.start)
|
|
524
|
+
if (right.fadeOut !== undefined) right.fadeOut = Math.min(right.fadeOut, track.end - at)
|
|
497
525
|
return [left, right]
|
|
498
526
|
}
|
|
499
527
|
|