@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
|
@@ -60,6 +60,7 @@ import { reflowMagneticLanes } from '../../audioMagnet'
|
|
|
60
60
|
import { laneOf, normalizeCaptionLanes, resolveDropLane, sameLaneNeighbours } from '../../captionLanes'
|
|
61
61
|
import { collapseGaps, rollEdit, slideItem, slipItem } from '../../cuts'
|
|
62
62
|
import { canKeyframe, enableKeyframing, moveKeyframe, setKeyframe, transformProps, valueAt } from '../../keyframeOps'
|
|
63
|
+
import { moveMarker } from '../markers'
|
|
63
64
|
import { applyMoveDeltaToSelection, applyResizeDeltaToSelection } from '../multiSelectOps'
|
|
64
65
|
import { AUDIO_LANE_HEIGHT_PX, CAPTION_ROW_HEIGHT_PX, computeDerivedTiming, groupAudioLanes, mapTrackItems, moveItemAcrossTracks, normalizeTracks, resolveTargetTrackIdx, trackItems, updateAudioTrack } from '../timeline-model'
|
|
65
66
|
import { DRAG_THRESHOLD_PX, computeResizedItem, resizeWindowedItem, type Draggable } from '../useItemDragDrop'
|
|
@@ -167,6 +168,10 @@ export type PointerEffect =
|
|
|
167
168
|
* sidebar, which is where caption text is edited now that the inline
|
|
168
169
|
* contentEditable row is gone. */
|
|
169
170
|
| { type: 'editCaption'; id: string }
|
|
171
|
+
/** Double-click on a marker: the host opens its inline rename box. Named
|
|
172
|
+
* separately from `editCaption` because it routes somewhere else entirely —
|
|
173
|
+
* an overlay input on the canvas, not the transcript sidebar. */
|
|
174
|
+
| { type: 'editMarker'; id: string }
|
|
170
175
|
/** The surface's CSS cursor. Emitted only when it changes. */
|
|
171
176
|
| { type: 'cursor'; cursor: Cursor }
|
|
172
177
|
/** Where to draw the snap guide and how hard it is holding, or nulls to take
|
|
@@ -214,6 +219,8 @@ export type GestureKind =
|
|
|
214
219
|
/** Dragging one caption block's in/out edge. Single-segment: multi-caption
|
|
215
220
|
* trim is out of scope for v1. */
|
|
216
221
|
| 'caption-trim'
|
|
222
|
+
/** Re-timing a marker in the strip above the ruler. See `applyMarkerMove`. */
|
|
223
|
+
| 'marker-move'
|
|
217
224
|
/** Dragging the playhead. Lives on the ruler strip only. */
|
|
218
225
|
| 'scrub'
|
|
219
226
|
/** Dragging a rubber-band box across empty track area to select. */
|
|
@@ -330,6 +337,10 @@ export function cursorForHit(hit: HitResult): Cursor {
|
|
|
330
337
|
// retime-in-place gesture a trim edge does, so it gets the same cursor.
|
|
331
338
|
case 'keyframe':
|
|
332
339
|
return 'ew-resize'
|
|
340
|
+
// A marker only ever moves horizontally along the strip — same
|
|
341
|
+
// retime-in-place gesture, same cursor.
|
|
342
|
+
case 'marker':
|
|
343
|
+
return 'ew-resize'
|
|
333
344
|
case 'item-edge':
|
|
334
345
|
case 'audio-edge':
|
|
335
346
|
case 'caption-edge':
|
|
@@ -355,6 +366,7 @@ function cursorForGesture(gesture: GestureKind): Cursor {
|
|
|
355
366
|
case 'caption-trim':
|
|
356
367
|
case 'scrub':
|
|
357
368
|
case 'keyframe-move':
|
|
369
|
+
case 'marker-move':
|
|
358
370
|
return 'ew-resize'
|
|
359
371
|
case 'marquee':
|
|
360
372
|
return 'crosshair'
|
|
@@ -384,6 +396,10 @@ export function resolveGesture(hit: HitResult, modifiers: Modifiers): GestureKin
|
|
|
384
396
|
// A keyframe diamond does exactly one thing too — retime.
|
|
385
397
|
case 'keyframe':
|
|
386
398
|
return 'keyframe-move'
|
|
399
|
+
// So does a marker: there is nothing to trim and no second dimension to
|
|
400
|
+
// drag it in, so no modifiers of its own either.
|
|
401
|
+
case 'marker':
|
|
402
|
+
return 'marker-move'
|
|
387
403
|
// Captions have no roll/slip/slide either — there is no source window to
|
|
388
404
|
// slip and no neighbour to roll against — so modifiers fall through here
|
|
389
405
|
// exactly as they do for audio.
|
|
@@ -567,9 +583,19 @@ function isOverPlayhead(point: Point, ctx: PointerContext): boolean {
|
|
|
567
583
|
* into `isEdgeHit` (whose contract is specifically "describes a trim grab",
|
|
568
584
|
* read via `hit.edge`; a fade grip has no `edge`, only `side`). Keyframe
|
|
569
585
|
* diamonds win for the identical reason — same size class as a fade grip,
|
|
570
|
-
* same "smaller and more deliberate than a trim edge" case for the grab.
|
|
586
|
+
* same "smaller and more deliberate than a trim edge" case for the grab.
|
|
587
|
+
*
|
|
588
|
+
* MARKERS win too, and for a stronger reason than any of the above (decision
|
|
589
|
+
* D3). A flag is a small target the operator aimed at deliberately, so the D1
|
|
590
|
+
* reasoning already covers it — but a marker is also routinely dropped AT the
|
|
591
|
+
* playhead: `M` places one at the current time, which puts it exactly on the
|
|
592
|
+
* line. Without this exclusion the freshest marker is always the one under the
|
|
593
|
+
* playhead, so the COMMONEST marker on the strip would be the one you can
|
|
594
|
+
* never pick up. */
|
|
571
595
|
function grabsPlayhead(hit: HitResult, point: Point, ctx: PointerContext): boolean {
|
|
572
|
-
return !isEdgeHit(hit) && hit.kind !== 'audio-fade' && hit.kind !== 'keyframe'
|
|
596
|
+
return !isEdgeHit(hit) && hit.kind !== 'audio-fade' && hit.kind !== 'keyframe'
|
|
597
|
+
&& hit.kind !== 'marker'
|
|
598
|
+
&& isOverPlayhead(point, ctx)
|
|
573
599
|
}
|
|
574
600
|
|
|
575
601
|
/**
|
|
@@ -1124,6 +1150,36 @@ function applyAudioFade(ctx: PointerContext, press: Press, point: Point, snap: S
|
|
|
1124
1150
|
return { effects: [{ type: 'projectChange', project: next }], snap, lastProject: next, guide: null }
|
|
1125
1151
|
}
|
|
1126
1152
|
|
|
1153
|
+
/**
|
|
1154
|
+
* Retime a marker under the pointer.
|
|
1155
|
+
*
|
|
1156
|
+
* Snapping is deliberately NOT applied: a marker is a free annotation, and the
|
|
1157
|
+
* snap boundaries are clip and audio edges — the moments a marker most often
|
|
1158
|
+
* needs to sit slightly OFF. Nothing else in the strip has edges to snap to.
|
|
1159
|
+
*
|
|
1160
|
+
* GRAB-RELATIVE, not absolute. The hit region is a flat `MARKER_HIT_WIDTH_PX`
|
|
1161
|
+
* wide (hit-test.ts) so that the LABEL is clickable and not just the 2px flag
|
|
1162
|
+
* stem — which means a press very often lands well right of the flag itself.
|
|
1163
|
+
* Retiming to the raw pointer time would teleport the marker up to that full
|
|
1164
|
+
* width to meet the cursor on the first move past threshold. Every other drag
|
|
1165
|
+
* on this surface preserves its grab offset through `dragDeltaSeconds`, and so
|
|
1166
|
+
* does this one. `applyScrub` is absolute and is NOT a precedent for it: when
|
|
1167
|
+
* you scrub, the playhead IS the cursor, so there is no offset to preserve.
|
|
1168
|
+
*/
|
|
1169
|
+
function applyMarkerMove(ctx: PointerContext, press: Press, point: Point, snap: SnapState, lastProject: Project): Applied {
|
|
1170
|
+
const id = press.hit.markerId
|
|
1171
|
+
const from = press.hit.marker
|
|
1172
|
+
if (!id || !from) return noChange(snap, lastProject)
|
|
1173
|
+
// Anchor on the marker the HIT captured, the way `applyAudioFade` reads
|
|
1174
|
+
// `press.hit.track` — `hitTest` took it from the same project `baseProject`
|
|
1175
|
+
// holds, at the same instant, so it is that project's own marker and needs no
|
|
1176
|
+
// per-move lookup. `HitResult.marker` exists for exactly this.
|
|
1177
|
+
const t = Math.max(0, from.t + dragDeltaSeconds(ctx, press, point))
|
|
1178
|
+
const next = moveMarker(press.baseProject, id, t)
|
|
1179
|
+
if (next === press.baseProject && lastProject === press.baseProject) return noChange(snap, lastProject)
|
|
1180
|
+
return { effects: [{ type: 'projectChange', project: next }], snap, lastProject: next, guide: null }
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1127
1183
|
/**
|
|
1128
1184
|
* Drag a keyframe-strip diamond to retime it (plan decision 5).
|
|
1129
1185
|
*
|
|
@@ -1480,6 +1536,7 @@ function applyGesture(
|
|
|
1480
1536
|
case 'audio-trim': return applyAudioTrim(ctx, press, point, snap, lastProject, escaped)
|
|
1481
1537
|
case 'audio-fade': return applyAudioFade(ctx, press, point, snap, lastProject)
|
|
1482
1538
|
case 'keyframe-move': return applyKeyframeMove(ctx, press, point, snap, lastProject, escaped)
|
|
1539
|
+
case 'marker-move': return applyMarkerMove(ctx, press, point, snap, lastProject)
|
|
1483
1540
|
case 'caption-move': return applyCaptionMove(ctx, press, point, snap, lastProject, escaped)
|
|
1484
1541
|
case 'caption-trim': return applyCaptionTrim(ctx, press, point, snap, lastProject, escaped)
|
|
1485
1542
|
case 'scrub': return applyScrub(ctx, point, snap, lastProject)
|
|
@@ -1571,7 +1628,9 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
|
|
|
1571
1628
|
// gets keyframe hit-testing for free the moment it has a selection —
|
|
1572
1629
|
// exactly like `ctx.selectedIds` itself is already required on the context,
|
|
1573
1630
|
// not an opt-in.
|
|
1574
|
-
const hit = hitTest(point, ctx.layout, ctx.viewport, {
|
|
1631
|
+
const hit = hitTest(point, ctx.layout, ctx.viewport, {
|
|
1632
|
+
...ctx.hitTestOptions, selectedIds: ctx.selectedIds, markers: ctx.project.markers,
|
|
1633
|
+
})
|
|
1575
1634
|
|
|
1576
1635
|
switch (event.type) {
|
|
1577
1636
|
case 'pointerDown': {
|
|
@@ -1582,6 +1641,12 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
|
|
|
1582
1641
|
// the full-height red bar anywhere it is reachable does the same thing
|
|
1583
1642
|
// (`grabsPlayhead`). Landing the seek on mousedown rather than mouseup is
|
|
1584
1643
|
// what makes the drag continuous.
|
|
1644
|
+
//
|
|
1645
|
+
// A MARKER hit must never reach this branch: it presses like an item, not
|
|
1646
|
+
// like the ruler. Two things keep it out, and both have to hold — `hitTest`
|
|
1647
|
+
// returns 'marker' (never 'ruler') for a point in the strip, and
|
|
1648
|
+
// `grabsPlayhead` excludes 'marker' explicitly. Change either and every
|
|
1649
|
+
// marker press starts scrubbing again.
|
|
1585
1650
|
const onRuler = hit.kind === 'ruler'
|
|
1586
1651
|
if (onRuler || grabsPlayhead(hit, point, ctx)) {
|
|
1587
1652
|
const applied = applyScrub(ctx, point, createSnapState(), ctx.project)
|
|
@@ -1683,7 +1748,15 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
|
|
|
1683
1748
|
if (state.kind === 'pressed') {
|
|
1684
1749
|
const { hit: pressed, wasSelected } = state.press
|
|
1685
1750
|
const effects: PointerEffect[] = []
|
|
1686
|
-
if (pressed.
|
|
1751
|
+
if (pressed.kind === 'marker' && pressed.markerId !== undefined) {
|
|
1752
|
+
// A marker click selects it, exactly as a clip click does — same
|
|
1753
|
+
// `select` effect, same host selection, which is what lets Delete
|
|
1754
|
+
// reach it. Its id lives in `markerId` rather than `itemId` (a marker
|
|
1755
|
+
// is not an item), so it needs its own branch rather than falling
|
|
1756
|
+
// into the one below. No seek: the playhead belongs to the ruler,
|
|
1757
|
+
// and a marker press is aimed at the flag, not at the time under it.
|
|
1758
|
+
effects.push({ type: 'select', id: pressed.markerId, additive: isAdditive(modifiers) })
|
|
1759
|
+
} else if (pressed.itemId !== undefined) {
|
|
1687
1760
|
const additive = isAdditive(modifiers)
|
|
1688
1761
|
effects.push({ type: 'select', id: pressed.itemId, additive })
|
|
1689
1762
|
// VisualTrackRow seeks on a plain click that changes the selection;
|
|
@@ -1802,6 +1875,12 @@ export function pointerReducer(state: MachineState, event: PointerMachineEvent):
|
|
|
1802
1875
|
if (hit.kind === 'keyframe' && hit.itemId !== undefined && hit.kfT !== undefined) {
|
|
1803
1876
|
return { state, effects: [{ type: 'selectKeyframe', itemId: hit.itemId, t: hit.kfT }] }
|
|
1804
1877
|
}
|
|
1878
|
+
// A marker opens its rename box. Checked up here with the diamond for the
|
|
1879
|
+
// same reason: it carries no `itemId`, so the generic branches below
|
|
1880
|
+
// would drop it on the floor rather than swallow it.
|
|
1881
|
+
if (hit.kind === 'marker' && hit.markerId !== undefined) {
|
|
1882
|
+
return { state, effects: [{ type: 'editMarker', id: hit.markerId }] }
|
|
1883
|
+
}
|
|
1805
1884
|
// Key the SELECTED element, and only when the click landed ON it. Checked
|
|
1806
1885
|
// against `ctx.selectedIds` rather than "is there a selection" so a
|
|
1807
1886
|
// double-click on a different clip never keys the selected one.
|
|
@@ -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
|
+
}
|