@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,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marker model — every mutation markers can undergo, as pure functions.
|
|
3
|
+
*
|
|
4
|
+
* Same contract as the rest of the timeline model: a function returns the SAME
|
|
5
|
+
* project reference when it would change nothing, so callers can use `next ===
|
|
6
|
+
* base` as their no-op guard and never push an empty undo entry or queue a
|
|
7
|
+
* pointless save (the convention `splitAtTime`, `computeAutoCrossfade` and
|
|
8
|
+
* `normalizeCaptionLanes` all follow).
|
|
9
|
+
*
|
|
10
|
+
* Markers are stored SORTED BY TIME. Nothing downstream re-sorts: the painter
|
|
11
|
+
* walks the array in order, and `serve/context.py` hands it to the agent as-is.
|
|
12
|
+
* Sorting on write rather than on read means one rule in one place.
|
|
13
|
+
*/
|
|
14
|
+
import type { EditorProject, Marker } from '../../schema'
|
|
15
|
+
|
|
16
|
+
/** Fallback frame rate when a project's settings omit one — matches the fps
|
|
17
|
+
* default `serve/context.py` and the renderer both use. */
|
|
18
|
+
const DEFAULT_FPS = 30
|
|
19
|
+
|
|
20
|
+
/** Fresh marker ids. Same shape as `cuts.ts`'s `uniqueId`: a time base plus
|
|
21
|
+
* randomness, so two markers dropped in the same millisecond still differ. */
|
|
22
|
+
function markerId(): string {
|
|
23
|
+
return `mk-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The label a new marker gets: one past the highest PURELY NUMERIC label in
|
|
28
|
+
* use.
|
|
29
|
+
*
|
|
30
|
+
* Reading the max rather than counting the array is what stops a delete from
|
|
31
|
+
* handing out a number twice — delete "2" of 1/2/3 and the next marker must be
|
|
32
|
+
* "4", not "3". A renamed marker ("cut this") contributes nothing, so renaming
|
|
33
|
+
* never stalls or rewinds the counter.
|
|
34
|
+
*/
|
|
35
|
+
export function nextMarkerLabel(markers: readonly Marker[]): string {
|
|
36
|
+
let max = 0
|
|
37
|
+
for (const m of markers) {
|
|
38
|
+
// `Number('')` is 0 and `Number('3px')` is NaN — require an all-digits
|
|
39
|
+
// label so neither can be mistaken for a counter value.
|
|
40
|
+
if (!/^\d+$/.test(m.label)) continue
|
|
41
|
+
const n = Number(m.label)
|
|
42
|
+
if (n > max) max = n
|
|
43
|
+
}
|
|
44
|
+
return String(max + 1)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const byTime = (a: Marker, b: Marker) => a.t - b.t
|
|
48
|
+
|
|
49
|
+
/** Write a marker list back, dropping the key entirely when it empties. */
|
|
50
|
+
function withMarkers(project: EditorProject, markers: Marker[]): EditorProject {
|
|
51
|
+
if (markers.length === 0) {
|
|
52
|
+
const { markers: _dropped, ...rest } = project
|
|
53
|
+
return rest as EditorProject
|
|
54
|
+
}
|
|
55
|
+
return { ...project, markers }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Drop a marker at `t`.
|
|
60
|
+
*
|
|
61
|
+
* Returns the same project when one already sits within HALF A FRAME: holding
|
|
62
|
+
* `M` down fires key repeat at the OS's repeat rate, and without this a held
|
|
63
|
+
* key buries the strip in stacked markers that then have to be deleted one by
|
|
64
|
+
* one. Half a frame is below the resolution the timeline can even distinguish,
|
|
65
|
+
* so nothing an operator could deliberately place is refused.
|
|
66
|
+
*/
|
|
67
|
+
export function addMarker(project: EditorProject, t: number, fps = project.settings?.fps ?? DEFAULT_FPS): EditorProject {
|
|
68
|
+
const at = Math.max(0, t)
|
|
69
|
+
const existing = project.markers ?? []
|
|
70
|
+
const halfFrame = 0.5 / (fps > 0 ? fps : DEFAULT_FPS)
|
|
71
|
+
if (existing.some(m => Math.abs(m.t - at) < halfFrame)) return project
|
|
72
|
+
const next = [...existing, { id: markerId(), t: at, label: nextMarkerLabel(existing) }].sort(byTime)
|
|
73
|
+
return { ...project, markers: next }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Retime a marker. Same reference for an unknown id or an unchanged time. */
|
|
77
|
+
export function moveMarker(project: EditorProject, id: string, t: number): EditorProject {
|
|
78
|
+
const existing = project.markers
|
|
79
|
+
if (!existing) return project
|
|
80
|
+
const at = Math.max(0, t)
|
|
81
|
+
const found = existing.find(m => m.id === id)
|
|
82
|
+
if (!found || found.t === at) return project
|
|
83
|
+
return withMarkers(project, existing.map(m => (m.id === id ? { ...m, t: at } : m)).sort(byTime))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Rename a marker. The label is trimmed, and an all-whitespace one is refused
|
|
88
|
+
* rather than committed — a blank marker draws as an empty box and tells the
|
|
89
|
+
* agent nothing, so a cleared rename box means "leave it alone", not "erase the
|
|
90
|
+
* name". Deleting the marker is the way to get rid of it.
|
|
91
|
+
*/
|
|
92
|
+
export function renameMarker(project: EditorProject, id: string, label: string): EditorProject {
|
|
93
|
+
const existing = project.markers
|
|
94
|
+
if (!existing) return project
|
|
95
|
+
const next = label.trim()
|
|
96
|
+
if (!next) return project
|
|
97
|
+
const found = existing.find(m => m.id === id)
|
|
98
|
+
if (!found || found.label === next) return project
|
|
99
|
+
return withMarkers(project, existing.map(m => (m.id === id ? { ...m, label: next } : m)))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Remove every marker whose id is in `ids`. Same reference when none matched. */
|
|
103
|
+
export function removeMarkers(project: EditorProject, ids: ReadonlySet<string>): EditorProject {
|
|
104
|
+
const existing = project.markers
|
|
105
|
+
if (!existing || existing.length === 0) return project
|
|
106
|
+
const kept = existing.filter(m => !ids.has(m.id))
|
|
107
|
+
if (kept.length === existing.length) return project
|
|
108
|
+
return withMarkers(project, kept)
|
|
109
|
+
}
|
|
@@ -127,6 +127,25 @@ export function resolveAudioWindow(
|
|
|
127
127
|
return { start, end: Math.max(horizon, start + AUDIO_FALLBACK_SPAN_SECONDS) }
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Furthest end across the project's unmuted audio tracks, resolving each
|
|
132
|
+
* track's window through `resolveAudioWindow` — so a track carrying no explicit
|
|
133
|
+
* `end` reports its natural length instead of collapsing to 0.
|
|
134
|
+
*
|
|
135
|
+
* Deliberately called with NO horizon: this feeds the value `contentDuration`
|
|
136
|
+
* is derived from, so passing one back in would be circular.
|
|
137
|
+
*
|
|
138
|
+
* Callers use this only as a LAST-RESORT transport ceiling, for a project with
|
|
139
|
+
* nothing visual in it. Audio stays out of the ceiling whenever there IS visual
|
|
140
|
+
* content — the canvas/video divergence over the audio tail is intentional and
|
|
141
|
+
* documented in timeline-core's `durations.js`.
|
|
142
|
+
*/
|
|
143
|
+
export function audioEnd(project: Project): number {
|
|
144
|
+
return (project.audio?.tracks ?? [])
|
|
145
|
+
.filter(t => !t.muted)
|
|
146
|
+
.reduce((m, t) => Math.max(m, resolveAudioWindow(t).end), 0)
|
|
147
|
+
}
|
|
148
|
+
|
|
130
149
|
/**
|
|
131
150
|
* Group audio tracks into rendered lanes, in ascending lane order. Tracks
|
|
132
151
|
* carrying an explicit `lane` keep it; the rest are auto-assigned lanes above
|