cursedbelt 5.112.1 → 5.113.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.
Files changed (45) hide show
  1. package/dist/react/media/CutTimeline.d.ts +31 -0
  2. package/dist/react/media/CutTimeline.d.ts.map +1 -0
  3. package/dist/react/media/CutTimeline.js +197 -0
  4. package/dist/react/media/CutTimeline.js.map +1 -0
  5. package/dist/react/media/VideoCutEditor.d.ts +32 -6
  6. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  7. package/dist/react/media/VideoCutEditor.js +97 -63
  8. package/dist/react/media/VideoCutEditor.js.map +1 -1
  9. package/dist/react/media/cutTimelineMath.d.ts +80 -0
  10. package/dist/react/media/cutTimelineMath.d.ts.map +1 -0
  11. package/dist/react/media/cutTimelineMath.js +116 -0
  12. package/dist/react/media/cutTimelineMath.js.map +1 -0
  13. package/dist/react/media/useFrameRate.d.ts +34 -0
  14. package/dist/react/media/useFrameRate.d.ts.map +1 -0
  15. package/dist/react/media/useFrameRate.js +66 -0
  16. package/dist/react/media/useFrameRate.js.map +1 -0
  17. package/dist/react/media/videoCutEditor.css +249 -63
  18. package/dist/react/media-gallery/MediaGallery.d.ts +10 -1
  19. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  20. package/dist/react/media-gallery/MediaGallery.js +29 -7
  21. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  22. package/dist/react/media-gallery/galleryDeepLink.d.ts +14 -0
  23. package/dist/react/media-gallery/galleryDeepLink.d.ts.map +1 -1
  24. package/dist/react/media-gallery/galleryDeepLink.js +44 -5
  25. package/dist/react/media-gallery/galleryDeepLink.js.map +1 -1
  26. package/dist/react/media-gallery/index.d.ts +1 -1
  27. package/dist/react/media-gallery/index.d.ts.map +1 -1
  28. package/dist/react/media-gallery/index.js +1 -1
  29. package/dist/react/media-gallery/index.js.map +1 -1
  30. package/dist/react/media-gallery/types.d.ts +5 -0
  31. package/dist/react/media-gallery/types.d.ts.map +1 -1
  32. package/package.json +2 -1
  33. package/scripts/guardrails-baseline.json +1 -1
  34. package/src/react/media/CutTimeline.tsx +379 -0
  35. package/src/react/media/VideoCutEditor.spec.tsx +1 -1
  36. package/src/react/media/VideoCutEditor.tsx +277 -214
  37. package/src/react/media/cutTimelineMath.spec.ts +90 -0
  38. package/src/react/media/cutTimelineMath.ts +140 -0
  39. package/src/react/media/useFrameRate.ts +96 -0
  40. package/src/react/media/videoCutEditor.css +249 -63
  41. package/src/react/media-gallery/MediaGallery.tsx +38 -7
  42. package/src/react/media-gallery/galleryDeepLink.spec.tsx +87 -2
  43. package/src/react/media-gallery/galleryDeepLink.ts +58 -5
  44. package/src/react/media-gallery/index.ts +1 -0
  45. package/src/react/media-gallery/types.ts +5 -0
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { type PlanSegment, type VideoCut } from 'cursedbelt-core/media/video-cuts';
3
+ /**
4
+ * The Trim page's timeline: a ruler, the clip's pieces, and a playhead, at any zoom from the
5
+ * whole clip down to one frame per 12 px (forge task 4311).
6
+ *
7
+ * The gestures are a map's and an NLE's, so nothing here has to be learned:
8
+ * · ⌘/Ctrl + wheel, or a trackpad pinch (which Chromium and Safari deliver as a ctrl-wheel),
9
+ * zooms around the POINTER; two fingers on a touch screen pinch the same way.
10
+ * · a plain wheel, or a drag anywhere on the track, pans.
11
+ * · a click on the ruler moves the playhead there, onto the frame under the pointer.
12
+ * · a click on a piece is the ✗ it always was — a drag that started on one never toggles it.
13
+ * The slider, ± and Fit do the same with no gesture at all (CNV-1: the wheel alone never zooms;
14
+ * CNV-3: a fit control on every canvas).
15
+ *
16
+ * 🔴 Drawn VIRTUALLY: only what is on screen is in the DOM, each piece clipped to the view. At
17
+ * 12 px a frame a two-hour 60 fps film is ten million pixels wide, which no browser will lay out
18
+ * as one element — so the track is always exactly the view, and the view is two numbers.
19
+ */
20
+ export interface CutTimelineProps {
21
+ duration: number;
22
+ fps: number;
23
+ playhead: number;
24
+ segments: readonly PlanSegment[];
25
+ onSeek: (seconds: number) => void;
26
+ onToggle: (segment: VideoCut) => void;
27
+ /** The transport, drawn at the start of the timeline's one-line toolbar (FRM-14). */
28
+ transport?: React.ReactNode;
29
+ }
30
+ export declare function CutTimeline({ duration, fps, playhead, segments, onSeek, onToggle, transport, ...passThrough }: CutTimelineProps): React.JSX.Element;
31
+ //# sourceMappingURL=CutTimeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CutTimeline.d.ts","sourceRoot":"","sources":["../../../src/react/media/CutTimeline.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,EAAe,KAAK,WAAW,EAAE,KAAK,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAmBhG;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,SAAS,WAAW,EAAE,CAAC;IACjC,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,QAAQ,EAAE,CAAC,OAAO,EAAE,QAAQ,KAAK,IAAI,CAAC;IACtC,qFAAqF;IACrF,SAAS,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC7B;AAeD,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,GAAG,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,WAAW,EAAE,EAAE,gBAAgB,qBA4T/H"}
@@ -0,0 +1,197 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Maximize2, X, ZoomIn, ZoomOut } from 'lucide-react';
4
+ import { cutTimecode } from 'cursedbelt-core/media/video-cuts';
5
+ import { IconButton } from '../components/IconButton.js';
6
+ import { Button } from '../components/Button.js';
7
+ import { Slider } from '../components/Slider.js';
8
+ import { Tooltip } from '../components/Tooltip.js';
9
+ import { cn, RAW_BUTTON } from '../lib/cn.js';
10
+ import { clampViewStart, frameAt, frameStart, rulerLabel, rulerStep, sliderToZoom, snapToFrame, zoomAround, zoomBounds, zoomToSlider, } from './cutTimelineMath.js';
11
+ /** A drag shorter than this is a click — the slop every pointer library uses. */
12
+ const DRAG_SLOP_PX = 4;
13
+ /** The width drawn at before the track is measured. */
14
+ const NOMINAL_WIDTH_PX = 1000;
15
+ /** Frame ticks are drawn once frames are at least this far apart. */
16
+ const FRAME_TICK_MIN_PX = 6;
17
+ export function CutTimeline({ duration, fps, playhead, segments, onSeek, onToggle, transport, ...passThrough }) {
18
+ const trackRef = React.useRef(null);
19
+ const [measured, setWidth] = React.useState(0);
20
+ // Until the track has been measured (and in a DOM with no layout at all), draw at a nominal
21
+ // width rather than at zero — zero would put every piece off screen, and the first real
22
+ // measurement lands in a layout effect, before anything paints.
23
+ const width = measured > 0 ? measured : NOMINAL_WIDTH_PX;
24
+ const [view, setView] = React.useState({ start: 0, pps: null });
25
+ React.useLayoutEffect(() => {
26
+ const track = trackRef.current;
27
+ if (!track)
28
+ return;
29
+ const measure = () => setWidth(track.clientWidth);
30
+ measure();
31
+ const observer = new ResizeObserver(measure);
32
+ observer.observe(track);
33
+ return () => observer.disconnect();
34
+ }, []);
35
+ const bounds = zoomBounds(width, duration, fps);
36
+ const pps = Math.min(bounds.max, Math.max(bounds.fit, view.pps ?? bounds.fit));
37
+ const start = clampViewStart(view.start, pps, width, duration);
38
+ const span = pps > 0 ? width / pps : 0;
39
+ const canZoom = bounds.max > bounds.fit && bounds.fit > 0;
40
+ // The handlers below are bound once (the wheel has to be non-passive, so it is a native
41
+ // listener) and read the current numbers from here.
42
+ const now = React.useRef({ pps, start, width, duration, bounds });
43
+ now.current = { pps, start, width, duration, bounds };
44
+ const zoomTo = React.useCallback((next, anchorX) => {
45
+ const n = now.current;
46
+ setView(zoomAround({ start: n.start, pps: n.pps }, next, anchorX, n.width, n.duration, n.bounds));
47
+ }, []);
48
+ /** Zoom by a factor around the playhead when it is on screen, else around the middle. */
49
+ const zoomBy = (factor) => {
50
+ const x = playhead >= start && playhead <= start + span ? (playhead - start) * pps : width / 2;
51
+ zoomTo(pps * factor, x);
52
+ };
53
+ React.useEffect(() => {
54
+ const track = trackRef.current;
55
+ if (!track)
56
+ return;
57
+ const onWheel = (event) => {
58
+ event.preventDefault();
59
+ const n = now.current;
60
+ const lines = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? n.width : 1;
61
+ if (event.ctrlKey || event.metaKey) {
62
+ const x = event.clientX - track.getBoundingClientRect().left;
63
+ zoomTo(n.pps * Math.exp(-event.deltaY * lines * 0.01), x);
64
+ return;
65
+ }
66
+ // A vertical wheel pans too: most mice have no other, and the page has nothing to scroll here.
67
+ const dx = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
68
+ setView({ pps: n.pps, start: clampViewStart(n.start + (dx * lines) / n.pps, n.pps, n.width, n.duration) });
69
+ };
70
+ track.addEventListener('wheel', onWheel, { passive: false });
71
+ return () => track.removeEventListener('wheel', onWheel);
72
+ }, [zoomTo]);
73
+ // Keep the playhead in view while it moves (playing, stepping, a seek from the cuts list) —
74
+ // a page-turn, as Resolve does, rather than a scroll that never lets the eye settle.
75
+ React.useEffect(() => {
76
+ const n = now.current;
77
+ if (n.width <= 0 || n.pps <= n.bounds.fit + 1e-9)
78
+ return;
79
+ const viewSpan = n.width / n.pps;
80
+ if (playhead < n.start || playhead > n.start + viewSpan) {
81
+ setView({ pps: n.pps, start: clampViewStart(playhead - viewSpan * 0.1, n.pps, n.width, n.duration) });
82
+ }
83
+ }, [playhead]);
84
+ /*
85
+ * Pointers. One pointer that moves past the slop pans; two pinch. One that does not move is a
86
+ * click: on the ruler it seeks, on a piece the piece's own button does its job.
87
+ */
88
+ const gesture = React.useRef({ pointers: new Map(), fromX: 0, fromStart: 0, dragged: false, pinch: null });
89
+ const onPointerDown = (event) => {
90
+ if (event.pointerType === 'mouse' && event.button !== 0)
91
+ return;
92
+ const g = gesture.current;
93
+ g.pointers.set(event.pointerId, event.clientX);
94
+ if (g.pointers.size === 1) {
95
+ g.fromX = event.clientX;
96
+ g.fromStart = start;
97
+ g.dragged = false;
98
+ g.pinch = null;
99
+ }
100
+ else if (g.pointers.size === 2) {
101
+ const [a, b] = [...g.pointers.values()];
102
+ g.pinch = { distance: Math.max(1, Math.abs(a - b)), pps };
103
+ g.dragged = true;
104
+ }
105
+ };
106
+ const onPointerMove = (event) => {
107
+ const g = gesture.current;
108
+ if (!g.pointers.has(event.pointerId))
109
+ return;
110
+ g.pointers.set(event.pointerId, event.clientX);
111
+ const left = event.currentTarget.getBoundingClientRect().left;
112
+ if (g.pinch && g.pointers.size >= 2) {
113
+ const [a, b] = [...g.pointers.values()];
114
+ zoomTo(g.pinch.pps * (Math.max(1, Math.abs(a - b)) / g.pinch.distance), (a + b) / 2 - left);
115
+ return;
116
+ }
117
+ const dx = event.clientX - g.fromX;
118
+ if (!g.dragged && Math.abs(dx) < DRAG_SLOP_PX)
119
+ return;
120
+ if (!g.dragged) {
121
+ g.dragged = true;
122
+ // Captured only once it is a drag: capturing on press would retarget the click of a
123
+ // piece's button to the track, and the ✗ would stop working.
124
+ event.currentTarget.setPointerCapture(event.pointerId);
125
+ }
126
+ const n = now.current;
127
+ setView({ pps: n.pps, start: clampViewStart(g.fromStart - dx / n.pps, n.pps, n.width, n.duration) });
128
+ };
129
+ const onPointerUp = (event) => {
130
+ const g = gesture.current;
131
+ if (!g.pointers.delete(event.pointerId))
132
+ return;
133
+ if (g.pointers.size > 0)
134
+ return;
135
+ g.pinch = null;
136
+ if (g.dragged || event.target.closest('.cbvc-seg'))
137
+ return;
138
+ const n = now.current;
139
+ const x = event.clientX - event.currentTarget.getBoundingClientRect().left;
140
+ onSeek(Math.min(n.duration, snapToFrame(n.start + x / n.pps, fps)));
141
+ };
142
+ // A drag that ended over a piece must not also toggle it.
143
+ const onClickCapture = (event) => {
144
+ if (gesture.current.dragged) {
145
+ event.preventDefault();
146
+ event.stopPropagation();
147
+ gesture.current.dragged = false;
148
+ }
149
+ };
150
+ const x = (seconds) => (seconds - start) * pps;
151
+ const end = start + span;
152
+ // The ruler: a labeled tick per step, and a tick per FRAME once frames are far enough apart.
153
+ const step = rulerStep(pps, fps);
154
+ const labels = [];
155
+ if (pps > 0 && duration > 0) {
156
+ // Counted in frames when the step is, so 0.1 s × 3 lands on frame 3 and not on 0.30000000000000004.
157
+ const stepFrames = Math.round(step.label * fps);
158
+ for (let k = Math.floor(start / step.label); k * step.label <= end + 1e-9; k += 1) {
159
+ const t = step.frames ? frameStart(k * stepFrames, fps) : k * step.label;
160
+ if (t >= start - 1e-9 && t <= duration + 1e-9)
161
+ labels.push(t);
162
+ }
163
+ }
164
+ const frameTicks = [];
165
+ if (pps / fps >= FRAME_TICK_MIN_PX) {
166
+ for (let k = frameAt(start, fps); frameStart(k, fps) <= Math.min(end, duration) + 1e-9; k += 1)
167
+ frameTicks.push(k);
168
+ }
169
+ return (_jsxs("div", { ...passThrough, className: 'cbvc-timeline', children: [_jsxs("div", { className: 'cbvc-tools', children: [transport, _jsxs("div", { className: 'cbvc-zoom', children: [_jsx(IconButton, { size: 'sm', label: 'Zoom out', tooltip: 'Zoom out (\u2318 or Ctrl + wheel, or pinch)', disabled: !canZoom || pps <= bounds.fit + 1e-9, onClick: () => zoomBy(0.5), children: _jsx(ZoomOut, { "aria-hidden": 'true' }) }), _jsx(Slider, { className: 'cbvc-zoom-slider', "aria-label": 'Zoom', "aria-valuetext": `${(pps / fps).toFixed(pps / fps < 1 ? 2 : 1)} px per frame`, min: 0, max: 1000, step: 1, disabled: !canZoom, value: Math.round(zoomToSlider(pps, bounds) * 1000), onChange: (value) => {
170
+ const next = sliderToZoom(value / 1000, bounds);
171
+ const x0 = playhead >= start && playhead <= end ? (playhead - start) * pps : width / 2;
172
+ zoomTo(next, x0);
173
+ } }), _jsx(IconButton, { size: 'sm', label: 'Zoom in', tooltip: 'Zoom in \u2014 down to single frames', disabled: !canZoom || pps >= bounds.max - 1e-9, onClick: () => zoomBy(2), children: _jsx(ZoomIn, { "aria-hidden": 'true' }) }), _jsxs(Button, { size: 'sm', variant: 'ghost', tooltip: 'Fit the whole clip in the timeline', disabled: view.pps === null || pps <= bounds.fit + 1e-9, onClick: () => setView({ start: 0, pps: null }), children: [_jsx(Maximize2, { "aria-hidden": 'true', size: 14 }), " Fit"] })] })] }), _jsxs("div", { ref: trackRef, className: 'cbvc-track', "data-px-per-second": pps, "data-view-start": start, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerUp, onClickCapture: onClickCapture, children: [_jsxs("div", { className: 'cbvc-ruler', "aria-hidden": 'true', children: [frameTicks.map((k) => (_jsx("span", { className: 'cbvc-frame-tick', "data-frame": k, style: { left: `${x(frameStart(k, fps))}px` } }, `f${k}`))), labels.map((t) => (_jsx("span", { className: 'cbvc-tick', style: { left: `${x(t)}px` }, children: _jsx("span", { className: 'cbvc-tick-label', children: rulerLabel(t, fps, step.frames, duration) }) }, `l${t}`)))] }), _jsx("ul", { className: 'cbvc-strip', "aria-label": segments.length <= 1
174
+ ? 'The whole clip, uncut'
175
+ : `${segments.length} sections; ${segments.filter((segment) => segment.removed).length} marked for removal`, children: segments
176
+ .filter((segment) => segment.end > start && segment.start < end)
177
+ .map((segment) => {
178
+ // Clipped to the view (a pixel past each edge, so no border shows at the cut-off).
179
+ const left = Math.max(-1, x(segment.start));
180
+ const right = Math.min(width + 1, x(segment.end));
181
+ const words = segment.removed
182
+ ? `Keep ${cutTimecode(segment.start)} to ${cutTimecode(segment.end)} after all`
183
+ : `Remove ${cutTimecode(segment.start)} to ${cutTimecode(segment.end)}`;
184
+ return (_jsx("li", { className: 'cbvc-seg-item', style: { left: `${left}px`, width: `${Math.max(0, right - left)}px` }, children: _jsx(Tooltip, { content: words, children: _jsxs("button", {
185
+ // guardrails-ignore no-raw-action-button: a segment of the timeline, sized to
186
+ // its own duration — a `Button` carries padding and a min-width, which is
187
+ // exactly wrong for a control whose WIDTH is the information.
188
+ type: 'button', className: cn(RAW_BUTTON, `cbvc-seg${segment.removed ? ' is-removed' : ''}`), "aria-pressed": segment.removed, "aria-label": words, "data-start": segment.start, "data-end": segment.end, onClick: () => onToggle(segment), children: [_jsx(X, { className: 'cbvc-seg-x', "aria-hidden": 'true', size: 12 }), _jsx("span", { className: 'cbvc-seg-label', children: segment.removed ? 'Removed' : cutTimecode(segment.end - segment.start) })] }) }) }, segment.key));
189
+ }) }), playhead >= start - 1e-9 && playhead <= end + 1e-9 ? (_jsx("span", { className: 'cbvc-strip-head', style: { left: `${x(playhead)}px` } })) : null] }), pps > bounds.fit + 1e-9 ? (_jsxs("div", { className: 'cbvc-overview', "aria-hidden": 'true', onClick: (event) => {
190
+ const rect = event.currentTarget.getBoundingClientRect();
191
+ const t = ((event.clientX - rect.left) / rect.width) * duration;
192
+ setView({ pps, start: clampViewStart(t - span / 2, pps, width, duration) });
193
+ }, children: [segments
194
+ .filter((segment) => segment.removed)
195
+ .map((segment) => (_jsx("span", { className: 'cbvc-overview-cut', style: { left: `${(segment.start / duration) * 100}%`, width: `${((segment.end - segment.start) / duration) * 100}%` } }, segment.key))), _jsx("span", { className: 'cbvc-overview-window', style: { left: `${(start / duration) * 100}%`, width: `${Math.max(0.5, (span / duration) * 100)}%` } }), _jsx("span", { className: 'cbvc-overview-head', style: { left: `${(playhead / duration) * 100}%` } })] })) : null] }));
196
+ }
197
+ //# sourceMappingURL=CutTimeline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CutTimeline.js","sourceRoot":"","sources":["../../../src/react/media/CutTimeline.tsx"],"names":[],"mappings":"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"}
@@ -12,7 +12,7 @@ import { type VideoCut } from 'cursedbelt-core/media/video-cuts';
12
12
  * it uses ... It can then remove the parts of the video I don't want, save a new copy and I
13
13
  * can view it when it's done."*
14
14
  *
15
- * So this dialog never touches a byte. It produces a `VideoCut[]`, hands it to `onSubmit`,
15
+ * So this page never touches a byte. It produces a `VideoCut[]`, hands it to `onSubmit`,
16
16
  * and closes. Everything about what makes a list valid is `videoCuts.ts`, shared with the
17
17
  * server that executes it, so the dialog and the encoder cannot disagree about what was
18
18
  * asked for.
@@ -51,7 +51,28 @@ import { type VideoCut } from 'cursedbelt-core/media/video-cuts';
51
51
  * ONE `InfoHint` — the fleet's standard ⓘ, which costs a 16px glyph at rest and opens on
52
52
  * hover, focus or tap. That is the answer to *"tooltips or an info icon or something we
53
53
  * standardize can make it clear without taking up space when out of use"*: the explanation
54
- * exists, in one predictable place, and pays no permanent rent in the dialog.
54
+ * exists, in one predictable place, and pays no permanent rent on the page.
55
+ *
56
+ * ── A PAGE, with a zoomable timeline, down to the frame (owner, 2026-11-03) ───────────
57
+ * *"a full Trim page with a zoomable timeline, down to frame-by-frame selection."* It was a
58
+ * `size='lg'` modal with one fixed strip, so on a two-hour film a pixel was several seconds
59
+ * and the frame he wanted could not be marked at all. Now it is the shape of an NLE's trim
60
+ * view (YouTube Studio's editor, Resolve's cut page): the picture on top, the transport and a
61
+ * full-width timeline under it, the actions bottom-right.
62
+ * · It COVERS the viewport and has an address of its own when the app gives it one
63
+ * (`MediaGallery`'s `trimming`, `useGalleryDeepLink({ trim: true })`), so Back closes it
64
+ * and a reload keeps it. Underneath it is still the belt's dialog primitive — that is what
65
+ * traps focus, takes Escape and makes the gallery's own keys stand down (`openDialogs`).
66
+ * · The timeline zooms from the whole clip to one frame per 12 px (`CutTimeline`).
67
+ * · `,` and `.` step one frame, every split lands on a frame boundary, and the readout is
68
+ * `hh:mm:ss:ff`. The rate is the catalog's probe, else measured from the clip
69
+ * (`useFrameRate`), and an assumed rate is labeled as one.
70
+ *
71
+ * 🔴 Frame-accurate is a promise only because binary-server KEEPS it: its trim decodes and
72
+ * re-encodes every kept span (`trim=start:end` + `concat` into libx264 — `cutArgs`,
73
+ * `apps/binary-server/src/media/processing/ffmpegJobs.ts`), so a cut lands on the frame it
74
+ * names, not on the keyframe before it. The note under the timeline says so; a stream-copy
75
+ * trim would make that sentence false, and it must change with it.
55
76
  */
56
77
  export interface VideoCutEditorProps {
57
78
  /**
@@ -78,21 +99,26 @@ export interface VideoCutEditorProps {
78
99
  /**
79
100
  * The clip's length, when the app already knows it. The element's own metadata still wins
80
101
  * once it loads: a duration the catalog rounded to the second would put every mark a
81
- * fraction out, and the marks are the entire output of this dialog.
102
+ * fraction out, and the marks are the entire output of this page.
82
103
  */
83
104
  durationSeconds?: number | null;
105
+ /**
106
+ * Frames per second from the catalog's probe, when it has one. Without it the page measures
107
+ * the clip itself (`useFrameRate`) and, until it can, says the rate it draws with is assumed.
108
+ */
109
+ frameRate?: number | null;
84
110
  open?: boolean;
85
111
  onClose: () => void;
86
112
  /**
87
- * Send the marks. Resolving CLOSES the dialog; rejecting leaves it open with the marks
113
+ * Send the marks. Resolving CLOSES the page; rejecting leaves it open with the marks
88
114
  * intact, because the alternative is losing several minutes of careful scrubbing to one
89
115
  * failed request.
90
116
  */
91
117
  onSubmit: (cuts: VideoCut[]) => void | Promise<void>;
92
118
  /** Wording for the confirm button — apps queue this differently. */
93
119
  submitLabel?: string;
94
- /** Shown under the strip: what happens after Send, in the app's own words. */
120
+ /** Shown under the timeline: what happens after Send, in the app's own words. */
95
121
  note?: React.ReactNode;
96
122
  }
97
- export declare function VideoCutEditor({ src, hlsUrl, posterUrl, resolving, name, durationSeconds, open, onClose, onSubmit, submitLabel, note, ...passThrough }: VideoCutEditorProps): React.JSX.Element;
123
+ export declare function VideoCutEditor({ src, hlsUrl, posterUrl, resolving, name, durationSeconds, frameRate, open, onClose, onSubmit, submitLabel, note, ...passThrough }: VideoCutEditorProps): React.JSX.Element;
98
124
  //# sourceMappingURL=VideoCutEditor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"VideoCutEditor.d.ts","sourceRoot":"","sources":["../../../src/react/media/VideoCutEditor.tsx"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAC9B,OAAO,KAAK,MAAM,OAAO,CAAC;AAQ1B,OAAO,EAYL,KAAK,QAAQ,EACd,MAAM,kCAAkC,CAAC;AAI1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;;OAQG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,4FAA4F;IAC5F,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB;;;;OAIG;IACH,QAAQ,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CACxB;AAED,wBAAgB,cAAc,CAAC,EAC7B,GAAG,EACH,MAAM,EACN,SAAS,EACT,SAAiB,EACjB,IAAI,EACJ,eAAe,EACf,IAAW,EACX,OAAO,EACP,QAAQ,EACR,WAAmC,EACnC,IAAI,EACJ,GAAG,WAAW,EACf,EAAE,mBAAmB,qBAoVrB"}
1
+ {"version":3,"file":"VideoCutEditor.d.ts","sourceRoot":"","sources":["../../../src/react/media/VideoCutEditor.tsx"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAC9B,OAAO,KAAK,MAAM,OAAO,CAAC;AAU1B,OAAO,EAYL,KAAK,QAAQ,EACd,MAAM,kCAAkC,CAAC;AAO1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;;OAQG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,4FAA4F;IAC5F,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB;;;;OAIG;IACH,QAAQ,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CACxB;AAED,wBAAgB,cAAc,CAAC,EAC7B,GAAG,EACH,MAAM,EACN,SAAS,EACT,SAAiB,EACjB,IAAI,EACJ,eAAe,EACf,SAAS,EACT,IAAW,EACX,OAAO,EACP,QAAQ,EACR,WAAmC,EACnC,IAAI,EACJ,GAAG,WAAW,EACf,EAAE,mBAAmB,qBAmXrB"}
@@ -2,32 +2,44 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import './videoCutEditor.css';
3
3
  import React from 'react';
4
4
  import { useEditorVideoSource } from './hlsSource.js';
5
- import { Scissors, X } from 'lucide-react';
5
+ import { ArrowLeft, Pause, Play, Scissors, StepBack, StepForward, Undo2 } from 'lucide-react';
6
6
  import { ActionBar } from '../components/ActionBar.js';
7
7
  import { Button } from '../components/Button.js';
8
+ import { Dialog, DialogContent, DialogTitle, focusPanelOnOpen } from '../components/dialog.js';
9
+ import { IconButton } from '../components/IconButton.js';
10
+ import { useDiscardGuard } from '../hooks/useDiscardGuard.js';
8
11
  import { InfoHint } from '../components/InfoHint.js';
9
12
  import { Tooltip } from '../components/Tooltip.js';
10
- import { Modal } from '../components/Modal.js';
11
13
  import { addSplit, cutSeconds, cutTimecode, EMPTY_PLAN, keptSeconds, planCuts, planProblem, planSegments, removeSplit, toggleSegment, } from 'cursedbelt-core/media/video-cuts';
12
14
  import { cn, RAW_BUTTON } from '../lib/cn.js';
13
15
  import { Kbd } from '../palette/Kbd.js';
14
- export function VideoCutEditor({ src, hlsUrl, posterUrl, resolving = false, name, durationSeconds, open = true, onClose, onSubmit, submitLabel = 'Send for processing', note, ...passThrough }) {
16
+ import { CutTimeline } from './CutTimeline.js';
17
+ import { frameAt, frameStart, frameTimecode, snapToFrame } from './cutTimelineMath.js';
18
+ import { useFrameRate } from './useFrameRate.js';
19
+ export function VideoCutEditor({ src, hlsUrl, posterUrl, resolving = false, name, durationSeconds, frameRate, open = true, onClose, onSubmit, submitLabel = 'Send for processing', note, ...passThrough }) {
15
20
  const videoRef = React.useRef(null);
16
21
  const [duration, setDuration] = React.useState(durationSeconds && Number.isFinite(durationSeconds) ? durationSeconds : 0);
17
22
  const [playhead, setPlayhead] = React.useState(0);
23
+ const [playing, setPlaying] = React.useState(false);
18
24
  const [plan, setPlan] = React.useState(EMPTY_PLAN);
19
25
  const [submitting, setSubmitting] = React.useState(false);
20
26
  const [error, setError] = React.useState(null);
27
+ // Marked cuts are minutes of scrubbing: Back, Escape and Cancel ask before dropping them.
28
+ const { guardedClose, discardOverlay, isConfirming, cancelDiscard } = useDiscardGuard({
29
+ onClose,
30
+ isDirty: plan.splits.length > 0,
31
+ open,
32
+ });
21
33
  // A source that changes is a fresh attempt: it must not inherit the previous one's failure,
22
34
  // the same rule `VideoTurner` and the gallery's own `ThumbImg` keep.
23
35
  const [failedSrc, setFailedSrc] = React.useState(null);
24
36
  /**
25
37
  * Why there is no film to mark up — `null` when there is one.
26
38
  *
27
- * 🔴 The dialog SAYS it rather than rendering the black rectangle a source-less `<video>`
39
+ * 🔴 The page SAYS it rather than rendering the black rectangle a source-less `<video>`
28
40
  * paints. Marking a cut means scrubbing to a moment, so with nothing to play there is
29
41
  * genuinely nothing to mark — and the one thing worse than that is a player that looks
30
- * broken and a Send button whose refusal ("Nothing is marked") reads as the dialog's fault.
42
+ * broken and a Send button whose refusal ("Nothing is marked") reads as the page's fault.
31
43
  */
32
44
  const cannotShow = resolving
33
45
  ? 'Finding this film…'
@@ -37,54 +49,79 @@ export function VideoCutEditor({ src, hlsUrl, posterUrl, resolving = false, name
37
49
  ? 'The film could not be loaded, so there is nothing to mark up. Nothing has been sent.'
38
50
  : null;
39
51
  const source = useEditorVideoSource(videoRef, { src, hlsUrl }, cannotShow === null);
52
+ const { rate, onLoadedData } = useFrameRate(videoRef, frameRate, source.key);
53
+ const fps = rate.fps;
40
54
  const segments = React.useMemo(() => planSegments(plan, duration), [plan, duration]);
41
55
  const cuts = React.useMemo(() => planCuts(plan, duration), [plan, duration]);
42
56
  const problem = planProblem(plan, duration);
43
- const pct = React.useCallback((seconds) => (duration > 0 ? Math.min(100, Math.max(0, (seconds / duration) * 100)) : 0), [duration]);
44
57
  const seek = React.useCallback((seconds) => {
45
58
  const video = videoRef.current;
46
- if (!video || !Number.isFinite(seconds))
59
+ if (!Number.isFinite(seconds))
47
60
  return;
48
- video.currentTime = Math.max(0, seconds);
49
- setPlayhead(Math.max(0, seconds));
61
+ const at = Math.max(0, seconds);
62
+ if (video)
63
+ video.currentTime = at;
64
+ setPlayhead(at);
50
65
  }, []);
51
- /** ✂ — divide the clip at the playhead. */
52
- const cutHere = React.useCallback(() => {
66
+ /** Where the playhead really is — the element's own clock, which a `timeupdate` trails. */
67
+ const now = () => videoRef.current?.currentTime ?? playhead;
68
+ /** `,` / `.` — one frame back or forward, pausing first, as YouTube and mpv do. */
69
+ const stepFrame = (direction) => {
70
+ const video = videoRef.current;
71
+ if (video && !video.paused)
72
+ video.pause();
73
+ const last = duration > 0 ? frameAt(duration - 1e-6, fps) : 0;
74
+ const frame = Math.min(last, Math.max(0, frameAt(now(), fps) + direction));
75
+ seek(frameStart(frame, fps));
76
+ };
77
+ const togglePlay = () => {
78
+ const video = videoRef.current;
79
+ if (!video)
80
+ return;
81
+ if (video.paused)
82
+ void video.play().catch(() => undefined);
83
+ else
84
+ video.pause();
85
+ };
86
+ /** ✂ — divide the clip at the start of the frame the playhead is on. */
87
+ const cutHere = () => {
53
88
  setError(null);
89
+ const at = snapToFrame(now(), fps);
54
90
  setPlan((current) => {
55
- const next = addSplit(current, playhead, duration);
91
+ const next = addSplit(current, at, duration);
56
92
  if (next === current)
57
93
  setError("There is already a cut there, or the playhead is too close to an end of the clip to cut.");
58
94
  return next;
59
95
  });
60
- }, [playhead, duration]);
96
+ };
61
97
  /** ✗ on a piece — mark it out, or put it back. */
62
98
  const toggle = React.useCallback((segment) => {
63
99
  setError(null);
64
100
  setPlan((current) => toggleSegment(current, segment, duration));
65
101
  }, [duration]);
66
102
  /** Undo a cut, merging the pieces either side. */
67
- const uncut = React.useCallback((at) => {
103
+ const uncut = (at) => {
68
104
  setError(null);
69
105
  setPlan((current) => removeSplit(current, at, duration));
70
- }, [duration]);
106
+ };
71
107
  /*
72
- * `C` cuts and `Space` plays — and nothing else is bound, deliberately.
108
+ * `C` cuts, `Space` plays, `,` and `.` step a frame — and nothing else is bound, deliberately.
73
109
  *
74
- * 🔴 Removing a piece has NO key. It is the only irreversible-feeling action in the dialog
75
- * and the one whose target is ambiguous from the keyboard: with the playhead between two
76
- * splits, "delete" could mean the piece behind it or the piece ahead. Clicking the piece
77
- * says which, which is the entire point of the model. Every segment is a real focusable
78
- * button, so the keyboard route is Tab-then-Enter and it names its target out loud.
110
+ * 🔴 Removing a piece has NO key. It is the only irreversible-feeling action on the page and
111
+ * the one whose target is ambiguous from the keyboard: with the playhead between two splits,
112
+ * "delete" could mean the piece behind it or the piece ahead. Clicking the piece says which,
113
+ * which is the entire point of the model. Every segment is a real focusable button, so the
114
+ * keyboard route is Tab-then-Enter and it names its target out loud.
79
115
  *
80
- * Bound on the DIALOG, not the window: the gallery has its own ⇧←/⇧→ and Space handling,
81
- * and a global listener would keep firing after this closed. `Modal` traps focus, so the
82
- * dialog really does have the keystrokes while it is open.
116
+ * Bound on the PAGE, not the window: the gallery has its own ⇧←/⇧→ and Space handling (it
117
+ * stands down while this is open), and a global listener would keep firing after this closed.
83
118
  */
84
119
  const onKeyDown = (event) => {
85
120
  const target = event.target;
86
121
  // Never steal a key from a text field — the app may render one in `note`.
87
- if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA'))
122
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable))
123
+ return;
124
+ if (event.metaKey || event.ctrlKey || event.altKey || isConfirming)
88
125
  return;
89
126
  if (event.key === 'c' || event.key === 'C') {
90
127
  event.preventDefault();
@@ -92,13 +129,11 @@ export function VideoCutEditor({ src, hlsUrl, posterUrl, resolving = false, name
92
129
  }
93
130
  else if (event.key === ' ') {
94
131
  event.preventDefault();
95
- const video = videoRef.current;
96
- if (!video)
97
- return;
98
- if (video.paused)
99
- void video.play().catch(() => undefined);
100
- else
101
- video.pause();
132
+ togglePlay();
133
+ }
134
+ else if (event.key === ',' || event.key === '.') {
135
+ event.preventDefault();
136
+ stepFrame(event.key === ',' ? -1 : 1);
102
137
  }
103
138
  };
104
139
  const submit = async () => {
@@ -121,36 +156,35 @@ export function VideoCutEditor({ src, hlsUrl, posterUrl, resolving = false, name
121
156
  setSubmitting(false);
122
157
  }
123
158
  };
124
- return (_jsx(Modal, { ...passThrough, open: open, onClose: onClose, title: name ? `Trim ${name}` : 'Trim video', size: 'lg', footer: _jsx(ActionBar, { primary: _jsxs(_Fragment, { children: [_jsx(Button, { variant: 'secondary', onClick: onClose, tooltip: 'Close without sending anything', children: "Cancel" }), _jsx(Button, { onClick: () => void submit(), disabled: submitting || problem !== null, tooltip: problem ?? 'Queue the cuts — the edited copy arrives beside the original', children: submitting ? 'Sending…' : submitLabel })] }) }), children: _jsxs("fieldset", { className: 'cbvc', onKeyDown: onKeyDown, "aria-label": 'Mark sections to cut. Press [ to start a cut, ] to end it, space to play or pause.', children: [cannotShow === null ? (_jsx("video", { ref: videoRef, className: 'cbvc-video', src: source.src, poster: posterUrl, controls: true, playsInline: true, preload: 'metadata', onLoadedMetadata: (event) => {
125
- const seen = event.currentTarget.duration;
126
- // The element's own metadata wins over anything the catalog rounded.
127
- if (Number.isFinite(seen) && seen > 0)
128
- setDuration(seen);
129
- }, onTimeUpdate: (event) => setPlayhead(event.currentTarget.currentTime), onSeeked: (event) => setPlayhead(event.currentTarget.currentTime), onError: (event) => {
130
- if (source.exhausted(event.currentTarget))
131
- setFailedSrc(source.key);
132
- } }, source.key)) : (_jsx("p", { className: 'cbvc-video cbvc-nofilm', role: 'status', children: cannotShow })), _jsxs("div", { className: 'cbvc-marks', children: [_jsxs(Button, { size: 'sm', variant: 'secondary', onClick: cutHere, tooltip: 'Cut the clip here (C)', children: [_jsx(Scissors, { "aria-hidden": 'true', size: 14 }), " Cut here"] }), _jsx("span", { className: 'cbvc-playhead', children: cutTimecode(playhead) }), _jsxs(InfoHint, { title: 'Marking a video to trim', align: 'right', children: [_jsxs("p", { children: ["Press ", _jsx("strong", { children: "Cut here" }), " (or ", _jsx(Kbd, { children: "C" }), ") to divide the clip at the playhead. Every piece between two cuts is a section."] }), _jsxs("p", { children: ["Click ", _jsx("strong", { children: "\u2717" }), " on a section to remove it \u2014 click again to keep it after all. Click a section's timecode to jump there, and ", _jsx(Kbd, { children: "Space" }), " plays and pauses."] }), _jsx("p", { children: "Nothing here changes your video. Sending makes a trimmed COPY beside the original, and you choose which to keep." })] })] }), _jsxs("ul", { className: 'cbvc-strip', "aria-label": segments.length <= 1
133
- ? 'The whole clip, uncut'
134
- : `${segments.length} sections; ${segments.filter((segment) => segment.removed).length} marked for removal`, children: [segments.map((segment) => (_jsx("li", { className: 'cbvc-seg-item', style: { width: `${pct(segment.end) - pct(segment.start)}%` }, children: _jsx(Tooltip, { content: segment.removed
135
- ? `Keep ${cutTimecode(segment.start)}–${cutTimecode(segment.end)} after all`
136
- : `Remove ${cutTimecode(segment.start)}–${cutTimecode(segment.end)}`, children: _jsxs("button", {
137
- // guardrails-ignore no-raw-action-button: a segment of the timeline, sized to
138
- // its own duration — a `Button` carries padding and a min-width, which is
139
- // exactly wrong for a control whose WIDTH is the information.
140
- type: 'button', className: cn(RAW_BUTTON, `cbvc-seg${segment.removed ? ' is-removed' : ''}`), "aria-pressed": segment.removed, "aria-label": segment.removed
141
- ? `Keep ${cutTimecode(segment.start)} to ${cutTimecode(segment.end)} after all`
142
- : `Remove ${cutTimecode(segment.start)} to ${cutTimecode(segment.end)}`, onClick: () => toggle(segment), children: [_jsx(X, { className: 'cbvc-seg-x', "aria-hidden": 'true', size: 12 }), _jsx("span", { className: 'cbvc-seg-label', children: segment.removed ? 'Removed' : cutTimecode(segment.end - segment.start) })] }) }) }, segment.key))), _jsx("span", { className: 'cbvc-strip-head', style: { left: `${pct(playhead)}%` } })] }), _jsx("p", { className: 'cbvc-summary', children: duration > 0 ? (_jsxs(_Fragment, { children: [_jsx("strong", { children: cutTimecode(keptSeconds(cuts, duration)) }), " kept of", ' ', cutTimecode(duration), cuts.length > 0
143
- ? ` — removing ${cutTimecode(cutSeconds(cuts, duration))} in ${cuts.length === 1 ? '1 section' : `${cuts.length} sections`}`
144
- : segments.length > 1
145
- ? ' — nothing marked for removal yet'
146
- : ''] })) : cannotShow === null ? ('Reading the clip…') : (
147
- // 🔴 Only a mounted film is being read. With none, this said "Reading the clip…"
148
- // forever beside the reason there was nothing to read (owner, 2026-10-27).
149
- 'Its length is not known.') }), plan.splits.length > 0 ? (_jsx("ul", { className: 'cbvc-list', children: planSegments(plan, duration)
150
- .slice(1)
151
- .map((segment) => (_jsxs("li", { className: 'cbvc-row', children: [_jsxs("button", {
152
- // guardrails-ignore no-raw-action-button: a plain-CSS row inside a lazily
153
- // imported, deliberately Tailwind-free editor — see `pictureEditor.css`.
154
- type: 'button', className: cn(RAW_BUTTON, 'cbvc-row-goto'), onClick: () => seek(segment.start), children: [_jsx(Scissors, { "aria-hidden": 'true', size: 12 }), " Cut at ", cutTimecode(segment.start)] }), _jsx(Button, { size: 'sm', variant: 'ghost', tooltip: 'Undo this cut \u2014 the sections either side become one again', "aria-label": `Undo the cut at ${cutTimecode(segment.start)}`, onClick: () => uncut(segment.start), children: "Undo" })] }, `cut-${segment.key}`))) })) : null, note ? _jsx("div", { className: 'cbvc-note', children: note }) : null, error ? (_jsx("p", { className: 'cbvc-error', role: 'alert', children: error })) : null] }) }));
159
+ const transport = (_jsxs("div", { className: 'cbvc-transport', children: [_jsx(IconButton, { size: 'sm', label: 'Back one frame', tooltip: 'Back one frame (,)', onClick: () => stepFrame(-1), disabled: cannotShow !== null, children: _jsx(StepBack, { "aria-hidden": 'true' }) }), _jsx(IconButton, { size: 'sm', label: playing ? 'Pause' : 'Play', tooltip: playing ? 'Pause (Space)' : 'Play (Space)', onClick: togglePlay, disabled: cannotShow !== null, children: playing ? _jsx(Pause, { "aria-hidden": 'true' }) : _jsx(Play, { "aria-hidden": 'true' }) }), _jsx(IconButton, { size: 'sm', label: 'Forward one frame', tooltip: 'Forward one frame (.)', onClick: () => stepFrame(1), disabled: cannotShow !== null, children: _jsx(StepForward, { "aria-hidden": 'true' }) }), _jsxs("span", { className: 'cbvc-readout', "data-testid": 'trim-readout', children: [_jsx("strong", { children: frameTimecode(playhead, fps) }), _jsxs("span", { className: 'cbvc-readout-of', children: [" / ", frameTimecode(duration, fps)] })] }), _jsx(Tooltip, { content: rate.source === 'probe'
160
+ ? 'The frame rate the file was probed at'
161
+ : rate.source === 'measured'
162
+ ? 'The frame rate, measured from the clip'
163
+ : 'Assumed — the clip has not said its frame rate, so frame steps may not match its frames', children: _jsx("span", { className: 'cbvc-fps', "data-source": rate.source, tabIndex: 0, children: rate.source === 'assumed' ? `${Math.round(fps)} fps?` : `${Number(fps.toFixed(3))} fps` }) }), _jsxs(Button, { size: 'sm', variant: 'secondary', onClick: cutHere, tooltip: 'Cut the clip at this frame (C)', disabled: cannotShow !== null, children: [_jsx(Scissors, { "aria-hidden": 'true', size: 14 }), " Cut here"] })] }));
164
+ return (_jsx(Dialog, { open: open, onOpenChange: (next) => !next && guardedClose(), children: _jsxs(DialogContent, { ...passThrough, className: 'cbvc-page', overlayClassName: 'cbvc-page-scrim', "aria-describedby": undefined, onOpenAutoFocus: focusPanelOnOpen, onEscapeKeyDown: (event) => {
165
+ event.preventDefault();
166
+ if (isConfirming)
167
+ cancelDiscard();
168
+ else
169
+ guardedClose();
170
+ }, onKeyDown: onKeyDown, children: [_jsxs("header", { className: 'cbvc-bar', children: [_jsx(IconButton, { label: 'Back', tooltip: 'Back to the file, sending nothing (Esc)', onClick: guardedClose, children: _jsx(ArrowLeft, { "aria-hidden": 'true' }) }), _jsx(DialogTitle, { className: 'cbvc-title', children: name ? `Trim ${name}` : 'Trim video' }), _jsxs(InfoHint, { title: 'Marking a video to trim', align: 'right', children: [_jsxs("p", { children: ["Press ", _jsx("strong", { children: "Cut here" }), " (or ", _jsx(Kbd, { children: "C" }), ") to divide the clip at the frame the playhead is on. Every piece between two cuts is a section."] }), _jsxs("p", { children: ["Click ", _jsx("strong", { children: "\u2717" }), " on a section to remove it \u2014 click again to keep it after all.", _jsx(Kbd, { children: "," }), " and ", _jsx(Kbd, { children: "." }), " step one frame, ", _jsx(Kbd, { children: "Space" }), " plays and pauses."] }), _jsx("p", { children: "Zoom the timeline with \u2318 or Ctrl and the wheel, a pinch, or the slider; drag or scroll to move along it, and click the ruler to put the playhead there." }), _jsx("p", { children: "Nothing here changes your video. Sending makes a trimmed COPY beside the original, and you choose which to keep." })] })] }), _jsx("div", { className: cn('cbvc-stage', cannotShow !== null && 'cbvc-stage-empty'), children: cannotShow === null ? (_jsx("video", { ref: videoRef, className: 'cbvc-video', src: source.src, poster: posterUrl, playsInline: true, preload: 'auto', onClick: togglePlay, onLoadedMetadata: (event) => {
171
+ const seen = event.currentTarget.duration;
172
+ // The element's own metadata wins over anything the catalog rounded.
173
+ if (Number.isFinite(seen) && seen > 0)
174
+ setDuration(seen);
175
+ }, onLoadedData: onLoadedData, onPlay: () => setPlaying(true), onPause: () => setPlaying(false), onTimeUpdate: (event) => setPlayhead(event.currentTarget.currentTime), onSeeked: (event) => setPlayhead(event.currentTarget.currentTime), onError: (event) => {
176
+ if (source.exhausted(event.currentTarget))
177
+ setFailedSrc(source.key);
178
+ } }, source.key)) : (_jsx("p", { className: 'cbvc-video cbvc-nofilm', role: 'status', children: cannotShow })) }), _jsxs("div", { className: 'cbvc-deck', children: [duration > 0 ? (_jsx(CutTimeline, { duration: duration, fps: fps, playhead: playhead, segments: segments, onSeek: seek, onToggle: toggle, transport: transport })) : (_jsx("div", { className: 'cbvc-tools', children: transport })), plan.splits.length > 0 ? (_jsx("ul", { className: 'cbvc-list', "aria-label": 'Cuts', children: segments.slice(1).map((segment) => (_jsxs("li", { className: 'cbvc-row', children: [_jsxs("button", {
179
+ // guardrails-ignore no-raw-action-button: a plain-CSS row inside a lazily
180
+ // imported, deliberately Tailwind-free editor — see `pictureEditor.css`.
181
+ type: 'button', className: cn(RAW_BUTTON, 'cbvc-row-goto'), onClick: () => seek(segment.start), children: [_jsx(Scissors, { "aria-hidden": 'true', size: 12 }), " ", frameTimecode(segment.start, fps)] }), _jsx(IconButton, { size: 'sm', label: `Undo the cut at ${frameTimecode(segment.start, fps)}`, tooltip: 'Undo this cut \u2014 the sections either side become one again', onClick: () => uncut(segment.start), children: _jsx(Undo2, { "aria-hidden": 'true' }) })] }, `cut-${segment.key}`))) })) : null, _jsxs("div", { className: 'cbvc-note', children: [_jsx("p", { children: "Each cut lands on the frame you mark: the trimmed copy is re-encoded at every join." }), note ? _jsx("div", { children: note }) : null] }), error ? (_jsx("p", { className: 'cbvc-error', role: 'alert', children: error })) : null] }), _jsx(ActionBar, { className: 'cbvc-foot', secondary: _jsx(Button, { variant: 'secondary', onClick: guardedClose, tooltip: 'Close without sending anything', children: "Cancel" }), primary: _jsx(Button, { onClick: () => void submit(), disabled: submitting || problem !== null, tooltip: problem ?? 'Queue the cuts — the edited copy arrives beside the original', children: submitting ? 'Sending…' : submitLabel }), children: _jsx("p", { className: 'cbvc-summary', children: duration > 0 ? (_jsxs(_Fragment, { children: [_jsx("strong", { children: cutTimecode(keptSeconds(cuts, duration)) }), " kept of ", cutTimecode(duration), cuts.length > 0
182
+ ? ` — removing ${cutTimecode(cutSeconds(cuts, duration))} in ${cuts.length === 1 ? '1 section' : `${cuts.length} sections`}`
183
+ : segments.length > 1
184
+ ? ' — nothing marked for removal yet'
185
+ : ''] })) : cannotShow === null ? ('Reading the clip…') : (
186
+ // 🔴 Only a mounted film is being read. With none, this said "Reading the clip…"
187
+ // forever beside the reason there was nothing to read (owner, 2026-10-27).
188
+ 'Its length is not known.') }) }), discardOverlay] }) }));
155
189
  }
156
190
  //# sourceMappingURL=VideoCutEditor.js.map