@bycrux/editor 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- package/src/engine/__tests__/scheduler.test.ts +56 -1
- package/src/engine/scheduler.ts +23 -5
- package/src/index.ts +36 -0
- package/src/lib/__tests__/font-faces.test.ts +244 -0
- package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
- package/src/lib/__tests__/google-fonts.test.ts +319 -2
- package/src/lib/font-families.ts +286 -0
- package/src/lib/google-fonts.ts +132 -10
- package/src/schema.ts +21 -0
- package/src/text/FontPicker.tsx +82 -11
- package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
- package/src/video/VersionPanel.tsx +1 -1
- package/src/video/VideoEditor.tsx +98 -4
- 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/PreviewPlayer.tsx +52 -2
- package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
- package/src/video/preview/useEnginePlayback.ts +58 -8
- package/src/video/preview/useVideoPlayback.ts +64 -17
- 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 +110 -12
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
- 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 +182 -8
- 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/src/video/timeline/timeline-model.ts +19 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { render, cleanup } from '@testing-library/react'
|
|
3
|
+
import { FontFamilyPicker, FONT_OPTIONS, setFontsBaseUrl } from '../FontPicker'
|
|
4
|
+
|
|
5
|
+
// FontFamilyPicker's own `ensureGoogleFontsLoaded` is private to FontPicker.tsx
|
|
6
|
+
// (unrelated to, and independent from, `lib/google-fonts.ts`'s loader of the
|
|
7
|
+
// same name — see that file's header comment). It has no exported entry point,
|
|
8
|
+
// so we drive it the way the app does: mount the component, whose first-mount
|
|
9
|
+
// useEffect calls it.
|
|
10
|
+
function mountPicker() {
|
|
11
|
+
return render(<FontFamilyPicker value="" onChange={() => {}} />)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function injectedHrefs(): string[] {
|
|
15
|
+
return Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
|
|
16
|
+
(l) => (l as HTMLLinkElement).href,
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Every Google family the picker previews, as the labels a vendored family
|
|
21
|
+
* list would spell. Derived from FONT_OPTIONS rather than hardcoded, so
|
|
22
|
+
* adding a family to the picker cannot leave this test asserting a stale set. */
|
|
23
|
+
const PICKER_FAMILIES = FONT_OPTIONS.filter((f) => f.isGoogleFont).map((f) => f.label)
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
27
|
+
vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
28
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
cleanup()
|
|
33
|
+
setFontsBaseUrl(undefined)
|
|
34
|
+
vi.restoreAllMocks()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('FontFamilyPicker google fonts base URL', () => {
|
|
38
|
+
// Must run before any other test in this file sets a base: the injection
|
|
39
|
+
// guard is keyed on the computed URL, and the default URL can only be
|
|
40
|
+
// observed as "not yet injected" once per module lifetime.
|
|
41
|
+
it('unset produces exactly the default per-family googleapis URL (byte-identical to pre-base behaviour)', () => {
|
|
42
|
+
mountPicker()
|
|
43
|
+
const hrefs = injectedHrefs()
|
|
44
|
+
expect(hrefs).toEqual([
|
|
45
|
+
'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Roboto:wght@400;700&family=Open+Sans:wght@400;700&family=Lato:wght@400;700&family=Montserrat:wght@400;700&family=Poppins:wght@400;700&family=Raleway:wght@400;700&family=Nunito:wght@400;700&family=Work+Sans:wght@400;700&family=DM+Sans:wght@400;700&family=Rubik:wght@400;700&family=Oswald:wght@400;700&family=Bebas+Neue:wght@400;700&family=Playfair+Display:wght@400;700&family=Merriweather:wght@400;700&family=Source+Serif+4:wght@400;700&family=JetBrains+Mono:wght@400;700&family=Baloo+2:wght@400;500;600;700;800&family=Fredoka:wght@300;400;500;600;700&family=Sniglet:wght@400;800&display=swap',
|
|
46
|
+
])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('a base whose family list covers the picker produces <base>/fonts.css ONLY — zero egress', () => {
|
|
50
|
+
setFontsBaseUrl('https://example.com/fonts/editor', PICKER_FAMILIES)
|
|
51
|
+
mountPicker()
|
|
52
|
+
expect(injectedHrefs()).toEqual(['https://example.com/fonts/editor/fonts.css'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('setting a base after a prior injection still injects — a stale guard must not suppress it', () => {
|
|
56
|
+
// Bases unique to this test so it doesn't depend on injection history left
|
|
57
|
+
// by the tests above (module state, not DOM state, outlives a test — the
|
|
58
|
+
// beforeEach above only clears document.head).
|
|
59
|
+
setFontsBaseUrl('https://example.com/fonts/one', PICKER_FAMILIES)
|
|
60
|
+
mountPicker()
|
|
61
|
+
expect(injectedHrefs()).toEqual(['https://example.com/fonts/one/fonts.css'])
|
|
62
|
+
|
|
63
|
+
setFontsBaseUrl('https://example.com/fonts/two', PICKER_FAMILIES)
|
|
64
|
+
mountPicker()
|
|
65
|
+
const hrefs = injectedHrefs()
|
|
66
|
+
expect(hrefs).toHaveLength(2)
|
|
67
|
+
expect(hrefs).toContain('https://example.com/fonts/two/fonts.css')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// The picker's families ARE the vendored set's reason for existing, so in a
|
|
71
|
+
// correctly configured app this never fires. It is guarded because the day a
|
|
72
|
+
// family is added to FONT_OPTIONS and not yet vendored, the alternative is a
|
|
73
|
+
// picker preview silently showing a system fallback — the exact rot a
|
|
74
|
+
// declared family list exists to catch.
|
|
75
|
+
it('a family the vendored list omits falls through to Google, alongside the vendored sheet', () => {
|
|
76
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
77
|
+
const partial = PICKER_FAMILIES.filter((f) => f !== 'Sniglet')
|
|
78
|
+
setFontsBaseUrl('https://example.com/fonts/partial', partial)
|
|
79
|
+
mountPicker()
|
|
80
|
+
|
|
81
|
+
const hrefs = injectedHrefs()
|
|
82
|
+
expect(hrefs).toHaveLength(2)
|
|
83
|
+
expect(hrefs[0]).toBe('https://example.com/fonts/partial/fonts.css')
|
|
84
|
+
expect(hrefs[1]).toBe('https://fonts.googleapis.com/css2?family=Sniglet:wght@400;800&display=swap')
|
|
85
|
+
expect(warn.mock.calls[0][0]).toContain('Sniglet')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('a base with no family list links nothing vendored and emits exactly the default googleapis URL', async () => {
|
|
89
|
+
// A fresh module instance, because the URL this produces IS the default
|
|
90
|
+
// URL — every picker family falls through — and the module-level dedupe
|
|
91
|
+
// would (correctly) suppress it after the first test above already
|
|
92
|
+
// injected it. Asserting against the live module would therefore pass
|
|
93
|
+
// vacuously, on an empty list.
|
|
94
|
+
vi.resetModules()
|
|
95
|
+
const fresh = await import('../FontPicker')
|
|
96
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
97
|
+
|
|
98
|
+
fresh.setFontsBaseUrl('https://example.com/fonts/nolist')
|
|
99
|
+
render(<fresh.FontFamilyPicker value="" onChange={() => {}} />)
|
|
100
|
+
|
|
101
|
+
const hrefs = injectedHrefs()
|
|
102
|
+
expect(hrefs).toHaveLength(1)
|
|
103
|
+
expect(hrefs[0]).toContain('fonts.googleapis.com')
|
|
104
|
+
expect(hrefs[0]).not.toContain('fonts.css')
|
|
105
|
+
// Every picker family is in it — nothing was withheld on the strength of
|
|
106
|
+
// a base we were given no family list for.
|
|
107
|
+
for (const label of PICKER_FAMILIES) expect(hrefs[0]).toContain(`family=${label.replace(/ /g, '+')}`)
|
|
108
|
+
expect(warn.mock.calls[0][0]).toContain('treating NOTHING as vendored')
|
|
109
|
+
|
|
110
|
+
fresh.setFontsBaseUrl(undefined)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -111,7 +111,7 @@ export default function VersionPanel({ versions, restoring, onRestore, onSaveVer
|
|
|
111
111
|
<button
|
|
112
112
|
onClick={handleSaveClick}
|
|
113
113
|
disabled={saving || !onSaveVersion}
|
|
114
|
-
className="shrink-0 flex items-center gap-1.5 h-8 px-3 rounded-md bg-[var(--editor-accent)] text-
|
|
114
|
+
className="shrink-0 flex items-center gap-1.5 h-8 px-3 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] text-xs font-medium hover:opacity-90 transition-opacity disabled:opacity-40 disabled:hover:opacity-40"
|
|
115
115
|
>
|
|
116
116
|
<Save size={13} />
|
|
117
117
|
{saving ? 'Saving…' : 'Save version'}
|
|
@@ -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 { audioEnd, 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
|
])
|
|
@@ -1246,7 +1307,25 @@ function ReviewSurface<P extends Project>({
|
|
|
1246
1307
|
}, [project.id, project.captions])
|
|
1247
1308
|
|
|
1248
1309
|
const clips = trackItems(project)[0] ?? []
|
|
1249
|
-
|
|
1310
|
+
// Gates the ENTIRE preview region — transport, click-to-play surface and the
|
|
1311
|
+
// multi-track audio elements all live inside it, so anything this misses is
|
|
1312
|
+
// not merely invisible, it is unplayable with no UI saying why.
|
|
1313
|
+
//
|
|
1314
|
+
// Audio counts. An audio-only timeline is the normal state of an animations
|
|
1315
|
+
// project between wiring music and authoring the first overlay, and gating
|
|
1316
|
+
// the region out there left the operator a populated timeline, a drawn
|
|
1317
|
+
// waveform lane, and no transport to press: space did nothing at all.
|
|
1318
|
+
//
|
|
1319
|
+
// `trackItems` is deliberately NOT sliced to `[1:]`. Track 0 is a content
|
|
1320
|
+
// track in a canvas project — an animations-workflow project is frequently
|
|
1321
|
+
// ONE track holding nothing but overlays — and `clips` only counts track-0
|
|
1322
|
+
// *video* items, so a track-0 overlay fell through both terms. Same blind
|
|
1323
|
+
// spot `transportEndFor` and `canvasMaxEndRef` were fixed for.
|
|
1324
|
+
const hasContent =
|
|
1325
|
+
clips.length > 0
|
|
1326
|
+
|| trackItems(project).flat().length > 0
|
|
1327
|
+
|| (project.captions?.segments?.length ?? 0) > 0
|
|
1328
|
+
|| audioEnd(project) > 0
|
|
1250
1329
|
|
|
1251
1330
|
// Preview controls row's timecode readout. `currentTime` is the same
|
|
1252
1331
|
// `usePlaybackTime(clock)` subscription `CaptionListPanelWithClock` uses
|
|
@@ -1621,6 +1700,13 @@ function ReviewSurface<P extends Project>({
|
|
|
1621
1700
|
void sync.mutate(() => updated as P)
|
|
1622
1701
|
}
|
|
1623
1702
|
|
|
1703
|
+
function handleAddMarker(at?: number) {
|
|
1704
|
+
const base = syncProjectRef.current
|
|
1705
|
+
const updated = addMarker(base, at ?? clock.get())
|
|
1706
|
+
if (updated === base) return // key repeat inside the half-frame window
|
|
1707
|
+
void sync.mutate(() => updated as P)
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1624
1710
|
function handleRippleToggle() {
|
|
1625
1711
|
const next = !rippleMode
|
|
1626
1712
|
setRippleMode(next)
|
|
@@ -1757,6 +1843,13 @@ function ReviewSurface<P extends Project>({
|
|
|
1757
1843
|
// is selected (a no-op if the axis isn't over the item).
|
|
1758
1844
|
action: () => handleSplit(previewAxis ? (hoverScrub.get() ?? undefined) : undefined),
|
|
1759
1845
|
},
|
|
1846
|
+
{
|
|
1847
|
+
id: 'video.add-marker',
|
|
1848
|
+
description: 'Drop a marker',
|
|
1849
|
+
keyHint: ['M'],
|
|
1850
|
+
matches: matchesPlainKey('m'),
|
|
1851
|
+
action: () => handleAddMarker(markerDropTime(previewAxis, hoverScrub.get(), clock.get())),
|
|
1852
|
+
},
|
|
1760
1853
|
{
|
|
1761
1854
|
id: 'video.undo',
|
|
1762
1855
|
description: 'Undo',
|
|
@@ -1897,6 +1990,7 @@ function ReviewSurface<P extends Project>({
|
|
|
1897
1990
|
const paletteCommands: PaletteCommand[] = [
|
|
1898
1991
|
{ id: 'play-pause', label: 'Play/Pause', keyHint: ['Space'], run: () => transportRef.current?.togglePlay() },
|
|
1899
1992
|
{ id: 'split', label: 'Split at playhead', keyHint: ['S'], run: () => handleSplit() },
|
|
1993
|
+
{ id: 'add-marker', label: 'Drop a marker', keyHint: ['M'], run: () => handleAddMarker() },
|
|
1900
1994
|
]
|
|
1901
1995
|
if (primarySelectedId) {
|
|
1902
1996
|
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
|
|