@pixerate/editor-react 0.56.0 → 0.58.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.
@@ -41,11 +41,11 @@ function useVideoEditor(controllerOrOptions = {}) {
41
41
  return { controller, state, ...actions };
42
42
  }
43
43
  function useVideoEditorTools(registry, controller, options = {}) {
44
- const { id, namespace, readOnly, rasterize } = options;
44
+ const { id, namespace, readOnly, rasterize, framePixels, framePixelsTimeout } = options;
45
45
  useEffect(() => {
46
46
  if (!registry) return;
47
- return registry.register(createVideoEditorToolProvider(controller, { id, namespace, readOnly, rasterize }));
48
- }, [registry, controller, id, namespace, readOnly, rasterize]);
47
+ return registry.register(createVideoEditorToolProvider(controller, { id, namespace, readOnly, rasterize, framePixels, framePixelsTimeout }));
48
+ }, [registry, controller, id, namespace, readOnly, rasterize, framePixels, framePixelsTimeout]);
49
49
  }
50
50
 
51
51
  // src/video-editor/VideoPreview.tsx
@@ -76,28 +76,96 @@ function VideoPreview({ controller, resolution, onFrame, className = "", style }
76
76
  );
77
77
  }
78
78
 
79
+ // src/video-editor/VideoScopes.tsx
80
+ import { useEffect as useEffect3, useRef as useRef3, useState } from "react";
81
+ import { readVideoScopesPanel, VIDEO_SCOPE_KINDS } from "@pixerate/editor/video-editor";
82
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
83
+ var LABELS = { histogram: "Histogram", waveform: "Luma waveform", vectorscope: "Vectorscope" };
84
+ var pct = (v) => `${Math.round(v * 1e3) / 10}%`;
85
+ function VideoScopes({ controller, scopes = VIDEO_SCOPE_KINDS, width = 256, height = 128, className = "" }) {
86
+ const canvases = useRef3({});
87
+ const [reading, setReading] = useState(() => ({ ok: false, message: "Show the preview to see its scopes." }));
88
+ const key = scopes.join(",");
89
+ useEffect3(() => {
90
+ const feed = controller.previewPixels;
91
+ let frame = 0;
92
+ const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (fn) => setTimeout(fn, 16);
93
+ const caf = typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : (id) => clearTimeout(id);
94
+ const draw = () => {
95
+ frame = 0;
96
+ const next = readVideoScopesPanel(feed, scopes, { width, height });
97
+ setReading(next);
98
+ if (!next.ok) return;
99
+ for (const kind of scopes) {
100
+ const img = next.images[kind];
101
+ const ctx = canvases.current[kind]?.getContext("2d");
102
+ if (img && ctx && typeof ImageData !== "undefined") ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
103
+ }
104
+ };
105
+ const schedule = () => {
106
+ if (!frame) frame = raf(draw);
107
+ };
108
+ schedule();
109
+ const off = feed.subscribe(schedule);
110
+ return () => {
111
+ off();
112
+ if (frame) caf(frame);
113
+ };
114
+ }, [controller, key, width, height]);
115
+ return /* @__PURE__ */ jsxs("div", { className: `flex flex-col gap-2 text-xs ${className}`, "data-video-scopes": "", children: [
116
+ /* @__PURE__ */ jsx2("div", { className: "flex flex-wrap gap-2", children: scopes.map((kind) => {
117
+ const w = kind === "vectorscope" ? height : width;
118
+ return /* @__PURE__ */ jsxs("figure", { className: "m-0 flex min-w-0 max-w-full flex-col gap-1", children: [
119
+ /* @__PURE__ */ jsx2("canvas", { ref: (el) => void (canvases.current[kind] = el), width: w, height, className: "rounded border border-border bg-black", style: { width: w, maxWidth: "100%", height: "auto", aspectRatio: `${w} / ${height}` }, "aria-label": LABELS[kind], "data-scope": kind }),
120
+ /* @__PURE__ */ jsx2("figcaption", { className: "text-[11px] text-muted-foreground", children: LABELS[kind] })
121
+ ] }, kind);
122
+ }) }),
123
+ reading.ok ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-0.5", "data-scope-stats": "", children: [
124
+ /* @__PURE__ */ jsxs("div", { className: "tabular-nums", children: [
125
+ "Luma ",
126
+ reading.stats.luma.mean.toFixed(2),
127
+ " mean \xB7 ",
128
+ reading.stats.luma.p1.toFixed(2),
129
+ "\u2013",
130
+ reading.stats.luma.p99.toFixed(2),
131
+ " \xB7 crushed ",
132
+ pct(reading.stats.clipped.shadows),
133
+ " \xB7 blown ",
134
+ pct(reading.stats.clipped.highlights),
135
+ " \xB7 average",
136
+ " ",
137
+ /* @__PURE__ */ jsx2("span", { className: "inline-block h-2.5 w-2.5 rounded-sm align-middle", style: { background: reading.stats.average } }),
138
+ " ",
139
+ reading.stats.average,
140
+ !reading.complete && /* @__PURE__ */ jsx2("span", { className: "text-muted-foreground", children: " \xB7 loading" })
141
+ ] }),
142
+ reading.notes.map((n) => /* @__PURE__ */ jsx2("div", { className: "text-amber-500", children: n }, n))
143
+ ] }) : /* @__PURE__ */ jsx2("div", { className: "text-muted-foreground", "data-scope-message": "", children: reading.message })
144
+ ] });
145
+ }
146
+
79
147
  // src/video-editor/VideoTransport.tsx
80
148
  import { formatVideoTimecode } from "@pixerate/editor/video-editor";
81
- import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
149
+ import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
82
150
  var btn = "rounded border border-border px-2 py-0.5 text-xs disabled:opacity-40";
83
151
  function VideoTransport({ controller, editable = true, className = "" }) {
84
152
  const state = useVideoEditorState(controller);
85
153
  const { time, duration, playing, project, selection, readOnly } = state;
86
154
  const canEdit = editable && !readOnly;
87
- return /* @__PURE__ */ jsxs("div", { className: `flex flex-wrap items-center gap-1.5 text-xs ${className}`, "data-video-transport": "", children: [
88
- /* @__PURE__ */ jsx2("button", { type: "button", className: btn, onClick: () => controller.step(-1), "aria-label": "Previous frame", title: "Previous frame (\u2190)", "data-step-back": "", children: "\u25C0|" }),
89
- /* @__PURE__ */ jsx2("button", { type: "button", className: btn, onClick: () => controller.toggle(), "aria-label": playing ? "Pause" : "Play", title: "Play / pause (Space)", disabled: duration <= 0, "data-play": "", children: playing ? "Pause" : "Play" }),
90
- /* @__PURE__ */ jsx2("button", { type: "button", className: btn, onClick: () => controller.step(1), "aria-label": "Next frame", title: "Next frame (\u2192)", "data-step-forward": "", children: "|\u25B6" }),
91
- /* @__PURE__ */ jsxs("span", { className: "px-1 tabular-nums text-muted-foreground", "data-timecode": "", children: [
155
+ return /* @__PURE__ */ jsxs2("div", { className: `flex flex-wrap items-center gap-1.5 text-xs ${className}`, "data-video-transport": "", children: [
156
+ /* @__PURE__ */ jsx3("button", { type: "button", className: btn, onClick: () => controller.step(-1), "aria-label": "Previous frame", title: "Previous frame (\u2190)", "data-step-back": "", children: "\u25C0|" }),
157
+ /* @__PURE__ */ jsx3("button", { type: "button", className: btn, onClick: () => controller.toggle(), "aria-label": playing ? "Pause" : "Play", title: "Play / pause (Space)", disabled: duration <= 0, "data-play": "", children: playing ? "Pause" : "Play" }),
158
+ /* @__PURE__ */ jsx3("button", { type: "button", className: btn, onClick: () => controller.step(1), "aria-label": "Next frame", title: "Next frame (\u2192)", "data-step-forward": "", children: "|\u25B6" }),
159
+ /* @__PURE__ */ jsxs2("span", { className: "px-1 tabular-nums text-muted-foreground", "data-timecode": "", children: [
92
160
  formatVideoTimecode(time, project.fps),
93
161
  " / ",
94
162
  formatVideoTimecode(duration, project.fps)
95
163
  ] }),
96
- editable && /* @__PURE__ */ jsxs(Fragment, { children: [
97
- /* @__PURE__ */ jsx2("span", { className: "mx-1 h-4 w-px bg-border" }),
98
- /* @__PURE__ */ jsx2("button", { type: "button", className: btn, onClick: () => controller.splitAtPlayhead(), disabled: !canEdit, title: "Split at the playhead (S)", "data-split": "", children: "Split" }),
99
- /* @__PURE__ */ jsx2("button", { type: "button", className: btn, onClick: () => controller.deleteSelection(), disabled: !canEdit || !selection.length, title: "Delete the selected clips (Delete)", "data-delete": "", children: "Delete" }),
100
- /* @__PURE__ */ jsx2(
164
+ editable && /* @__PURE__ */ jsxs2(Fragment, { children: [
165
+ /* @__PURE__ */ jsx3("span", { className: "mx-1 h-4 w-px bg-border" }),
166
+ /* @__PURE__ */ jsx3("button", { type: "button", className: btn, onClick: () => controller.splitAtPlayhead(), disabled: !canEdit, title: "Split at the playhead (S)", "data-split": "", children: "Split" }),
167
+ /* @__PURE__ */ jsx3("button", { type: "button", className: btn, onClick: () => controller.deleteSelection(), disabled: !canEdit || !selection.length, title: "Delete the selected clips (Delete)", "data-delete": "", children: "Delete" }),
168
+ /* @__PURE__ */ jsx3(
101
169
  "button",
102
170
  {
103
171
  type: "button",
@@ -109,14 +177,14 @@ function VideoTransport({ controller, editable = true, className = "" }) {
109
177
  children: "Ripple delete"
110
178
  }
111
179
  ),
112
- /* @__PURE__ */ jsx2("button", { type: "button", className: btn, onClick: () => controller.undo(), disabled: !canEdit || !state.canUndo, title: state.canUndo ? `Undo ${state.undoLabel}` : "Nothing to undo", "data-undo": "", children: "Undo" }),
113
- /* @__PURE__ */ jsx2("button", { type: "button", className: btn, onClick: () => controller.redo(), disabled: !canEdit || !state.canRedo, title: state.canRedo ? `Redo ${state.redoLabel}` : "Nothing to redo", "data-redo": "", children: "Redo" })
180
+ /* @__PURE__ */ jsx3("button", { type: "button", className: btn, onClick: () => controller.undo(), disabled: !canEdit || !state.canUndo, title: state.canUndo ? `Undo ${state.undoLabel}` : "Nothing to undo", "data-undo": "", children: "Undo" }),
181
+ /* @__PURE__ */ jsx3("button", { type: "button", className: btn, onClick: () => controller.redo(), disabled: !canEdit || !state.canRedo, title: state.canRedo ? `Redo ${state.redoLabel}` : "Nothing to redo", "data-redo": "", children: "Redo" })
114
182
  ] })
115
183
  ] });
116
184
  }
117
185
 
118
186
  // src/video-editor/VideoTimeline.tsx
119
- import { useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useState, useSyncExternalStore as useSyncExternalStore2 } from "react";
187
+ import { useEffect as useEffect4, useMemo as useMemo2, useRef as useRef4, useState as useState2, useSyncExternalStore as useSyncExternalStore2 } from "react";
120
188
  import {
121
189
  planVideoClipDrag,
122
190
  videoDragLabel,
@@ -129,7 +197,7 @@ import {
129
197
  videoRulerTicks,
130
198
  formatVideoSeconds
131
199
  } from "@pixerate/editor/video-editor";
132
- import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
200
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
133
201
  var HEADER = 150;
134
202
  var RULER = 24;
135
203
  var ROW = 44;
@@ -143,25 +211,25 @@ function ClipWaveform({ controller, clip, asset, width, height }) {
143
211
  () => controller.audioPeaks.peek(asset),
144
212
  () => void 0
145
213
  );
146
- useEffect3(() => {
214
+ useEffect4(() => {
147
215
  controller.audioPeaks.load(asset).catch(() => {
148
216
  });
149
217
  }, [controller, asset]);
150
218
  const d = useMemo2(() => peaks ? videoClipWaveformPath(clip, peaks, width, height) : "", [peaks, clip, width, height]);
151
219
  if (!d) return null;
152
- return /* @__PURE__ */ jsx3("svg", { className: "pointer-events-none absolute inset-0", width, height, "aria-hidden": "true", "data-waveform": clip.id, children: /* @__PURE__ */ jsx3("path", { d, fill: "currentColor", fillOpacity: 0.45 }) });
220
+ return /* @__PURE__ */ jsx4("svg", { className: "pointer-events-none absolute inset-0", width, height, "aria-hidden": "true", "data-waveform": clip.id, children: /* @__PURE__ */ jsx4("path", { d, fill: "currentColor", fillOpacity: 0.45 }) });
153
221
  }
154
222
  var isField = (el) => el instanceof HTMLElement && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName);
155
223
  function VideoTimeline({ controller, pxPerSecond, onPxPerSecondChange, editable = true, waveforms = "audio", className = "" }) {
156
224
  const state = useVideoEditorState(controller);
157
225
  const { project, duration, time, selection, readOnly, error } = state;
158
- const [ownZoom, setOwnZoom] = useState(pxPerSecond ?? 100);
226
+ const [ownZoom, setOwnZoom] = useState2(pxPerSecond ?? 100);
159
227
  const px = onPxPerSecondChange ? pxPerSecond ?? 100 : ownZoom;
160
228
  const canEdit = editable && !readOnly;
161
- const [drag, setDrag] = useState(null);
162
- const dragRef = useRef3(null);
163
- const rows = useRef3(/* @__PURE__ */ new Map());
164
- const ruler = useRef3(null);
229
+ const [drag, setDrag] = useState2(null);
230
+ const dragRef = useRef4(null);
231
+ const rows = useRef4(/* @__PURE__ */ new Map());
232
+ const ruler = useRef4(null);
165
233
  const assets = videoAssetMap(project);
166
234
  const msToPx = (ms) => ms * px / 1e3;
167
235
  const span = Math.max(duration + 5e3, 1e4);
@@ -192,10 +260,10 @@ function VideoTimeline({ controller, pxPerSecond, onPxPerSecondChange, editable
192
260
  dragRef.current = next;
193
261
  setDrag(next);
194
262
  };
195
- const live = useRef3({ planFor, selection });
263
+ const live = useRef4({ planFor, selection });
196
264
  live.current = { planFor, selection };
197
- const stopDrag = useRef3(null);
198
- useEffect3(() => () => stopDrag.current?.(), []);
265
+ const stopDrag = useRef4(null);
266
+ useEffect4(() => () => stopDrag.current?.(), []);
199
267
  const onClipPointerDown = (e, clip, track) => {
200
268
  if (e.button !== void 0 && e.button !== 0) return;
201
269
  e.stopPropagation();
@@ -273,7 +341,7 @@ function VideoTimeline({ controller, pxPerSecond, onPxPerSecondChange, editable
273
341
  }).filter((x) => x !== null)
274
342
  )
275
343
  }));
276
- const toggle = (track, field2, label) => /* @__PURE__ */ jsx3(
344
+ const toggle = (track, field2, label) => /* @__PURE__ */ jsx4(
277
345
  "button",
278
346
  {
279
347
  type: "button",
@@ -287,12 +355,12 @@ function VideoTimeline({ controller, pxPerSecond, onPxPerSecondChange, editable
287
355
  children: field2 === "muted" ? "M" : field2 === "hidden" ? "H" : "L"
288
356
  }
289
357
  );
290
- return /* @__PURE__ */ jsxs2("div", { className: `flex flex-col gap-1 ${className}`, "data-video-timeline-wrapper": "", children: [
291
- !onPxPerSecondChange && /* @__PURE__ */ jsxs2("label", { className: "flex items-center gap-2 self-end text-xs text-muted-foreground", children: [
358
+ return /* @__PURE__ */ jsxs3("div", { className: `flex flex-col gap-1 ${className}`, "data-video-timeline-wrapper": "", children: [
359
+ !onPxPerSecondChange && /* @__PURE__ */ jsxs3("label", { className: "flex items-center gap-2 self-end text-xs text-muted-foreground", children: [
292
360
  "Zoom",
293
- /* @__PURE__ */ jsx3("input", { type: "range", min: MIN_ZOOM, max: MAX_ZOOM, step: 5, value: ownZoom, "aria-label": "Timeline zoom", onChange: (e) => setOwnZoom(Number(e.target.value)), "data-zoom": "" })
361
+ /* @__PURE__ */ jsx4("input", { type: "range", min: MIN_ZOOM, max: MAX_ZOOM, step: 5, value: ownZoom, "aria-label": "Timeline zoom", onChange: (e) => setOwnZoom(Number(e.target.value)), "data-zoom": "" })
294
362
  ] }),
295
- /* @__PURE__ */ jsx3(
363
+ /* @__PURE__ */ jsx4(
296
364
  "div",
297
365
  {
298
366
  className: "relative overflow-x-auto rounded border border-border outline-none focus-visible:ring-2 focus-visible:ring-rose-500/50",
@@ -301,10 +369,10 @@ function VideoTimeline({ controller, pxPerSecond, onPxPerSecondChange, editable
301
369
  "aria-label": "Video timeline",
302
370
  onKeyDown,
303
371
  "data-video-timeline": "",
304
- children: /* @__PURE__ */ jsxs2("div", { className: "relative", style: { width: HEADER + width }, children: [
305
- /* @__PURE__ */ jsxs2("div", { className: "flex", style: { height: RULER }, children: [
306
- /* @__PURE__ */ jsx3("div", { className: "sticky left-0 z-20 shrink-0 border-b border-r border-border bg-background", style: { width: HEADER } }),
307
- /* @__PURE__ */ jsxs2(
372
+ children: /* @__PURE__ */ jsxs3("div", { className: "relative", style: { width: HEADER + width }, children: [
373
+ /* @__PURE__ */ jsxs3("div", { className: "flex", style: { height: RULER }, children: [
374
+ /* @__PURE__ */ jsx4("div", { className: "sticky left-0 z-20 shrink-0 border-b border-r border-border bg-background", style: { width: HEADER } }),
375
+ /* @__PURE__ */ jsxs3(
308
376
  "div",
309
377
  {
310
378
  ref: ruler,
@@ -327,8 +395,8 @@ function VideoTimeline({ controller, pxPerSecond, onPxPerSecondChange, editable
327
395
  if (e.buttons & 1) seekAt(e.clientX);
328
396
  },
329
397
  children: [
330
- ticks.map((t) => /* @__PURE__ */ jsx3("div", { className: `absolute bottom-0 w-px ${t.major ? "h-2.5 bg-muted-foreground/70" : "h-1 bg-muted-foreground/40"}`, style: { left: t.x }, "data-tick": t.time, children: t.label && /* @__PURE__ */ jsx3("span", { className: "absolute -top-3.5 left-1 whitespace-nowrap tabular-nums", children: t.label }) }, t.time)),
331
- (project.markers ?? []).map((m) => /* @__PURE__ */ jsx3(
398
+ ticks.map((t) => /* @__PURE__ */ jsx4("div", { className: `absolute bottom-0 w-px ${t.major ? "h-2.5 bg-muted-foreground/70" : "h-1 bg-muted-foreground/40"}`, style: { left: t.x }, "data-tick": t.time, children: t.label && /* @__PURE__ */ jsx4("span", { className: "absolute -top-3.5 left-1 whitespace-nowrap tabular-nums", children: t.label }) }, t.time)),
399
+ (project.markers ?? []).map((m) => /* @__PURE__ */ jsx4(
332
400
  "div",
333
401
  {
334
402
  className: "absolute top-0 h-2.5 w-2.5 -translate-x-1/2 rotate-45",
@@ -342,19 +410,19 @@ function VideoTimeline({ controller, pxPerSecond, onPxPerSecondChange, editable
342
410
  }
343
411
  )
344
412
  ] }),
345
- placed.map(({ track, clips }) => /* @__PURE__ */ jsxs2("div", { className: "flex border-b border-border last:border-b-0", style: { height: ROW }, "data-track": track.id, children: [
346
- /* @__PURE__ */ jsxs2("div", { className: "sticky left-0 z-20 flex shrink-0 flex-col justify-center gap-0.5 border-r border-border bg-background px-2 text-xs", style: { width: HEADER }, "data-track-header": "", children: [
347
- /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-1", children: [
348
- /* @__PURE__ */ jsx3("span", { className: "truncate font-medium", children: track.name ?? track.id }),
349
- /* @__PURE__ */ jsx3("span", { className: "rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground", "data-track-kind": "", children: track.kind })
413
+ placed.map(({ track, clips }) => /* @__PURE__ */ jsxs3("div", { className: "flex border-b border-border last:border-b-0", style: { height: ROW }, "data-track": track.id, children: [
414
+ /* @__PURE__ */ jsxs3("div", { className: "sticky left-0 z-20 flex shrink-0 flex-col justify-center gap-0.5 border-r border-border bg-background px-2 text-xs", style: { width: HEADER }, "data-track-header": "", children: [
415
+ /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-1", children: [
416
+ /* @__PURE__ */ jsx4("span", { className: "truncate font-medium", children: track.name ?? track.id }),
417
+ /* @__PURE__ */ jsx4("span", { className: "rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground", "data-track-kind": "", children: track.kind })
350
418
  ] }),
351
- editable && /* @__PURE__ */ jsxs2("div", { className: "flex gap-1", children: [
419
+ editable && /* @__PURE__ */ jsxs3("div", { className: "flex gap-1", children: [
352
420
  toggle(track, "muted", "Mute"),
353
421
  track.kind === "video" && toggle(track, "hidden", "Hide"),
354
422
  toggle(track, "locked", "Lock")
355
423
  ] })
356
424
  ] }),
357
- /* @__PURE__ */ jsx3(
425
+ /* @__PURE__ */ jsx4(
358
426
  "div",
359
427
  {
360
428
  ref: (el) => {
@@ -371,7 +439,7 @@ function VideoTimeline({ controller, pxPerSecond, onPxPerSecondChange, editable
371
439
  const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
372
440
  const selected = selection.includes(clip.id);
373
441
  const label = videoClipLabel(clip, asset);
374
- return /* @__PURE__ */ jsxs2(
442
+ return /* @__PURE__ */ jsxs3(
375
443
  "div",
376
444
  {
377
445
  className: `absolute top-1 bottom-1 select-none touch-none overflow-hidden rounded text-[11px] text-white ${canEdit && !home.locked ? "cursor-grab" : "cursor-default"} ${dragging ? "opacity-80 shadow-lg" : ""}`,
@@ -390,15 +458,15 @@ function VideoTimeline({ controller, pxPerSecond, onPxPerSecondChange, editable
390
458
  "aria-selected": selected,
391
459
  onPointerDown: (e) => onClipPointerDown(e, clip, home),
392
460
  children: [
393
- videoClipShowsWaveform(clip, asset, waveforms) && /* @__PURE__ */ jsx3(ClipWaveform, { controller, clip, asset, width: Math.max(2, msToPx(clip.duration)), height: ROW - 8 }),
394
- /* @__PURE__ */ jsx3("span", { className: "pointer-events-none relative block truncate px-1.5 py-0.5 leading-4", children: label }),
395
- clip.transition && /* @__PURE__ */ jsxs2("span", { className: "pointer-events-none absolute left-1 bottom-0.5 rounded bg-black/40 px-1 text-[9px] leading-3", title: `${clip.transition.type} ${clip.transition.duration} ms`, "data-transition": clip.transition.type, children: [
461
+ videoClipShowsWaveform(clip, asset, waveforms) && /* @__PURE__ */ jsx4(ClipWaveform, { controller, clip, asset, width: Math.max(2, msToPx(clip.duration)), height: ROW - 8 }),
462
+ /* @__PURE__ */ jsx4("span", { className: "pointer-events-none relative block truncate px-1.5 py-0.5 leading-4", children: label }),
463
+ clip.transition && /* @__PURE__ */ jsxs3("span", { className: "pointer-events-none absolute left-1 bottom-0.5 rounded bg-black/40 px-1 text-[9px] leading-3", title: `${clip.transition.type} ${clip.transition.duration} ms`, "data-transition": clip.transition.type, children: [
396
464
  "\u29D3 ",
397
465
  clip.transition.type
398
466
  ] }),
399
- canEdit && !home.locked && /* @__PURE__ */ jsxs2(Fragment2, { children: [
400
- /* @__PURE__ */ jsx3("div", { className: "absolute inset-y-0 left-0 cursor-ew-resize bg-white/0 hover:bg-white/40", style: { width: HANDLE }, "data-handle": "start", "aria-hidden": "true" }),
401
- /* @__PURE__ */ jsx3("div", { className: "absolute inset-y-0 right-0 cursor-ew-resize bg-white/0 hover:bg-white/40", style: { width: HANDLE }, "data-handle": "end", "aria-hidden": "true" })
467
+ canEdit && !home.locked && /* @__PURE__ */ jsxs3(Fragment2, { children: [
468
+ /* @__PURE__ */ jsx4("div", { className: "absolute inset-y-0 left-0 cursor-ew-resize bg-white/0 hover:bg-white/40", style: { width: HANDLE }, "data-handle": "start", "aria-hidden": "true" }),
469
+ /* @__PURE__ */ jsx4("div", { className: "absolute inset-y-0 right-0 cursor-ew-resize bg-white/0 hover:bg-white/40", style: { width: HANDLE }, "data-handle": "end", "aria-hidden": "true" })
402
470
  ] })
403
471
  ]
404
472
  },
@@ -408,15 +476,16 @@ function VideoTimeline({ controller, pxPerSecond, onPxPerSecondChange, editable
408
476
  }
409
477
  )
410
478
  ] }, track.id)),
411
- /* @__PURE__ */ jsx3("div", { className: "pointer-events-none absolute top-0 bottom-0 w-0.5 -translate-x-1/2 bg-rose-500", style: { left: HEADER + msToPx(time), zIndex: 15 }, "data-playhead": "" })
479
+ /* @__PURE__ */ jsx4("div", { className: "pointer-events-none absolute top-0 bottom-0 w-0.5 -translate-x-1/2 bg-rose-500", style: { left: HEADER + msToPx(time), zIndex: 15 }, "data-playhead": "" })
412
480
  ] })
413
481
  }
414
482
  ),
415
- error && /* @__PURE__ */ jsx3("p", { className: "text-xs text-red-500", role: "alert", "data-edit-error": "", children: error })
483
+ error && /* @__PURE__ */ jsx4("p", { className: "text-xs text-red-500", role: "alert", "data-edit-error": "", children: error })
416
484
  ] });
417
485
  }
418
486
 
419
487
  // src/video-editor/ClipInspector.tsx
488
+ import { useState as useState3 } from "react";
420
489
  import {
421
490
  findVideoClip,
422
491
  formatVideoSeconds as formatVideoSeconds2,
@@ -425,7 +494,8 @@ import {
425
494
  normalizeVideoGrade,
426
495
  VIDEO_GRADE_CONTROLS,
427
496
  VIDEO_GRADE_PRESET_NAMES,
428
- videoGradePreset,
497
+ videoGradeLook,
498
+ videoLutEdits,
429
499
  VIDEO_BLEND_MODE_LABELS,
430
500
  VIDEO_BLEND_MODES,
431
501
  VIDEO_FADE_CURVE_LABELS,
@@ -442,7 +512,7 @@ import {
442
512
  videoKeyframeToggleEdit,
443
513
  videoKeyframeValueEdit
444
514
  } from "@pixerate/editor/video-editor";
445
- import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
515
+ import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
446
516
  var btn2 = "rounded border border-border px-1.5 py-0.5 text-[11px] disabled:opacity-40";
447
517
  var field = "w-full rounded border border-border bg-transparent px-1 py-0.5 text-xs disabled:opacity-50";
448
518
  var DEFAULT_TRANSITION = 500;
@@ -456,7 +526,7 @@ function Commit({
456
526
  if (raw !== value) onCommit(raw);
457
527
  };
458
528
  if (multiline)
459
- return /* @__PURE__ */ jsx4(
529
+ return /* @__PURE__ */ jsx5(
460
530
  "textarea",
461
531
  {
462
532
  className: field,
@@ -468,11 +538,64 @@ function Commit({
468
538
  },
469
539
  value
470
540
  );
471
- return /* @__PURE__ */ jsx4("input", { className: field, ...rest, defaultValue: value, onKeyDown: (e) => e.key === "Enter" && e.currentTarget.blur(), onBlur: (e) => commit(e.currentTarget.value) }, value);
541
+ return /* @__PURE__ */ jsx5("input", { className: field, ...rest, defaultValue: value, onKeyDown: (e) => e.key === "Enter" && e.currentTarget.blur(), onBlur: (e) => commit(e.currentTarget.value) }, value);
542
+ }
543
+ function LutControls({ controller, clip, disabled }) {
544
+ const [message, setMessage] = useState3(null);
545
+ const lut = clip.grade?.lut;
546
+ const apply = (setting, label) => {
547
+ const r = controller.apply(videoLutEdits(controller.getProject(), [clip.id], setting), label);
548
+ setMessage(r.ok ? null : r.error);
549
+ };
550
+ const choose = async (src) => {
551
+ if (!src) return lut && apply(null, "Remove LUT");
552
+ try {
553
+ const table = await controller.luts.load(src);
554
+ apply({ src, ...lut?.strength !== void 0 ? { strength: lut.strength } : {}, ...table.title ? { name: table.title } : {} }, "Apply LUT");
555
+ } catch (e) {
556
+ setMessage(e instanceof Error ? e.message : String(e));
557
+ }
558
+ };
559
+ const readFile = async (file) => {
560
+ if (!file) return;
561
+ try {
562
+ const table = controller.luts.set(file.name, await file.text());
563
+ apply({ src: file.name, ...lut?.strength !== void 0 ? { strength: lut.strength } : {}, ...table.title ? { name: table.title } : {} }, "Apply LUT");
564
+ } catch (e) {
565
+ setMessage(`${file.name}: ${e instanceof Error ? e.message : String(e)}`);
566
+ }
567
+ };
568
+ return /* @__PURE__ */ jsxs4(Fragment3, { children: [
569
+ /* @__PURE__ */ jsx5(Row, { label: "LUT", name: "grade.lut", children: /* @__PURE__ */ jsxs4("div", { className: "flex min-w-0 gap-1", children: [
570
+ /* @__PURE__ */ jsx5(Commit, { value: lut?.src ?? "", placeholder: "look.cube", "aria-label": "LUT file", disabled, onCommit: (raw) => void choose(raw.trim()) }),
571
+ /* @__PURE__ */ jsxs4("label", { className: `${btn2} cursor-pointer whitespace-nowrap`, title: "Load a .cube file", children: [
572
+ "Load",
573
+ /* @__PURE__ */ jsx5("input", { type: "file", accept: ".cube", className: "sr-only", disabled, "aria-label": "Load a .cube LUT", onChange: (e) => void readFile(e.currentTarget.files?.[0]) })
574
+ ] }),
575
+ lut && /* @__PURE__ */ jsx5("button", { type: "button", className: btn2, disabled, "aria-label": "Remove LUT", onClick: () => apply(null, "Remove LUT"), children: "\u2715" })
576
+ ] }) }),
577
+ lut && /* @__PURE__ */ jsx5(Row, { label: "LUT strength", name: "grade.lut.strength", children: /* @__PURE__ */ jsx5(
578
+ Commit,
579
+ {
580
+ type: "number",
581
+ min: 0,
582
+ max: 1,
583
+ step: 0.05,
584
+ value: String(lut.strength ?? 1),
585
+ "aria-label": "LUT strength",
586
+ disabled,
587
+ onCommit: (raw) => {
588
+ const n = Number(raw);
589
+ if (raw.trim() && Number.isFinite(n)) apply(n > 0 ? { ...lut, strength: Math.min(1, n) } : null, n > 0 ? "Change LUT strength" : "Remove LUT");
590
+ }
591
+ }
592
+ ) }),
593
+ message && /* @__PURE__ */ jsx5("p", { className: "text-xs text-red-500", role: "alert", "data-lut-error": "", children: message })
594
+ ] });
472
595
  }
473
596
  function Row({ label, name, children }) {
474
- return /* @__PURE__ */ jsxs3("label", { className: "grid grid-cols-[5.5rem_minmax(0,1fr)] items-center gap-2 text-xs", "data-field": name, children: [
475
- /* @__PURE__ */ jsx4("span", { className: "text-muted-foreground", children: label }),
597
+ return /* @__PURE__ */ jsxs4("label", { className: "grid grid-cols-[5.5rem_minmax(0,1fr)] items-center gap-2 text-xs", "data-field": name, children: [
598
+ /* @__PURE__ */ jsx5("span", { className: "text-muted-foreground", children: label }),
476
599
  children
477
600
  ] });
478
601
  }
@@ -480,7 +603,7 @@ function ClipInspector({ controller, className = "" }) {
480
603
  const state = useVideoEditorState(controller);
481
604
  const { project, selection, readOnly, time } = state;
482
605
  if (selection.length !== 1)
483
- return /* @__PURE__ */ jsx4("p", { className: `text-xs text-muted-foreground ${className}`, "data-clip-inspector": "", children: selection.length ? `${selection.length} clips selected. Select one clip to edit it.` : "Select a clip on the timeline to edit it." });
606
+ return /* @__PURE__ */ jsx5("p", { className: `text-xs text-muted-foreground ${className}`, "data-clip-inspector": "", children: selection.length ? `${selection.length} clips selected. Select one clip to edit it.` : "Select a clip on the timeline to edit it." });
484
607
  const at = findVideoClip(project, selection[0]);
485
608
  if (!at) return null;
486
609
  const clip = at.clip;
@@ -497,24 +620,24 @@ function ClipInspector({ controller, className = "" }) {
497
620
  };
498
621
  const text = (key, label) => {
499
622
  const value = clip[key];
500
- return /* @__PURE__ */ jsx4(Commit, { value: value === void 0 ? "" : String(value), "aria-label": label ?? key, disabled, onCommit: (raw) => set(key, raw.trim() || void 0, `Change ${label ?? key}`) });
623
+ return /* @__PURE__ */ jsx5(Commit, { value: value === void 0 ? "" : String(value), "aria-label": label ?? key, disabled, onCommit: (raw) => set(key, raw.trim() || void 0, `Change ${label ?? key}`) });
501
624
  };
502
625
  const number = (key, label, attrs = {}) => {
503
626
  const value = clip[key];
504
- return /* @__PURE__ */ jsx4(Commit, { type: "number", value: value === void 0 ? "" : String(value), "aria-label": label, disabled, onCommit: (raw) => num(key, raw, `Change ${label.toLowerCase()}`), ...attrs });
627
+ return /* @__PURE__ */ jsx5(Commit, { type: "number", value: value === void 0 ? "" : String(value), "aria-label": label, disabled, onCommit: (raw) => num(key, raw, `Change ${label.toLowerCase()}`), ...attrs });
505
628
  };
506
629
  const transition = clip.transition;
507
- return /* @__PURE__ */ jsxs3("div", { className: `flex flex-col gap-1.5 ${className}`, "data-clip-inspector": "", "data-clip-id": clip.id, children: [
508
- /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-2 text-xs", children: [
509
- /* @__PURE__ */ jsx4("span", { className: "font-medium", children: clip.type === "media" ? "Media clip" : clip.type === "text" ? "Title" : "Colour clip" }),
510
- /* @__PURE__ */ jsx4("code", { className: "text-muted-foreground", children: clip.id }),
511
- at.track.locked && /* @__PURE__ */ jsx4("span", { className: "text-amber-500", children: "locked track" })
630
+ return /* @__PURE__ */ jsxs4("div", { className: `flex flex-col gap-1.5 ${className}`, "data-clip-inspector": "", "data-clip-id": clip.id, children: [
631
+ /* @__PURE__ */ jsxs4("div", { className: "flex items-center gap-2 text-xs", children: [
632
+ /* @__PURE__ */ jsx5("span", { className: "font-medium", children: clip.type === "media" ? "Media clip" : clip.type === "text" ? "Title" : "Colour clip" }),
633
+ /* @__PURE__ */ jsx5("code", { className: "text-muted-foreground", children: clip.id }),
634
+ at.track.locked && /* @__PURE__ */ jsx5("span", { className: "text-amber-500", children: "locked track" })
512
635
  ] }),
513
- /* @__PURE__ */ jsx4(Row, { label: "Name", name: "name", children: text("name", "Name") }),
514
- /* @__PURE__ */ jsx4(Row, { label: "Start", name: "start", children: /* @__PURE__ */ jsx4("span", { className: "tabular-nums text-xs", children: formatVideoSeconds2(clip.start) }) }),
515
- /* @__PURE__ */ jsx4(Row, { label: "Duration", name: "duration", children: /* @__PURE__ */ jsx4("span", { className: "tabular-nums text-xs", children: formatVideoSeconds2(clip.duration) }) }),
516
- videoKeyframeRows(project, clip.id, time).map((row) => /* @__PURE__ */ jsx4(Row, { label: row.label, name: row.property, children: /* @__PURE__ */ jsxs3("span", { className: "flex min-w-0 items-center gap-1", "data-keyframe-row": row.property, children: [
517
- /* @__PURE__ */ jsx4(
636
+ /* @__PURE__ */ jsx5(Row, { label: "Name", name: "name", children: text("name", "Name") }),
637
+ /* @__PURE__ */ jsx5(Row, { label: "Start", name: "start", children: /* @__PURE__ */ jsx5("span", { className: "tabular-nums text-xs", children: formatVideoSeconds2(clip.start) }) }),
638
+ /* @__PURE__ */ jsx5(Row, { label: "Duration", name: "duration", children: /* @__PURE__ */ jsx5("span", { className: "tabular-nums text-xs", children: formatVideoSeconds2(clip.duration) }) }),
639
+ videoKeyframeRows(project, clip.id, time).map((row) => /* @__PURE__ */ jsx5(Row, { label: row.label, name: row.property, children: /* @__PURE__ */ jsxs4("span", { className: "flex min-w-0 items-center gap-1", "data-keyframe-row": row.property, children: [
640
+ /* @__PURE__ */ jsx5(
518
641
  Commit,
519
642
  {
520
643
  type: "number",
@@ -533,7 +656,7 @@ function ClipInspector({ controller, className = "" }) {
533
656
  }
534
657
  }
535
658
  ),
536
- /* @__PURE__ */ jsx4(
659
+ /* @__PURE__ */ jsx5(
537
660
  "button",
538
661
  {
539
662
  type: "button",
@@ -550,11 +673,11 @@ function ClipInspector({ controller, className = "" }) {
550
673
  children: row.onKey ? "\u25C6" : "\u25C7"
551
674
  }
552
675
  ),
553
- row.keyed && /* @__PURE__ */ jsx4("span", { className: "shrink-0 text-[10px] tabular-nums text-muted-foreground", "data-keyframe-count": row.property, children: row.keys })
676
+ row.keyed && /* @__PURE__ */ jsx5("span", { className: "shrink-0 text-[10px] tabular-nums text-muted-foreground", "data-keyframe-count": row.property, children: row.keys })
554
677
  ] }) }, row.property)),
555
- at.track.kind === "video" && /* @__PURE__ */ jsx4(Row, { label: "Blend", name: "blend", children: /* @__PURE__ */ jsx4("select", { className: field, value: clip.blend ?? "normal", disabled, "aria-label": "Blend mode", onChange: (e) => set("blend", e.target.value === "normal" ? void 0 : e.target.value, "Change blend mode"), children: VIDEO_BLEND_MODES.map((m) => /* @__PURE__ */ jsx4("option", { value: m, children: VIDEO_BLEND_MODE_LABELS[m] }, m)) }) }),
556
- at.track.kind === "video" && /* @__PURE__ */ jsxs3(Fragment3, { children: [
557
- /* @__PURE__ */ jsx4(Row, { label: "Look", name: "gradePreset", children: /* @__PURE__ */ jsxs3(
678
+ at.track.kind === "video" && /* @__PURE__ */ jsx5(Row, { label: "Blend", name: "blend", children: /* @__PURE__ */ jsx5("select", { className: field, value: clip.blend ?? "normal", disabled, "aria-label": "Blend mode", onChange: (e) => set("blend", e.target.value === "normal" ? void 0 : e.target.value, "Change blend mode"), children: VIDEO_BLEND_MODES.map((m) => /* @__PURE__ */ jsx5("option", { value: m, children: VIDEO_BLEND_MODE_LABELS[m] }, m)) }) }),
679
+ at.track.kind === "video" && /* @__PURE__ */ jsxs4(Fragment3, { children: [
680
+ /* @__PURE__ */ jsx5(Row, { label: "Look", name: "gradePreset", children: /* @__PURE__ */ jsxs4(
558
681
  "select",
559
682
  {
560
683
  className: field,
@@ -562,16 +685,16 @@ function ClipInspector({ controller, className = "" }) {
562
685
  disabled,
563
686
  "aria-label": "Apply a look",
564
687
  onChange: (e) => {
565
- const grade = videoGradePreset(e.target.value);
688
+ const grade = videoGradeLook(clip.grade, e.target.value);
566
689
  if (grade) controller.apply({ op: "update_clip", clip: clip.id, set: { grade } }, `Apply ${e.target.value} look`);
567
690
  },
568
691
  children: [
569
- /* @__PURE__ */ jsx4("option", { value: "", children: clip.grade ? "Graded" : "None" }),
570
- VIDEO_GRADE_PRESET_NAMES.map((name) => /* @__PURE__ */ jsx4("option", { value: name, children: name.replace(/-/g, " ") }, name))
692
+ /* @__PURE__ */ jsx5("option", { value: "", children: clip.grade ? "Graded" : "None" }),
693
+ VIDEO_GRADE_PRESET_NAMES.map((name) => /* @__PURE__ */ jsx5("option", { value: name, children: name.replace(/-/g, " ") }, name))
571
694
  ]
572
695
  }
573
696
  ) }),
574
- VIDEO_GRADE_CONTROLS.map((c) => /* @__PURE__ */ jsx4(Row, { label: c.label, name: `grade.${c.key}`, children: /* @__PURE__ */ jsx4(
697
+ VIDEO_GRADE_CONTROLS.map((c) => /* @__PURE__ */ jsx5(Row, { label: c.label, name: `grade.${c.key}`, children: /* @__PURE__ */ jsx5(
575
698
  Commit,
576
699
  {
577
700
  type: "number",
@@ -589,9 +712,10 @@ function ClipInspector({ controller, className = "" }) {
589
712
  set("grade", grade, `Change ${c.label.toLowerCase()}`);
590
713
  }
591
714
  }
592
- ) }, c.key))
715
+ ) }, c.key)),
716
+ /* @__PURE__ */ jsx5(LutControls, { controller, clip, disabled })
593
717
  ] }),
594
- at.track.kind === "video" && ["animateIn", "animateOut"].map((key) => /* @__PURE__ */ jsx4(Row, { label: key === "animateIn" ? "Animate in" : "Animate out", name: key, children: /* @__PURE__ */ jsxs3(
718
+ at.track.kind === "video" && ["animateIn", "animateOut"].map((key) => /* @__PURE__ */ jsx5(Row, { label: key === "animateIn" ? "Animate in" : "Animate out", name: key, children: /* @__PURE__ */ jsxs4(
595
719
  "select",
596
720
  {
597
721
  className: field,
@@ -603,20 +727,20 @@ function ClipInspector({ controller, className = "" }) {
603
727
  set(key, effect ? { ...clip[key], effect } : void 0, key === "animateIn" ? "Change entrance" : "Change exit");
604
728
  },
605
729
  children: [
606
- /* @__PURE__ */ jsx4("option", { value: "", children: "None" }),
607
- VIDEO_CLIP_ANIMATION_EFFECTS.map((effect) => /* @__PURE__ */ jsx4("option", { value: effect, children: VIDEO_CLIP_ANIMATION_LABELS[effect] }, effect))
730
+ /* @__PURE__ */ jsx5("option", { value: "", children: "None" }),
731
+ VIDEO_CLIP_ANIMATION_EFFECTS.map((effect) => /* @__PURE__ */ jsx5("option", { value: effect, children: VIDEO_CLIP_ANIMATION_LABELS[effect] }, effect))
608
732
  ]
609
733
  }
610
734
  ) }, key)),
611
- /* @__PURE__ */ jsx4(Row, { label: "Fade in (ms)", name: "fadeIn", children: number("fadeIn", "Fade in", { min: 0, step: 100 }) }),
612
- /* @__PURE__ */ jsx4(Row, { label: "Fade out (ms)", name: "fadeOut", children: number("fadeOut", "Fade out", { min: 0, step: 100 }) }),
613
- clip.type === "media" && /* @__PURE__ */ jsxs3(Fragment3, { children: [
614
- /* @__PURE__ */ jsx4(Row, { label: "Speed", name: "speed", children: clip.timeRemap ? /* @__PURE__ */ jsxs3("span", { className: "flex items-center gap-1", "data-ramped": "", children: [
615
- /* @__PURE__ */ jsx4("span", { className: "text-muted-foreground", children: "ramped" }),
616
- /* @__PURE__ */ jsx4("button", { type: "button", className: btn2, disabled, onClick: () => controller.apply({ op: "set_time_remap", clip: clip.id, keys: null, ripple: true }, "Remove speed ramp"), children: "Remove ramp" })
735
+ /* @__PURE__ */ jsx5(Row, { label: "Fade in (ms)", name: "fadeIn", children: number("fadeIn", "Fade in", { min: 0, step: 100 }) }),
736
+ /* @__PURE__ */ jsx5(Row, { label: "Fade out (ms)", name: "fadeOut", children: number("fadeOut", "Fade out", { min: 0, step: 100 }) }),
737
+ clip.type === "media" && /* @__PURE__ */ jsxs4(Fragment3, { children: [
738
+ /* @__PURE__ */ jsx5(Row, { label: "Speed", name: "speed", children: clip.timeRemap ? /* @__PURE__ */ jsxs4("span", { className: "flex items-center gap-1", "data-ramped": "", children: [
739
+ /* @__PURE__ */ jsx5("span", { className: "text-muted-foreground", children: "ramped" }),
740
+ /* @__PURE__ */ jsx5("button", { type: "button", className: btn2, disabled, onClick: () => controller.apply({ op: "set_time_remap", clip: clip.id, keys: null, ripple: true }, "Remove speed ramp"), children: "Remove ramp" })
617
741
  ] }) : number("speed", "Speed", { min: 0.1, step: 0.25 }) }),
618
- project.assets.find((a) => a.id === clip.asset)?.kind !== "image" && /* @__PURE__ */ jsx4(Row, { label: "Retime", name: "retime", children: /* @__PURE__ */ jsxs3("span", { className: "flex flex-wrap gap-1", children: [
619
- /* @__PURE__ */ jsx4(
742
+ project.assets.find((a) => a.id === clip.asset)?.kind !== "image" && /* @__PURE__ */ jsx5(Row, { label: "Retime", name: "retime", children: /* @__PURE__ */ jsxs4("span", { className: "flex flex-wrap gap-1", children: [
743
+ /* @__PURE__ */ jsx5(
620
744
  "button",
621
745
  {
622
746
  type: "button",
@@ -627,11 +751,11 @@ function ClipInspector({ controller, className = "" }) {
627
751
  children: "Freeze 1 s here"
628
752
  }
629
753
  ),
630
- /* @__PURE__ */ jsx4("button", { type: "button", className: btn2, disabled, title: "Normal speed, then a glide into 0.3\xD7 slow motion", onClick: () => controller.apply(videoSpeedRampEdit(clip, videoSlowMotionRamp(clip.duration)), "Speed ramp"), children: "Slow-mo ramp" })
754
+ /* @__PURE__ */ jsx5("button", { type: "button", className: btn2, disabled, title: "Normal speed, then a glide into 0.3\xD7 slow motion", onClick: () => controller.apply(videoSpeedRampEdit(clip, videoSlowMotionRamp(clip.duration)), "Speed ramp"), children: "Slow-mo ramp" })
631
755
  ] }) }),
632
- /* @__PURE__ */ jsx4(Row, { label: "Muted", name: "muted", children: /* @__PURE__ */ jsx4("input", { type: "checkbox", className: "justify-self-start", checked: !!clip.muted, disabled, "aria-label": "Muted", onChange: (e) => set("muted", e.target.checked || void 0, e.target.checked ? "Mute clip" : "Unmute clip") }) }),
756
+ /* @__PURE__ */ jsx5(Row, { label: "Muted", name: "muted", children: /* @__PURE__ */ jsx5("input", { type: "checkbox", className: "justify-self-start", checked: !!clip.muted, disabled, "aria-label": "Muted", onChange: (e) => set("muted", e.target.checked || void 0, e.target.checked ? "Mute clip" : "Unmute clip") }) }),
633
757
  ["fadeInCurve", "fadeOutCurve"].map(
634
- (key) => (key === "fadeInCurve" ? clip.fadeIn : clip.fadeOut) ? /* @__PURE__ */ jsx4(Row, { label: key === "fadeInCurve" ? "Fade in sound" : "Fade out sound", name: key, children: /* @__PURE__ */ jsx4(
758
+ (key) => (key === "fadeInCurve" ? clip.fadeIn : clip.fadeOut) ? /* @__PURE__ */ jsx5(Row, { label: key === "fadeInCurve" ? "Fade in sound" : "Fade out sound", name: key, children: /* @__PURE__ */ jsx5(
635
759
  "select",
636
760
  {
637
761
  className: field,
@@ -642,19 +766,19 @@ function ClipInspector({ controller, className = "" }) {
642
766
  const curve = e.target.value;
643
767
  set(key, curve === "linear" ? void 0 : curve, key === "fadeInCurve" ? "Change fade in curve" : "Change fade out curve");
644
768
  },
645
- children: VIDEO_FADE_CURVES.map((c) => /* @__PURE__ */ jsx4("option", { value: c, children: VIDEO_FADE_CURVE_LABELS[c] }, c))
769
+ children: VIDEO_FADE_CURVES.map((c) => /* @__PURE__ */ jsx5("option", { value: c, children: VIDEO_FADE_CURVE_LABELS[c] }, c))
646
770
  }
647
771
  ) }, key) : null
648
772
  )
649
773
  ] }),
650
- clip.type === "text" && /* @__PURE__ */ jsxs3(Fragment3, { children: [
651
- /* @__PURE__ */ jsx4(Row, { label: "Text", name: "text", children: /* @__PURE__ */ jsx4(Commit, { multiline: true, value: clip.text, "aria-label": "Text", disabled, onCommit: (raw) => set("text", raw, "Change text") }) }),
652
- /* @__PURE__ */ jsx4(Row, { label: "Colour", name: "color", children: text("color", "Colour") }),
653
- /* @__PURE__ */ jsx4(Row, { label: "Font size", name: "fontSize", children: number("fontSize", "Font size", { min: 1, step: 2 }) }),
654
- /* @__PURE__ */ jsx4(Row, { label: "Background", name: "background", children: text("background", "Background") })
774
+ clip.type === "text" && /* @__PURE__ */ jsxs4(Fragment3, { children: [
775
+ /* @__PURE__ */ jsx5(Row, { label: "Text", name: "text", children: /* @__PURE__ */ jsx5(Commit, { multiline: true, value: clip.text, "aria-label": "Text", disabled, onCommit: (raw) => set("text", raw, "Change text") }) }),
776
+ /* @__PURE__ */ jsx5(Row, { label: "Colour", name: "color", children: text("color", "Colour") }),
777
+ /* @__PURE__ */ jsx5(Row, { label: "Font size", name: "fontSize", children: number("fontSize", "Font size", { min: 1, step: 2 }) }),
778
+ /* @__PURE__ */ jsx5(Row, { label: "Background", name: "background", children: text("background", "Background") })
655
779
  ] }),
656
- clip.type === "color" && /* @__PURE__ */ jsx4(Row, { label: "Colour", name: "color", children: /* @__PURE__ */ jsx4(Commit, { value: clip.color, "aria-label": "Colour", disabled, onCommit: (raw) => raw.trim() && set("color", raw.trim(), "Change colour") }) }),
657
- /* @__PURE__ */ jsx4(Row, { label: "Transition in", name: "transition", children: /* @__PURE__ */ jsxs3(
780
+ clip.type === "color" && /* @__PURE__ */ jsx5(Row, { label: "Colour", name: "color", children: /* @__PURE__ */ jsx5(Commit, { value: clip.color, "aria-label": "Colour", disabled, onCommit: (raw) => raw.trim() && set("color", raw.trim(), "Change colour") }) }),
781
+ /* @__PURE__ */ jsx5(Row, { label: "Transition in", name: "transition", children: /* @__PURE__ */ jsxs4(
658
782
  "select",
659
783
  {
660
784
  className: field,
@@ -669,12 +793,12 @@ function ClipInspector({ controller, className = "" }) {
669
793
  );
670
794
  },
671
795
  children: [
672
- /* @__PURE__ */ jsx4("option", { value: "", children: "None" }),
673
- VIDEO_TRANSITION_TYPES.map((t) => /* @__PURE__ */ jsx4("option", { value: t, children: VIDEO_TRANSITION_LABELS[t] }, t))
796
+ /* @__PURE__ */ jsx5("option", { value: "", children: "None" }),
797
+ VIDEO_TRANSITION_TYPES.map((t) => /* @__PURE__ */ jsx5("option", { value: t, children: VIDEO_TRANSITION_LABELS[t] }, t))
674
798
  ]
675
799
  }
676
800
  ) }),
677
- transition && /* @__PURE__ */ jsx4(Row, { label: "Length (ms)", name: "transitionDuration", children: /* @__PURE__ */ jsx4(
801
+ transition && /* @__PURE__ */ jsx5(Row, { label: "Length (ms)", name: "transitionDuration", children: /* @__PURE__ */ jsx5(
678
802
  Commit,
679
803
  {
680
804
  type: "number",
@@ -689,7 +813,7 @@ function ClipInspector({ controller, className = "" }) {
689
813
  }
690
814
  }
691
815
  ) }),
692
- transition && videoTransitionHasDirection(transition.type) && /* @__PURE__ */ jsx4(Row, { label: "Direction", name: "transitionDirection", children: /* @__PURE__ */ jsx4(
816
+ transition && videoTransitionHasDirection(transition.type) && /* @__PURE__ */ jsx5(Row, { label: "Direction", name: "transitionDirection", children: /* @__PURE__ */ jsx5(
693
817
  "select",
694
818
  {
695
819
  className: field,
@@ -697,10 +821,10 @@ function ClipInspector({ controller, className = "" }) {
697
821
  disabled,
698
822
  "aria-label": "Transition direction",
699
823
  onChange: (e) => controller.apply({ op: "set_transition", clip: clip.id, transition: { ...transition, direction: e.target.value } }, "Change transition direction"),
700
- children: VIDEO_WIPE_DIRECTIONS.map((d) => /* @__PURE__ */ jsx4("option", { value: d, children: d }, d))
824
+ children: VIDEO_WIPE_DIRECTIONS.map((d) => /* @__PURE__ */ jsx5("option", { value: d, children: d }, d))
701
825
  }
702
826
  ) }),
703
- transition && /* @__PURE__ */ jsx4(Row, { label: "Transition sound", name: "transitionAudioCurve", children: /* @__PURE__ */ jsx4(
827
+ transition && /* @__PURE__ */ jsx5(Row, { label: "Transition sound", name: "transitionAudioCurve", children: /* @__PURE__ */ jsx5(
704
828
  "select",
705
829
  {
706
830
  className: field,
@@ -713,30 +837,30 @@ function ClipInspector({ controller, className = "" }) {
713
837
  const next = curve === videoTransitionAudioCurve(rest) ? rest : { ...rest, audioCurve: curve };
714
838
  controller.apply({ op: "set_transition", clip: clip.id, transition: next }, "Change transition sound");
715
839
  },
716
- children: VIDEO_FADE_CURVES.map((c) => /* @__PURE__ */ jsx4("option", { value: c, children: VIDEO_FADE_CURVE_LABELS[c] }, c))
840
+ children: VIDEO_FADE_CURVES.map((c) => /* @__PURE__ */ jsx5("option", { value: c, children: VIDEO_FADE_CURVE_LABELS[c] }, c))
717
841
  }
718
842
  ) })
719
843
  ] });
720
844
  }
721
845
 
722
846
  // src/video-editor/MediaBin.tsx
723
- import { useState as useState2 } from "react";
847
+ import { useState as useState4 } from "react";
724
848
  import {
725
849
  formatVideoSeconds as formatVideoSeconds3,
726
850
  probeVideoMedia,
727
851
  videoAssetDropEdit,
728
852
  videoAssetKindOf
729
853
  } from "@pixerate/editor/video-editor";
730
- import { Fragment as Fragment4, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
854
+ import { Fragment as Fragment4, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
731
855
  var btn3 = "rounded border border-border px-2 py-0.5 text-xs disabled:opacity-40";
732
856
  function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMedia, className = "" }) {
733
857
  const { project, readOnly } = useVideoEditorState(controller);
734
858
  const canEdit = editable && !readOnly;
735
- const [url, setUrl] = useState2("");
736
- const [urlKind, setUrlKind] = useState2("");
737
- const [busy, setBusy] = useState2(0);
738
- const [problem, setProblem] = useState2(null);
739
- const [over, setOver] = useState2(false);
859
+ const [url, setUrl] = useState4("");
860
+ const [urlKind, setUrlKind] = useState4("");
861
+ const [busy, setBusy] = useState4(0);
862
+ const [problem, setProblem] = useState4(null);
863
+ const [over, setOver] = useState4(false);
740
864
  const addSource = async (src, kind, name) => {
741
865
  setBusy((n) => n + 1);
742
866
  try {
@@ -784,7 +908,7 @@ function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMe
784
908
  if (added) controller.select([added]);
785
909
  }
786
910
  };
787
- return /* @__PURE__ */ jsxs4(
911
+ return /* @__PURE__ */ jsxs5(
788
912
  "div",
789
913
  {
790
914
  className: `flex flex-col gap-2 rounded ${over ? "ring-2 ring-rose-500/60" : ""} ${className}`,
@@ -802,20 +926,20 @@ function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMe
802
926
  void addFiles(e.dataTransfer.files);
803
927
  },
804
928
  children: [
805
- /* @__PURE__ */ jsx5("div", { className: "text-xs font-medium", children: "Media" }),
806
- project.assets.length ? /* @__PURE__ */ jsx5("ul", { className: "flex flex-col gap-1", "aria-label": "Media assets", children: project.assets.map((asset) => /* @__PURE__ */ jsxs4("li", { className: "flex items-center gap-2 text-xs", "data-asset": asset.id, children: [
807
- /* @__PURE__ */ jsx5("span", { className: "rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground", "data-asset-kind": "", children: asset.kind }),
808
- /* @__PURE__ */ jsx5("span", { className: "min-w-0 flex-1 truncate", title: asset.src, children: asset.name ?? asset.id }),
809
- asset.duration !== void 0 && /* @__PURE__ */ jsx5("span", { className: "tabular-nums text-muted-foreground", children: formatVideoSeconds3(asset.duration) }),
810
- editable && /* @__PURE__ */ jsx5("button", { type: "button", className: btn3, disabled: !canEdit, onClick: () => addToTimeline(asset), "data-add-to-timeline": "", children: "Add to timeline" })
811
- ] }, asset.id)) }) : /* @__PURE__ */ jsxs4("p", { className: "text-xs text-muted-foreground", children: [
929
+ /* @__PURE__ */ jsx6("div", { className: "text-xs font-medium", children: "Media" }),
930
+ project.assets.length ? /* @__PURE__ */ jsx6("ul", { className: "flex flex-col gap-1", "aria-label": "Media assets", children: project.assets.map((asset) => /* @__PURE__ */ jsxs5("li", { className: "flex items-center gap-2 text-xs", "data-asset": asset.id, children: [
931
+ /* @__PURE__ */ jsx6("span", { className: "rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground", "data-asset-kind": "", children: asset.kind }),
932
+ /* @__PURE__ */ jsx6("span", { className: "min-w-0 flex-1 truncate", title: asset.src, children: asset.name ?? asset.id }),
933
+ asset.duration !== void 0 && /* @__PURE__ */ jsx6("span", { className: "tabular-nums text-muted-foreground", children: formatVideoSeconds3(asset.duration) }),
934
+ editable && /* @__PURE__ */ jsx6("button", { type: "button", className: btn3, disabled: !canEdit, onClick: () => addToTimeline(asset), "data-add-to-timeline": "", children: "Add to timeline" })
935
+ ] }, asset.id)) }) : /* @__PURE__ */ jsxs5("p", { className: "text-xs text-muted-foreground", children: [
812
936
  "No media yet.",
813
937
  editable ? " Choose or drop files, or add a URL." : ""
814
938
  ] }),
815
- editable && /* @__PURE__ */ jsxs4(Fragment4, { children: [
816
- /* @__PURE__ */ jsxs4("label", { className: "flex flex-col gap-1 rounded border border-dashed border-border p-2 text-xs text-muted-foreground", children: [
939
+ editable && /* @__PURE__ */ jsxs5(Fragment4, { children: [
940
+ /* @__PURE__ */ jsxs5("label", { className: "flex flex-col gap-1 rounded border border-dashed border-border p-2 text-xs text-muted-foreground", children: [
817
941
  "Choose or drop video, audio or image files",
818
- /* @__PURE__ */ jsx5(
942
+ /* @__PURE__ */ jsx6(
819
943
  "input",
820
944
  {
821
945
  type: "file",
@@ -832,7 +956,7 @@ function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMe
832
956
  }
833
957
  )
834
958
  ] }),
835
- /* @__PURE__ */ jsxs4(
959
+ /* @__PURE__ */ jsxs5(
836
960
  "form",
837
961
  {
838
962
  className: "flex items-center gap-1",
@@ -841,7 +965,7 @@ function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMe
841
965
  void addUrl();
842
966
  },
843
967
  children: [
844
- /* @__PURE__ */ jsx5(
968
+ /* @__PURE__ */ jsx6(
845
969
  "input",
846
970
  {
847
971
  className: "min-w-0 flex-1 rounded border border-border bg-transparent px-1 py-0.5 text-xs",
@@ -852,26 +976,26 @@ function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMe
852
976
  onChange: (e) => setUrl(e.target.value)
853
977
  }
854
978
  ),
855
- /* @__PURE__ */ jsxs4("select", { className: "rounded border border-border bg-transparent px-1 py-0.5 text-xs", value: urlKind, disabled: !canEdit, "aria-label": "Media kind", onChange: (e) => setUrlKind(e.target.value), children: [
856
- /* @__PURE__ */ jsx5("option", { value: "", children: "Auto" }),
857
- /* @__PURE__ */ jsx5("option", { value: "video", children: "Video" }),
858
- /* @__PURE__ */ jsx5("option", { value: "audio", children: "Audio" }),
859
- /* @__PURE__ */ jsx5("option", { value: "image", children: "Image" })
979
+ /* @__PURE__ */ jsxs5("select", { className: "rounded border border-border bg-transparent px-1 py-0.5 text-xs", value: urlKind, disabled: !canEdit, "aria-label": "Media kind", onChange: (e) => setUrlKind(e.target.value), children: [
980
+ /* @__PURE__ */ jsx6("option", { value: "", children: "Auto" }),
981
+ /* @__PURE__ */ jsx6("option", { value: "video", children: "Video" }),
982
+ /* @__PURE__ */ jsx6("option", { value: "audio", children: "Audio" }),
983
+ /* @__PURE__ */ jsx6("option", { value: "image", children: "Image" })
860
984
  ] }),
861
- /* @__PURE__ */ jsx5("button", { type: "submit", className: btn3, disabled: !canEdit || !url.trim(), "data-add-url": "", children: "Add" })
985
+ /* @__PURE__ */ jsx6("button", { type: "submit", className: btn3, disabled: !canEdit || !url.trim(), "data-add-url": "", children: "Add" })
862
986
  ]
863
987
  }
864
988
  )
865
989
  ] }),
866
- busy > 0 && /* @__PURE__ */ jsx5("p", { className: "text-xs text-muted-foreground", children: "Reading media\u2026" }),
867
- problem && /* @__PURE__ */ jsx5("p", { className: "text-xs text-red-500", role: "alert", "data-media-error": "", children: problem })
990
+ busy > 0 && /* @__PURE__ */ jsx6("p", { className: "text-xs text-muted-foreground", children: "Reading media\u2026" }),
991
+ problem && /* @__PURE__ */ jsx6("p", { className: "text-xs text-red-500", role: "alert", "data-media-error": "", children: problem })
868
992
  ]
869
993
  }
870
994
  );
871
995
  }
872
996
 
873
997
  // src/video-editor/TranscriptPanel.tsx
874
- import React4, { useMemo as useMemo3, useState as useState3 } from "react";
998
+ import React6, { useMemo as useMemo3, useState as useState5 } from "react";
875
999
  import {
876
1000
  findVideoFillers,
877
1001
  findVideoTranscriptPauses,
@@ -881,15 +1005,15 @@ import {
881
1005
  videoWordAt,
882
1006
  videoWordsRange
883
1007
  } from "@pixerate/editor/video-editor";
884
- import { Fragment as Fragment5, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1008
+ import { Fragment as Fragment5, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
885
1009
  var btn4 = "rounded border border-border px-1.5 py-0.5 text-[11px] disabled:opacity-40";
886
1010
  function TranscriptPanel({ controller, track, editable = true, className = "" }) {
887
1011
  const state = useVideoEditorState(controller);
888
1012
  const { project, time, readOnly } = state;
889
1013
  const canEdit = editable && !readOnly;
890
- const [anchor, setAnchor] = useState3(null);
891
- const [focus, setFocus] = useState3(null);
892
- const [message, setMessage] = useState3(null);
1014
+ const [anchor, setAnchor] = useState5(null);
1015
+ const [focus, setFocus] = useState5(null);
1016
+ const [message, setMessage] = useState5(null);
893
1017
  const words = useMemo3(() => {
894
1018
  try {
895
1019
  return videoTimelineTranscript(project, { track });
@@ -916,9 +1040,9 @@ function TranscriptPanel({ controller, track, editable = true, className = "" })
916
1040
  setMessage(e instanceof Error ? e.message : String(e));
917
1041
  }
918
1042
  };
919
- return /* @__PURE__ */ jsxs5("div", { className: `flex flex-col gap-2 text-xs ${className}`, "data-transcript-panel": "", children: [
920
- words.length > 0 ? /* @__PURE__ */ jsxs5(Fragment5, { children: [
921
- /* @__PURE__ */ jsx6(
1043
+ return /* @__PURE__ */ jsxs6("div", { className: `flex flex-col gap-2 text-xs ${className}`, "data-transcript-panel": "", children: [
1044
+ words.length > 0 ? /* @__PURE__ */ jsxs6(Fragment5, { children: [
1045
+ /* @__PURE__ */ jsx7(
922
1046
  "div",
923
1047
  {
924
1048
  className: "max-h-48 overflow-y-auto leading-6",
@@ -934,8 +1058,8 @@ function TranscriptPanel({ controller, track, editable = true, className = "" })
934
1058
  },
935
1059
  children: words.map((w, i) => {
936
1060
  const inSelection = selected !== null && i >= selected[0] && i <= selected[1];
937
- return /* @__PURE__ */ jsxs5(React4.Fragment, { children: [
938
- /* @__PURE__ */ jsx6(
1061
+ return /* @__PURE__ */ jsxs6(React6.Fragment, { children: [
1062
+ /* @__PURE__ */ jsx7(
939
1063
  "span",
940
1064
  {
941
1065
  role: "option",
@@ -961,51 +1085,52 @@ function TranscriptPanel({ controller, track, editable = true, className = "" })
961
1085
  })
962
1086
  }
963
1087
  ),
964
- canEdit && /* @__PURE__ */ jsxs5("div", { className: "flex flex-wrap gap-1", children: [
965
- /* @__PURE__ */ jsx6("button", { type: "button", className: btn4, disabled: !selected, onClick: () => selected && cut([videoWordsRange(words, selected[0], selected[1])], "Cut words", "Select words to cut."), children: "Delete selected words" }),
966
- /* @__PURE__ */ jsx6("button", { type: "button", className: btn4, onClick: () => cut(findVideoFillers(words), "Remove fillers", "No filler words found."), children: "Remove fillers" }),
967
- /* @__PURE__ */ jsx6("button", { type: "button", className: btn4, onClick: () => cut(findVideoTranscriptPauses(words, { fps: project.fps }), "Tighten pauses", "No long pauses found."), children: "Tighten pauses" })
1088
+ canEdit && /* @__PURE__ */ jsxs6("div", { className: "flex flex-wrap gap-1", children: [
1089
+ /* @__PURE__ */ jsx7("button", { type: "button", className: btn4, disabled: !selected, onClick: () => selected && cut([videoWordsRange(words, selected[0], selected[1])], "Cut words", "Select words to cut."), children: "Delete selected words" }),
1090
+ /* @__PURE__ */ jsx7("button", { type: "button", className: btn4, onClick: () => cut(findVideoFillers(words), "Remove fillers", "No filler words found."), children: "Remove fillers" }),
1091
+ /* @__PURE__ */ jsx7("button", { type: "button", className: btn4, onClick: () => cut(findVideoTranscriptPauses(words, { fps: project.fps }), "Tighten pauses", "No long pauses found."), children: "Tighten pauses" })
968
1092
  ] })
969
- ] }) : /* @__PURE__ */ jsx6("p", { className: "text-muted-foreground", "data-transcript-empty": "", children: "No transcribed media on the timeline. Load a transcript (Whisper JSON, .srt or .vtt) for a video or audio asset to edit by text." }),
970
- canEdit && sound.length > 0 && /* @__PURE__ */ jsxs5("details", { children: [
971
- /* @__PURE__ */ jsx6("summary", { className: "cursor-pointer text-muted-foreground", children: "Transcripts" }),
972
- /* @__PURE__ */ jsx6("ul", { className: "mt-1 space-y-1", children: sound.map((a) => /* @__PURE__ */ jsxs5("li", { className: "flex items-center gap-2", children: [
973
- /* @__PURE__ */ jsxs5("span", { className: "min-w-0 flex-1 truncate", children: [
1093
+ ] }) : /* @__PURE__ */ jsx7("p", { className: "text-muted-foreground", "data-transcript-empty": "", children: "No transcribed media on the timeline. Load a transcript (Whisper JSON, .srt or .vtt) for a video or audio asset to edit by text." }),
1094
+ canEdit && sound.length > 0 && /* @__PURE__ */ jsxs6("details", { children: [
1095
+ /* @__PURE__ */ jsx7("summary", { className: "cursor-pointer text-muted-foreground", children: "Transcripts" }),
1096
+ /* @__PURE__ */ jsx7("ul", { className: "mt-1 space-y-1", children: sound.map((a) => /* @__PURE__ */ jsxs6("li", { className: "flex items-center gap-2", children: [
1097
+ /* @__PURE__ */ jsxs6("span", { className: "min-w-0 flex-1 truncate", children: [
974
1098
  a.name ?? a.id,
975
1099
  " ",
976
1100
  a.transcript ? `\xB7 ${a.transcript.length} words` : ""
977
1101
  ] }),
978
- /* @__PURE__ */ jsx6("input", { type: "file", accept: ".json,.srt,.vtt,application/json,text/vtt", "aria-label": `Load a transcript for ${a.name ?? a.id}`, className: "w-40 text-[10px]", onChange: (e) => void load(a.id, e.target.files?.[0]) })
1102
+ /* @__PURE__ */ jsx7("input", { type: "file", accept: ".json,.srt,.vtt,application/json,text/vtt", "aria-label": `Load a transcript for ${a.name ?? a.id}`, className: "w-40 text-[10px]", onChange: (e) => void load(a.id, e.target.files?.[0]) })
979
1103
  ] }, a.id)) })
980
1104
  ] }),
981
- message && /* @__PURE__ */ jsx6("p", { className: "text-red-500", role: "alert", "data-transcript-error": "", children: message })
1105
+ message && /* @__PURE__ */ jsx7("p", { className: "text-red-500", role: "alert", "data-transcript-error": "", children: message })
982
1106
  ] });
983
1107
  }
984
1108
 
985
1109
  // src/video-editor/VideoEditor.tsx
986
- import { useEffect as useEffect4 } from "react";
987
- import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
988
- function VideoEditor({ controller: given, options, editable = true, pxPerSecond, onPxPerSecondChange, resolution, showIssues = true, showTranscript = true, className = "" }) {
1110
+ import { useEffect as useEffect5 } from "react";
1111
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
1112
+ function VideoEditor({ controller: given, options, editable = true, pxPerSecond, onPxPerSecondChange, resolution, showIssues = true, showTranscript = true, showScopes = false, className = "" }) {
989
1113
  const { controller, state } = useVideoEditor(given ?? options ?? {});
990
- useEffect4(() => {
1114
+ useEffect5(() => {
991
1115
  if (editable) return;
992
1116
  const was = controller.getState().readOnly;
993
1117
  controller.setReadOnly(true);
994
1118
  return () => controller.setReadOnly(was);
995
1119
  }, [controller, editable]);
996
1120
  const { issues } = state;
997
- const issueList = showIssues && issues.length > 0 && /* @__PURE__ */ jsx7("ul", { className: "space-y-0.5 text-xs", "aria-label": "Video issues", "data-video-issues": "", children: issues.map((issue, i) => /* @__PURE__ */ jsx7("li", { className: issue.severity === "error" ? "text-red-500" : issue.severity === "warning" ? "text-amber-500" : "text-muted-foreground", "data-issue": issue.code, children: issue.message }, i)) });
998
- return /* @__PURE__ */ jsxs6("div", { className: `flex flex-col gap-3 ${className}`, "data-video-editor": "", children: [
999
- /* @__PURE__ */ jsxs6("div", { className: `grid gap-3 ${editable ? "lg:grid-cols-[16rem_minmax(0,1fr)_18rem]" : ""}`, children: [
1000
- editable && /* @__PURE__ */ jsx7(MediaBin, { controller, className: "min-w-0" }),
1001
- /* @__PURE__ */ jsxs6("div", { className: "flex min-w-0 flex-col gap-2", children: [
1002
- /* @__PURE__ */ jsx7(VideoPreview, { controller, resolution, className: "rounded-md border border-border" }),
1003
- /* @__PURE__ */ jsx7(VideoTransport, { controller, editable })
1121
+ const issueList = showIssues && issues.length > 0 && /* @__PURE__ */ jsx8("ul", { className: "space-y-0.5 text-xs", "aria-label": "Video issues", "data-video-issues": "", children: issues.map((issue, i) => /* @__PURE__ */ jsx8("li", { className: issue.severity === "error" ? "text-red-500" : issue.severity === "warning" ? "text-amber-500" : "text-muted-foreground", "data-issue": issue.code, children: issue.message }, i)) });
1122
+ return /* @__PURE__ */ jsxs7("div", { className: `flex flex-col gap-3 ${className}`, "data-video-editor": "", children: [
1123
+ /* @__PURE__ */ jsxs7("div", { className: `grid gap-3 ${editable ? "lg:grid-cols-[16rem_minmax(0,1fr)_18rem]" : ""}`, children: [
1124
+ editable && /* @__PURE__ */ jsx8(MediaBin, { controller, className: "min-w-0" }),
1125
+ /* @__PURE__ */ jsxs7("div", { className: "flex min-w-0 flex-col gap-2", children: [
1126
+ /* @__PURE__ */ jsx8(VideoPreview, { controller, resolution, className: "rounded-md border border-border" }),
1127
+ /* @__PURE__ */ jsx8(VideoTransport, { controller, editable }),
1128
+ showScopes && /* @__PURE__ */ jsx8(VideoScopes, { controller })
1004
1129
  ] }),
1005
- editable && /* @__PURE__ */ jsx7(ClipInspector, { controller, className: "min-w-0" })
1130
+ editable && /* @__PURE__ */ jsx8(ClipInspector, { controller, className: "min-w-0" })
1006
1131
  ] }),
1007
- /* @__PURE__ */ jsx7(VideoTimeline, { controller, editable, pxPerSecond, onPxPerSecondChange }),
1008
- showTranscript && /* @__PURE__ */ jsx7(TranscriptPanel, { controller, editable }),
1132
+ /* @__PURE__ */ jsx8(VideoTimeline, { controller, editable, pxPerSecond, onPxPerSecondChange }),
1133
+ showTranscript && /* @__PURE__ */ jsx8(TranscriptPanel, { controller, editable }),
1009
1134
  issueList
1010
1135
  ] });
1011
1136
  }
@@ -1015,6 +1140,7 @@ export {
1015
1140
  TranscriptPanel,
1016
1141
  VideoEditor,
1017
1142
  VideoPreview,
1143
+ VideoScopes,
1018
1144
  VideoTimeline,
1019
1145
  VideoTransport,
1020
1146
  useVideoEditor,