@pixerate/editor-react 0.48.0 → 0.50.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.
@@ -0,0 +1,758 @@
1
+ // src/video-editor/useVideoEditor.ts
2
+ import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
3
+ import {
4
+ VideoEditorController,
5
+ createVideoEditorToolProvider
6
+ } from "@pixerate/editor/video-editor";
7
+ function useVideoEditorController(options = {}) {
8
+ const ref = useRef(null);
9
+ if (!ref.current) ref.current = new VideoEditorController(options);
10
+ const controller = ref.current;
11
+ useEffect(() => () => controller.destroy(), [controller]);
12
+ return controller;
13
+ }
14
+ function useVideoEditorState(controller) {
15
+ return useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
16
+ }
17
+ function useVideoEditor(controllerOrOptions = {}) {
18
+ const own = useRef(null);
19
+ let controller;
20
+ if (controllerOrOptions instanceof VideoEditorController) controller = controllerOrOptions;
21
+ else {
22
+ if (!own.current) own.current = new VideoEditorController(controllerOrOptions);
23
+ controller = own.current;
24
+ }
25
+ useEffect(() => () => own.current?.destroy(), []);
26
+ const state = useVideoEditorState(controller);
27
+ const actions = useMemo(
28
+ () => ({
29
+ play: () => controller.play(),
30
+ pause: () => controller.pause(),
31
+ toggle: () => controller.toggle(),
32
+ seek: (time) => controller.seek(time),
33
+ step: (frames) => controller.step(frames),
34
+ undo: () => void controller.undo(),
35
+ redo: () => void controller.redo(),
36
+ splitAtPlayhead: () => controller.splitAtPlayhead(),
37
+ deleteSelection: (options) => controller.deleteSelection(options)
38
+ }),
39
+ [controller]
40
+ );
41
+ return { controller, state, ...actions };
42
+ }
43
+ function useVideoEditorTools(registry, controller, options = {}) {
44
+ const { id, namespace, readOnly, rasterize } = options;
45
+ useEffect(() => {
46
+ if (!registry) return;
47
+ return registry.register(createVideoEditorToolProvider(controller, { id, namespace, readOnly, rasterize }));
48
+ }, [registry, controller, id, namespace, readOnly, rasterize]);
49
+ }
50
+
51
+ // src/video-editor/VideoPreview.tsx
52
+ import { useEffect as useEffect2, useRef as useRef2 } from "react";
53
+ import { VideoPreviewEngine } from "@pixerate/editor/video-editor";
54
+ import { jsx } from "react/jsx-runtime";
55
+ function VideoPreview({ controller, resolution, onFrame, className = "", style }) {
56
+ const { project } = useVideoEditorState(controller);
57
+ const canvas = useRef2(null);
58
+ const frameRef = useRef2(onFrame);
59
+ frameRef.current = onFrame;
60
+ useEffect2(() => {
61
+ const el = canvas.current;
62
+ if (!el) return;
63
+ const engine = new VideoPreviewEngine(controller, el, { resolution, onFrame: (frame) => frameRef.current?.(frame) });
64
+ return () => engine.destroy();
65
+ }, [controller, resolution]);
66
+ return /* @__PURE__ */ jsx(
67
+ "canvas",
68
+ {
69
+ ref: canvas,
70
+ className,
71
+ style: { display: "block", width: "100%", aspectRatio: `${project.width} / ${project.height}`, background: project.background ?? "#000", ...style },
72
+ role: "img",
73
+ "aria-label": `Preview of ${project.name ?? "the video"}`,
74
+ "data-video-preview": ""
75
+ }
76
+ );
77
+ }
78
+
79
+ // src/video-editor/VideoTransport.tsx
80
+ import { formatVideoTimecode } from "@pixerate/editor/video-editor";
81
+ import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
82
+ var btn = "rounded border border-border px-2 py-0.5 text-xs disabled:opacity-40";
83
+ function VideoTransport({ controller, editable = true, className = "" }) {
84
+ const state = useVideoEditorState(controller);
85
+ const { time, duration, playing, project, selection, readOnly } = state;
86
+ 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: [
92
+ formatVideoTimecode(time, project.fps),
93
+ " / ",
94
+ formatVideoTimecode(duration, project.fps)
95
+ ] }),
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(
101
+ "button",
102
+ {
103
+ type: "button",
104
+ className: btn,
105
+ onClick: () => controller.deleteSelection({ ripple: true }),
106
+ disabled: !canEdit || !selection.length,
107
+ title: "Delete the selected clips and close the gaps (Shift+Delete)",
108
+ "data-ripple-delete": "",
109
+ children: "Ripple delete"
110
+ }
111
+ ),
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" })
114
+ ] })
115
+ ] });
116
+ }
117
+
118
+ // src/video-editor/VideoTimeline.tsx
119
+ import { useEffect as useEffect3, useRef as useRef3, useState } from "react";
120
+ import {
121
+ planVideoClipDrag,
122
+ videoAssetMap,
123
+ videoClipLabel,
124
+ videoClipTint,
125
+ videoDropTracks,
126
+ videoRulerTicks,
127
+ formatVideoSeconds
128
+ } from "@pixerate/editor/video-editor";
129
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
130
+ var HEADER = 150;
131
+ var RULER = 24;
132
+ var ROW = 44;
133
+ var HANDLE = 6;
134
+ var DRAG_THRESHOLD = 3;
135
+ var MIN_ZOOM = 10;
136
+ var MAX_ZOOM = 400;
137
+ var isField = (el) => el instanceof HTMLElement && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName);
138
+ function VideoTimeline({ controller, pxPerSecond, onPxPerSecondChange, editable = true, className = "" }) {
139
+ const state = useVideoEditorState(controller);
140
+ const { project, duration, time, selection, readOnly, error } = state;
141
+ const [ownZoom, setOwnZoom] = useState(pxPerSecond ?? 100);
142
+ const px = onPxPerSecondChange ? pxPerSecond ?? 100 : ownZoom;
143
+ const canEdit = editable && !readOnly;
144
+ const [drag, setDrag] = useState(null);
145
+ const dragRef = useRef3(null);
146
+ const rows = useRef3(/* @__PURE__ */ new Map());
147
+ const ruler = useRef3(null);
148
+ const assets = videoAssetMap(project);
149
+ const msToPx = (ms) => ms * px / 1e3;
150
+ const span = Math.max(duration + 5e3, 1e4);
151
+ const width = msToPx(span);
152
+ const ticks = videoRulerTicks(span, px, project.fps);
153
+ const seekAt = (clientX) => {
154
+ const box = ruler.current.getBoundingClientRect();
155
+ controller.seek(Math.max(0, (clientX - box.left) * 1e3 / px));
156
+ };
157
+ const trackAt = (clipId, clientY) => {
158
+ const allowed = videoDropTracks(project, clipId);
159
+ for (const [id, el] of rows.current) {
160
+ const box = el.getBoundingClientRect();
161
+ if (clientY >= box.top && clientY < box.bottom && allowed.includes(id)) return id;
162
+ }
163
+ return void 0;
164
+ };
165
+ const planFor = (d, e) => planVideoClipDrag(project, {
166
+ clip: d.clip,
167
+ mode: d.mode,
168
+ delta: (e.clientX - d.x) * 1e3 / px,
169
+ track: d.mode === "move" ? trackAt(d.clip, e.clientY) : void 0,
170
+ ripple: e.altKey,
171
+ snap: (t) => controller.snap(t, { exclude: [d.clip] }).time
172
+ });
173
+ const update = (next) => {
174
+ dragRef.current = next;
175
+ setDrag(next);
176
+ };
177
+ const live = useRef3({ planFor, selection });
178
+ live.current = { planFor, selection };
179
+ const stopDrag = useRef3(null);
180
+ useEffect3(() => () => stopDrag.current?.(), []);
181
+ const onClipPointerDown = (e, clip, track) => {
182
+ if (e.button !== void 0 && e.button !== 0) return;
183
+ e.stopPropagation();
184
+ if (!editable) return;
185
+ try {
186
+ e.currentTarget.setPointerCapture?.(e.pointerId);
187
+ } catch {
188
+ }
189
+ const handle = e.target.closest("[data-handle]")?.getAttribute("data-handle");
190
+ const mode = handle === "start" ? "trim-start" : handle === "end" ? "trim-end" : "move";
191
+ const fixed = !canEdit || !!track.locked;
192
+ stopDrag.current?.();
193
+ update({ clip: clip.id, mode, x: e.clientX, pointer: e.pointerId, moved: false, plan: null });
194
+ const move = (ev) => {
195
+ const d = dragRef.current;
196
+ if (!d || fixed) return;
197
+ if (!d.moved && Math.abs(ev.clientX - d.x) < DRAG_THRESHOLD) return;
198
+ if (!d.moved && !live.current.selection.includes(d.clip)) controller.select([d.clip]);
199
+ update({ ...d, moved: true, plan: live.current.planFor(d, ev) });
200
+ };
201
+ const up = (ev) => {
202
+ stop();
203
+ const d = dragRef.current;
204
+ if (!d) return;
205
+ update(null);
206
+ if (!d.moved) {
207
+ if (ev.shiftKey || ev.metaKey || ev.ctrlKey) controller.select([d.clip], { add: true });
208
+ else controller.select([d.clip]);
209
+ return;
210
+ }
211
+ const plan = live.current.planFor(d, ev);
212
+ if (plan?.edit) controller.apply(plan.edit, d.mode === "move" ? "Move clip" : ev.altKey ? "Ripple trim" : "Trim clip");
213
+ };
214
+ const cancel = () => {
215
+ stop();
216
+ update(null);
217
+ };
218
+ const stop = () => {
219
+ window.removeEventListener("pointermove", move);
220
+ window.removeEventListener("pointerup", up);
221
+ window.removeEventListener("pointercancel", cancel);
222
+ stopDrag.current = null;
223
+ };
224
+ window.addEventListener("pointermove", move);
225
+ window.addEventListener("pointerup", up);
226
+ window.addEventListener("pointercancel", cancel);
227
+ stopDrag.current = stop;
228
+ };
229
+ const onKeyDown = (e) => {
230
+ if (isField(e.target)) return;
231
+ const mod = e.metaKey || e.ctrlKey;
232
+ const key = e.key.toLowerCase();
233
+ if (e.key === " ") {
234
+ if (e.target.tagName === "BUTTON") return;
235
+ controller.toggle();
236
+ } else if (e.key === "ArrowLeft" && !mod) controller.step(-1);
237
+ else if (e.key === "ArrowRight" && !mod) controller.step(1);
238
+ else if (e.key === "Escape") controller.select(null);
239
+ else if (!canEdit) return;
240
+ else if (key === "s" && !mod) controller.splitAtPlayhead();
241
+ else if ((e.key === "Delete" || e.key === "Backspace") && !mod) controller.deleteSelection({ ripple: e.shiftKey });
242
+ else if (key === "z" && mod) {
243
+ if (e.shiftKey) controller.redo();
244
+ else controller.undo();
245
+ } else return;
246
+ e.preventDefault();
247
+ };
248
+ const placed = project.tracks.map((track) => ({
249
+ track,
250
+ clips: project.tracks.flatMap(
251
+ (t) => t.clips.map((clip) => {
252
+ const plan = drag?.moved && drag.clip === clip.id ? drag.plan : null;
253
+ const on = plan ? plan.track : t.id;
254
+ return on === track.id ? { clip, home: t, start: plan ? plan.start : clip.start, duration: plan ? plan.duration : clip.duration, dragging: !!plan } : null;
255
+ }).filter((x) => x !== null)
256
+ )
257
+ }));
258
+ const toggle = (track, field2, label) => /* @__PURE__ */ jsx3(
259
+ "button",
260
+ {
261
+ type: "button",
262
+ className: `rounded border px-1 text-[10px] leading-4 ${track[field2] ? "border-rose-500 bg-rose-500/15 text-rose-600" : "border-border text-muted-foreground"} disabled:opacity-40`,
263
+ "aria-pressed": !!track[field2],
264
+ "aria-label": `${label} ${track.name ?? track.id}`,
265
+ title: label,
266
+ disabled: !canEdit,
267
+ "data-track-toggle": field2,
268
+ onClick: () => controller.apply({ op: "update_track", track: track.id, set: { [field2]: !track[field2] } }, `${track[field2] ? "Un" : ""}${field2 === "muted" ? "mute" : field2 === "hidden" ? "hide" : "lock"} track`),
269
+ children: field2 === "muted" ? "M" : field2 === "hidden" ? "H" : "L"
270
+ }
271
+ );
272
+ return /* @__PURE__ */ jsxs2("div", { className: `flex flex-col gap-1 ${className}`, "data-video-timeline-wrapper": "", children: [
273
+ !onPxPerSecondChange && /* @__PURE__ */ jsxs2("label", { className: "flex items-center gap-2 self-end text-xs text-muted-foreground", children: [
274
+ "Zoom",
275
+ /* @__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": "" })
276
+ ] }),
277
+ /* @__PURE__ */ jsx3(
278
+ "div",
279
+ {
280
+ className: "relative overflow-x-auto rounded border border-border outline-none focus-visible:ring-2 focus-visible:ring-rose-500/50",
281
+ tabIndex: 0,
282
+ role: "group",
283
+ "aria-label": "Video timeline",
284
+ onKeyDown,
285
+ "data-video-timeline": "",
286
+ children: /* @__PURE__ */ jsxs2("div", { className: "relative", style: { width: HEADER + width }, children: [
287
+ /* @__PURE__ */ jsxs2("div", { className: "flex", style: { height: RULER }, children: [
288
+ /* @__PURE__ */ jsx3("div", { className: "sticky left-0 z-20 shrink-0 border-b border-r border-border bg-background", style: { width: HEADER } }),
289
+ /* @__PURE__ */ jsxs2(
290
+ "div",
291
+ {
292
+ ref: ruler,
293
+ className: "relative shrink-0 cursor-ew-resize select-none touch-none border-b border-border text-[10px] text-muted-foreground",
294
+ style: { width },
295
+ role: "slider",
296
+ "aria-label": "Playhead",
297
+ "aria-valuemin": 0,
298
+ "aria-valuemax": duration,
299
+ "aria-valuenow": Math.round(time),
300
+ "data-ruler": "",
301
+ onPointerDown: (e) => {
302
+ try {
303
+ e.currentTarget.setPointerCapture?.(e.pointerId);
304
+ } catch {
305
+ }
306
+ seekAt(e.clientX);
307
+ },
308
+ onPointerMove: (e) => {
309
+ if (e.buttons & 1) seekAt(e.clientX);
310
+ },
311
+ children: [
312
+ 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)),
313
+ (project.markers ?? []).map((m) => /* @__PURE__ */ jsx3(
314
+ "div",
315
+ {
316
+ className: "absolute top-0 h-2.5 w-2.5 -translate-x-1/2 rotate-45",
317
+ style: { left: msToPx(m.time), background: m.color ?? "#f59e0b" },
318
+ title: `${m.label ?? m.id} at ${formatVideoSeconds(m.time)}`,
319
+ "data-marker": m.id
320
+ },
321
+ m.id
322
+ ))
323
+ ]
324
+ }
325
+ )
326
+ ] }),
327
+ 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: [
328
+ /* @__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: [
329
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-1", children: [
330
+ /* @__PURE__ */ jsx3("span", { className: "truncate font-medium", children: track.name ?? track.id }),
331
+ /* @__PURE__ */ jsx3("span", { className: "rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground", "data-track-kind": "", children: track.kind })
332
+ ] }),
333
+ editable && /* @__PURE__ */ jsxs2("div", { className: "flex gap-1", children: [
334
+ toggle(track, "muted", "Mute"),
335
+ track.kind === "video" && toggle(track, "hidden", "Hide"),
336
+ toggle(track, "locked", "Lock")
337
+ ] })
338
+ ] }),
339
+ /* @__PURE__ */ jsx3(
340
+ "div",
341
+ {
342
+ ref: (el) => {
343
+ if (el) rows.current.set(track.id, el);
344
+ else rows.current.delete(track.id);
345
+ },
346
+ className: `relative shrink-0 ${track.hidden ? "opacity-50" : ""} ${track.locked ? "bg-muted/40" : ""}`,
347
+ style: { width },
348
+ "data-track-lane": track.id,
349
+ onPointerDown: (e) => {
350
+ if (e.target === e.currentTarget && editable) controller.select(null);
351
+ },
352
+ children: clips.map(({ clip, home, start, duration: length, dragging }) => {
353
+ const asset = clip.type === "media" ? assets.get(clip.asset) : void 0;
354
+ const selected = selection.includes(clip.id);
355
+ const label = videoClipLabel(clip, asset);
356
+ return /* @__PURE__ */ jsxs2(
357
+ "div",
358
+ {
359
+ 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" : ""}`,
360
+ style: {
361
+ left: msToPx(start),
362
+ width: Math.max(2, msToPx(length)),
363
+ background: videoClipTint(clip, asset),
364
+ outline: selected ? "2px solid #f43f5e" : void 0,
365
+ outlineOffset: selected ? 1 : void 0,
366
+ zIndex: dragging ? 10 : selected ? 2 : 1
367
+ },
368
+ title: `${label} \xB7 ${formatVideoSeconds(start)}\u2013${formatVideoSeconds(start + length)}`,
369
+ "data-clip": clip.id,
370
+ "data-selected": selected ? "" : void 0,
371
+ "data-dragging": dragging ? "" : void 0,
372
+ "aria-selected": selected,
373
+ onPointerDown: (e) => onClipPointerDown(e, clip, home),
374
+ children: [
375
+ /* @__PURE__ */ jsx3("span", { className: "pointer-events-none block truncate px-1.5 py-0.5 leading-4", children: label }),
376
+ 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: [
377
+ "\u29D3 ",
378
+ clip.transition.type
379
+ ] }),
380
+ canEdit && !home.locked && /* @__PURE__ */ jsxs2(Fragment2, { children: [
381
+ /* @__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" }),
382
+ /* @__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" })
383
+ ] })
384
+ ]
385
+ },
386
+ clip.id
387
+ );
388
+ })
389
+ }
390
+ )
391
+ ] }, track.id)),
392
+ /* @__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": "" })
393
+ ] })
394
+ }
395
+ ),
396
+ error && /* @__PURE__ */ jsx3("p", { className: "text-xs text-red-500", role: "alert", "data-edit-error": "", children: error })
397
+ ] });
398
+ }
399
+
400
+ // src/video-editor/ClipInspector.tsx
401
+ import {
402
+ findVideoClip,
403
+ formatVideoSeconds as formatVideoSeconds2,
404
+ VIDEO_TRANSITION_TYPES,
405
+ videoKeyframeRows,
406
+ videoKeyframeToggleEdit,
407
+ videoKeyframeValueEdit
408
+ } from "@pixerate/editor/video-editor";
409
+ import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
410
+ var field = "w-full rounded border border-border bg-transparent px-1 py-0.5 text-xs disabled:opacity-50";
411
+ var DEFAULT_TRANSITION = 500;
412
+ function Commit({
413
+ value,
414
+ onCommit,
415
+ multiline,
416
+ ...rest
417
+ }) {
418
+ const commit = (raw) => {
419
+ if (raw !== value) onCommit(raw);
420
+ };
421
+ if (multiline)
422
+ return /* @__PURE__ */ jsx4(
423
+ "textarea",
424
+ {
425
+ className: field,
426
+ rows: 2,
427
+ defaultValue: value,
428
+ disabled: rest.disabled,
429
+ "aria-label": rest["aria-label"],
430
+ onBlur: (e) => commit(e.currentTarget.value)
431
+ },
432
+ value
433
+ );
434
+ return /* @__PURE__ */ jsx4("input", { className: field, ...rest, defaultValue: value, onKeyDown: (e) => e.key === "Enter" && e.currentTarget.blur(), onBlur: (e) => commit(e.currentTarget.value) }, value);
435
+ }
436
+ function Row({ label, name, children }) {
437
+ return /* @__PURE__ */ jsxs3("label", { className: "grid grid-cols-[5.5rem_minmax(0,1fr)] items-center gap-2 text-xs", "data-field": name, children: [
438
+ /* @__PURE__ */ jsx4("span", { className: "text-muted-foreground", children: label }),
439
+ children
440
+ ] });
441
+ }
442
+ function ClipInspector({ controller, className = "" }) {
443
+ const state = useVideoEditorState(controller);
444
+ const { project, selection, readOnly, time } = state;
445
+ if (selection.length !== 1)
446
+ 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." });
447
+ const at = findVideoClip(project, selection[0]);
448
+ if (!at) return null;
449
+ const clip = at.clip;
450
+ const disabled = readOnly || !!at.track.locked;
451
+ const set = (key, value, label = `Change ${key}`) => {
452
+ if (value === void 0 || value === "") controller.apply({ op: "update_clip", clip: clip.id, remove: [key] }, label);
453
+ else controller.apply({ op: "update_clip", clip: clip.id, set: { [key]: value } }, label);
454
+ };
455
+ const num = (key, raw, label) => {
456
+ const trimmed = raw.trim();
457
+ if (!trimmed) return set(key, void 0, label);
458
+ const n = Number(trimmed);
459
+ if (Number.isFinite(n)) set(key, n, label);
460
+ };
461
+ const text = (key, label) => {
462
+ const value = clip[key];
463
+ 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}`) });
464
+ };
465
+ const number = (key, label, attrs = {}) => {
466
+ const value = clip[key];
467
+ 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 });
468
+ };
469
+ const transition = clip.transition;
470
+ return /* @__PURE__ */ jsxs3("div", { className: `flex flex-col gap-1.5 ${className}`, "data-clip-inspector": "", "data-clip-id": clip.id, children: [
471
+ /* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-2 text-xs", children: [
472
+ /* @__PURE__ */ jsx4("span", { className: "font-medium", children: clip.type === "media" ? "Media clip" : clip.type === "text" ? "Title" : "Colour clip" }),
473
+ /* @__PURE__ */ jsx4("code", { className: "text-muted-foreground", children: clip.id }),
474
+ at.track.locked && /* @__PURE__ */ jsx4("span", { className: "text-amber-500", children: "locked track" })
475
+ ] }),
476
+ /* @__PURE__ */ jsx4(Row, { label: "Name", name: "name", children: text("name", "Name") }),
477
+ /* @__PURE__ */ jsx4(Row, { label: "Start", name: "start", children: /* @__PURE__ */ jsx4("span", { className: "tabular-nums text-xs", children: formatVideoSeconds2(clip.start) }) }),
478
+ /* @__PURE__ */ jsx4(Row, { label: "Duration", name: "duration", children: /* @__PURE__ */ jsx4("span", { className: "tabular-nums text-xs", children: formatVideoSeconds2(clip.duration) }) }),
479
+ 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: [
480
+ /* @__PURE__ */ jsx4(
481
+ Commit,
482
+ {
483
+ type: "number",
484
+ value: row.keyed || (row.property === "opacity" ? clip.opacity : row.property === "volume" ? clip.volume : clip.transform?.[row.property]) !== void 0 ? String(row.value) : "",
485
+ placeholder: String(row.value),
486
+ "aria-label": row.label,
487
+ disabled,
488
+ min: row.min,
489
+ max: row.max,
490
+ step: row.step,
491
+ onCommit: (raw) => {
492
+ const n = Number(raw);
493
+ if (raw.trim() && !Number.isFinite(n)) return;
494
+ const edit = videoKeyframeValueEdit(project, clip.id, row.property, time, raw.trim() ? n : void 0);
495
+ if (edit) controller.apply(edit, row.keyed ? `Keyframe ${row.label.toLowerCase()}` : `Change ${row.label.toLowerCase()}`);
496
+ }
497
+ }
498
+ ),
499
+ /* @__PURE__ */ jsx4(
500
+ "button",
501
+ {
502
+ type: "button",
503
+ className: `shrink-0 rounded px-1 text-xs disabled:opacity-50 ${row.onKey ? "text-amber-500" : row.keyed ? "text-foreground" : "text-muted-foreground"}`,
504
+ "aria-label": `${row.onKey ? "Remove" : "Add"} ${row.label.toLowerCase()} keyframe at playhead`,
505
+ "aria-pressed": row.onKey,
506
+ title: row.keyed ? `${row.keys} key${row.keys === 1 ? "" : "s"}; ${row.onKey ? "remove the one" : "add one"} at the playhead` : "Animate: add a key at the playhead",
507
+ disabled,
508
+ "data-keyframe-toggle": row.property,
509
+ onClick: () => {
510
+ const edit = videoKeyframeToggleEdit(project, clip.id, row.property, time);
511
+ if (edit) controller.apply(edit, `${row.onKey ? "Remove" : "Add"} ${row.label.toLowerCase()} keyframe`);
512
+ },
513
+ children: row.onKey ? "\u25C6" : "\u25C7"
514
+ }
515
+ ),
516
+ row.keyed && /* @__PURE__ */ jsx4("span", { className: "shrink-0 text-[10px] tabular-nums text-muted-foreground", "data-keyframe-count": row.property, children: row.keys })
517
+ ] }) }, row.property)),
518
+ /* @__PURE__ */ jsx4(Row, { label: "Fade in (ms)", name: "fadeIn", children: number("fadeIn", "Fade in", { min: 0, step: 100 }) }),
519
+ /* @__PURE__ */ jsx4(Row, { label: "Fade out (ms)", name: "fadeOut", children: number("fadeOut", "Fade out", { min: 0, step: 100 }) }),
520
+ clip.type === "media" && /* @__PURE__ */ jsxs3(Fragment3, { children: [
521
+ /* @__PURE__ */ jsx4(Row, { label: "Speed", name: "speed", children: number("speed", "Speed", { min: 0.1, step: 0.25 }) }),
522
+ /* @__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") }) })
523
+ ] }),
524
+ clip.type === "text" && /* @__PURE__ */ jsxs3(Fragment3, { children: [
525
+ /* @__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") }) }),
526
+ /* @__PURE__ */ jsx4(Row, { label: "Colour", name: "color", children: text("color", "Colour") }),
527
+ /* @__PURE__ */ jsx4(Row, { label: "Font size", name: "fontSize", children: number("fontSize", "Font size", { min: 1, step: 2 }) }),
528
+ /* @__PURE__ */ jsx4(Row, { label: "Background", name: "background", children: text("background", "Background") })
529
+ ] }),
530
+ 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") }) }),
531
+ /* @__PURE__ */ jsx4(Row, { label: "Transition in", name: "transition", children: /* @__PURE__ */ jsxs3(
532
+ "select",
533
+ {
534
+ className: field,
535
+ value: transition?.type ?? "",
536
+ disabled,
537
+ "aria-label": "Transition",
538
+ onChange: (e) => {
539
+ const type = e.target.value;
540
+ controller.apply(
541
+ { op: "set_transition", clip: clip.id, transition: type ? { ...transition, type, duration: transition?.duration ?? DEFAULT_TRANSITION } : null },
542
+ type ? "Set transition" : "Remove transition"
543
+ );
544
+ },
545
+ children: [
546
+ /* @__PURE__ */ jsx4("option", { value: "", children: "None" }),
547
+ VIDEO_TRANSITION_TYPES.map((t) => /* @__PURE__ */ jsx4("option", { value: t, children: t === "crossfade" ? "Crossfade" : t === "dip" ? "Dip to colour" : "Wipe" }, t))
548
+ ]
549
+ }
550
+ ) }),
551
+ transition && /* @__PURE__ */ jsx4(Row, { label: "Length (ms)", name: "transitionDuration", children: /* @__PURE__ */ jsx4(
552
+ Commit,
553
+ {
554
+ type: "number",
555
+ min: 1,
556
+ step: 100,
557
+ value: String(transition.duration),
558
+ "aria-label": "Transition length",
559
+ disabled,
560
+ onCommit: (raw) => {
561
+ const n = Number(raw);
562
+ if (raw.trim() && Number.isFinite(n) && n > 0) controller.apply({ op: "set_transition", clip: clip.id, transition: { ...transition, duration: n } }, "Change transition length");
563
+ }
564
+ }
565
+ ) })
566
+ ] });
567
+ }
568
+
569
+ // src/video-editor/MediaBin.tsx
570
+ import { useState as useState2 } from "react";
571
+ import {
572
+ formatVideoSeconds as formatVideoSeconds3,
573
+ probeVideoMedia,
574
+ videoAssetDropEdit,
575
+ videoAssetKindOf
576
+ } from "@pixerate/editor/video-editor";
577
+ import { Fragment as Fragment4, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
578
+ var btn2 = "rounded border border-border px-2 py-0.5 text-xs disabled:opacity-40";
579
+ function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMedia, className = "" }) {
580
+ const { project, readOnly } = useVideoEditorState(controller);
581
+ const canEdit = editable && !readOnly;
582
+ const [url, setUrl] = useState2("");
583
+ const [urlKind, setUrlKind] = useState2("");
584
+ const [busy, setBusy] = useState2(0);
585
+ const [problem, setProblem] = useState2(null);
586
+ const [over, setOver] = useState2(false);
587
+ const addSource = async (src, kind, name) => {
588
+ setBusy((n) => n + 1);
589
+ try {
590
+ const found = await probe(src, kind).catch(() => null);
591
+ const result = controller.apply({ op: "add_asset", asset: { kind, src, name, ...found ?? {} } }, `Import ${name}`);
592
+ if (!result.ok) setProblem(result.error);
593
+ } finally {
594
+ setBusy((n) => n - 1);
595
+ }
596
+ };
597
+ const addFiles = async (files) => {
598
+ setProblem(null);
599
+ for (const file of Array.from(files ?? [])) {
600
+ const kind = videoAssetKindOf(file.type || file.name);
601
+ if (!kind) {
602
+ setProblem(`"${file.name}" is not a video, audio or image file.`);
603
+ continue;
604
+ }
605
+ const src = fileToUrl ? fileToUrl(file) : URL.createObjectURL(file);
606
+ await addSource(src, kind, file.name);
607
+ }
608
+ };
609
+ const addUrl = async () => {
610
+ const src = url.trim();
611
+ if (!src) return;
612
+ const kind = urlKind || videoAssetKindOf(src.replace(/[?#].*$/, ""));
613
+ if (!kind) {
614
+ setProblem(`Can't tell whether "${src}" is video, audio or an image; choose its kind.`);
615
+ return;
616
+ }
617
+ setProblem(null);
618
+ await addSource(src, kind, src.replace(/[?#].*$/, "").replace(/^.*[\\/]/, "") || src);
619
+ setUrl("");
620
+ };
621
+ const addToTimeline = (asset) => {
622
+ const edit = videoAssetDropEdit(project, asset.id);
623
+ if (!edit) {
624
+ setProblem(`There is no unlocked ${asset.kind === "audio" ? "audio" : "video"} track to put "${asset.name ?? asset.id}" on.`);
625
+ return;
626
+ }
627
+ setProblem(null);
628
+ const result = controller.apply(edit, `Add ${asset.name ?? asset.id}`);
629
+ if (result.ok) {
630
+ const added = result.outcomes[0]?.clip;
631
+ if (added) controller.select([added]);
632
+ }
633
+ };
634
+ return /* @__PURE__ */ jsxs4(
635
+ "div",
636
+ {
637
+ className: `flex flex-col gap-2 rounded ${over ? "ring-2 ring-rose-500/60" : ""} ${className}`,
638
+ "data-media-bin": "",
639
+ onDragOver: (e) => {
640
+ if (!canEdit || !e.dataTransfer?.types?.includes("Files")) return;
641
+ e.preventDefault();
642
+ setOver(true);
643
+ },
644
+ onDragLeave: () => setOver(false),
645
+ onDrop: (e) => {
646
+ setOver(false);
647
+ if (!canEdit || !e.dataTransfer?.files?.length) return;
648
+ e.preventDefault();
649
+ void addFiles(e.dataTransfer.files);
650
+ },
651
+ children: [
652
+ /* @__PURE__ */ jsx5("div", { className: "text-xs font-medium", children: "Media" }),
653
+ 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: [
654
+ /* @__PURE__ */ jsx5("span", { className: "rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground", "data-asset-kind": "", children: asset.kind }),
655
+ /* @__PURE__ */ jsx5("span", { className: "min-w-0 flex-1 truncate", title: asset.src, children: asset.name ?? asset.id }),
656
+ asset.duration !== void 0 && /* @__PURE__ */ jsx5("span", { className: "tabular-nums text-muted-foreground", children: formatVideoSeconds3(asset.duration) }),
657
+ editable && /* @__PURE__ */ jsx5("button", { type: "button", className: btn2, disabled: !canEdit, onClick: () => addToTimeline(asset), "data-add-to-timeline": "", children: "Add to timeline" })
658
+ ] }, asset.id)) }) : /* @__PURE__ */ jsxs4("p", { className: "text-xs text-muted-foreground", children: [
659
+ "No media yet.",
660
+ editable ? " Choose or drop files, or add a URL." : ""
661
+ ] }),
662
+ editable && /* @__PURE__ */ jsxs4(Fragment4, { children: [
663
+ /* @__PURE__ */ jsxs4("label", { className: "flex flex-col gap-1 rounded border border-dashed border-border p-2 text-xs text-muted-foreground", children: [
664
+ "Choose or drop video, audio or image files",
665
+ /* @__PURE__ */ jsx5(
666
+ "input",
667
+ {
668
+ type: "file",
669
+ multiple: true,
670
+ accept: "video/*,audio/*,image/*",
671
+ disabled: !canEdit,
672
+ "aria-label": "Import media files",
673
+ "data-import-files": "",
674
+ onChange: (e) => {
675
+ const files = e.currentTarget.files ? Array.from(e.currentTarget.files) : [];
676
+ e.currentTarget.value = "";
677
+ void addFiles(files);
678
+ }
679
+ }
680
+ )
681
+ ] }),
682
+ /* @__PURE__ */ jsxs4(
683
+ "form",
684
+ {
685
+ className: "flex items-center gap-1",
686
+ onSubmit: (e) => {
687
+ e.preventDefault();
688
+ void addUrl();
689
+ },
690
+ children: [
691
+ /* @__PURE__ */ jsx5(
692
+ "input",
693
+ {
694
+ className: "min-w-0 flex-1 rounded border border-border bg-transparent px-1 py-0.5 text-xs",
695
+ placeholder: "https://\u2026/clip.mp4",
696
+ value: url,
697
+ disabled: !canEdit,
698
+ "aria-label": "Media URL",
699
+ onChange: (e) => setUrl(e.target.value)
700
+ }
701
+ ),
702
+ /* @__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: [
703
+ /* @__PURE__ */ jsx5("option", { value: "", children: "Auto" }),
704
+ /* @__PURE__ */ jsx5("option", { value: "video", children: "Video" }),
705
+ /* @__PURE__ */ jsx5("option", { value: "audio", children: "Audio" }),
706
+ /* @__PURE__ */ jsx5("option", { value: "image", children: "Image" })
707
+ ] }),
708
+ /* @__PURE__ */ jsx5("button", { type: "submit", className: btn2, disabled: !canEdit || !url.trim(), "data-add-url": "", children: "Add" })
709
+ ]
710
+ }
711
+ )
712
+ ] }),
713
+ busy > 0 && /* @__PURE__ */ jsx5("p", { className: "text-xs text-muted-foreground", children: "Reading media\u2026" }),
714
+ problem && /* @__PURE__ */ jsx5("p", { className: "text-xs text-red-500", role: "alert", "data-media-error": "", children: problem })
715
+ ]
716
+ }
717
+ );
718
+ }
719
+
720
+ // src/video-editor/VideoEditor.tsx
721
+ import { useEffect as useEffect4 } from "react";
722
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
723
+ function VideoEditor({ controller: given, options, editable = true, pxPerSecond, onPxPerSecondChange, resolution, showIssues = true, className = "" }) {
724
+ const { controller, state } = useVideoEditor(given ?? options ?? {});
725
+ useEffect4(() => {
726
+ if (editable) return;
727
+ const was = controller.getState().readOnly;
728
+ controller.setReadOnly(true);
729
+ return () => controller.setReadOnly(was);
730
+ }, [controller, editable]);
731
+ const { issues } = state;
732
+ const issueList = showIssues && issues.length > 0 && /* @__PURE__ */ jsx6("ul", { className: "space-y-0.5 text-xs", "aria-label": "Video issues", "data-video-issues": "", children: issues.map((issue, i) => /* @__PURE__ */ jsx6("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)) });
733
+ return /* @__PURE__ */ jsxs5("div", { className: `flex flex-col gap-3 ${className}`, "data-video-editor": "", children: [
734
+ /* @__PURE__ */ jsxs5("div", { className: `grid gap-3 ${editable ? "lg:grid-cols-[16rem_minmax(0,1fr)_18rem]" : ""}`, children: [
735
+ editable && /* @__PURE__ */ jsx6(MediaBin, { controller, className: "min-w-0" }),
736
+ /* @__PURE__ */ jsxs5("div", { className: "flex min-w-0 flex-col gap-2", children: [
737
+ /* @__PURE__ */ jsx6(VideoPreview, { controller, resolution, className: "rounded-md border border-border" }),
738
+ /* @__PURE__ */ jsx6(VideoTransport, { controller, editable })
739
+ ] }),
740
+ editable && /* @__PURE__ */ jsx6(ClipInspector, { controller, className: "min-w-0" })
741
+ ] }),
742
+ /* @__PURE__ */ jsx6(VideoTimeline, { controller, editable, pxPerSecond, onPxPerSecondChange }),
743
+ issueList
744
+ ] });
745
+ }
746
+ export {
747
+ ClipInspector,
748
+ MediaBin,
749
+ VideoEditor,
750
+ VideoPreview,
751
+ VideoTimeline,
752
+ VideoTransport,
753
+ useVideoEditor,
754
+ useVideoEditorController,
755
+ useVideoEditorState,
756
+ useVideoEditorTools
757
+ };
758
+ //# sourceMappingURL=index.js.map