@pixerate/editor-react 0.54.0 → 0.56.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.
@@ -434,12 +434,16 @@ import {
434
434
  VIDEO_TRANSITION_TYPES,
435
435
  VIDEO_WIPE_DIRECTIONS,
436
436
  videoTransitionHasDirection,
437
+ videoFreezeFrameEdit,
437
438
  videoKeyframeRows,
439
+ videoSlowMotionRamp,
440
+ videoSpeedRampEdit,
438
441
  videoTransitionAudioCurve,
439
442
  videoKeyframeToggleEdit,
440
443
  videoKeyframeValueEdit
441
444
  } from "@pixerate/editor/video-editor";
442
445
  import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
446
+ var btn2 = "rounded border border-border px-1.5 py-0.5 text-[11px] disabled:opacity-40";
443
447
  var field = "w-full rounded border border-border bg-transparent px-1 py-0.5 text-xs disabled:opacity-50";
444
448
  var DEFAULT_TRANSITION = 500;
445
449
  function Commit({
@@ -607,7 +611,24 @@ function ClipInspector({ controller, className = "" }) {
607
611
  /* @__PURE__ */ jsx4(Row, { label: "Fade in (ms)", name: "fadeIn", children: number("fadeIn", "Fade in", { min: 0, step: 100 }) }),
608
612
  /* @__PURE__ */ jsx4(Row, { label: "Fade out (ms)", name: "fadeOut", children: number("fadeOut", "Fade out", { min: 0, step: 100 }) }),
609
613
  clip.type === "media" && /* @__PURE__ */ jsxs3(Fragment3, { children: [
610
- /* @__PURE__ */ jsx4(Row, { label: "Speed", name: "speed", children: number("speed", "Speed", { min: 0.1, step: 0.25 }) }),
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" })
617
+ ] }) : 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(
620
+ "button",
621
+ {
622
+ type: "button",
623
+ className: btn2,
624
+ disabled: disabled || time < clip.start || time > clip.start + clip.duration,
625
+ title: "Hold the frame at the playhead for a second, pushing later clips along",
626
+ onClick: () => controller.apply(videoFreezeFrameEdit(clip, time - clip.start, 1e3), "Freeze frame"),
627
+ children: "Freeze 1 s here"
628
+ }
629
+ ),
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" })
631
+ ] }) }),
611
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") }) }),
612
633
  ["fadeInCurve", "fadeOutCurve"].map(
613
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(
@@ -707,7 +728,7 @@ import {
707
728
  videoAssetKindOf
708
729
  } from "@pixerate/editor/video-editor";
709
730
  import { Fragment as Fragment4, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
710
- var btn2 = "rounded border border-border px-2 py-0.5 text-xs disabled:opacity-40";
731
+ var btn3 = "rounded border border-border px-2 py-0.5 text-xs disabled:opacity-40";
711
732
  function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMedia, className = "" }) {
712
733
  const { project, readOnly } = useVideoEditorState(controller);
713
734
  const canEdit = editable && !readOnly;
@@ -786,7 +807,7 @@ function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMe
786
807
  /* @__PURE__ */ jsx5("span", { className: "rounded bg-muted px-1 text-[10px] uppercase text-muted-foreground", "data-asset-kind": "", children: asset.kind }),
787
808
  /* @__PURE__ */ jsx5("span", { className: "min-w-0 flex-1 truncate", title: asset.src, children: asset.name ?? asset.id }),
788
809
  asset.duration !== void 0 && /* @__PURE__ */ jsx5("span", { className: "tabular-nums text-muted-foreground", children: formatVideoSeconds3(asset.duration) }),
789
- editable && /* @__PURE__ */ jsx5("button", { type: "button", className: btn2, disabled: !canEdit, onClick: () => addToTimeline(asset), "data-add-to-timeline": "", children: "Add to timeline" })
810
+ editable && /* @__PURE__ */ jsx5("button", { type: "button", className: btn3, disabled: !canEdit, onClick: () => addToTimeline(asset), "data-add-to-timeline": "", children: "Add to timeline" })
790
811
  ] }, asset.id)) }) : /* @__PURE__ */ jsxs4("p", { className: "text-xs text-muted-foreground", children: [
791
812
  "No media yet.",
792
813
  editable ? " Choose or drop files, or add a URL." : ""
@@ -837,7 +858,7 @@ function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMe
837
858
  /* @__PURE__ */ jsx5("option", { value: "audio", children: "Audio" }),
838
859
  /* @__PURE__ */ jsx5("option", { value: "image", children: "Image" })
839
860
  ] }),
840
- /* @__PURE__ */ jsx5("button", { type: "submit", className: btn2, disabled: !canEdit || !url.trim(), "data-add-url": "", children: "Add" })
861
+ /* @__PURE__ */ jsx5("button", { type: "submit", className: btn3, disabled: !canEdit || !url.trim(), "data-add-url": "", children: "Add" })
841
862
  ]
842
863
  }
843
864
  )
@@ -849,10 +870,122 @@ function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMe
849
870
  );
850
871
  }
851
872
 
873
+ // src/video-editor/TranscriptPanel.tsx
874
+ import React4, { useMemo as useMemo3, useState as useState3 } from "react";
875
+ import {
876
+ findVideoFillers,
877
+ findVideoTranscriptPauses,
878
+ videoRemoveRangesEdits,
879
+ videoTimelineTranscript,
880
+ videoTranscriptEdit,
881
+ videoWordAt,
882
+ videoWordsRange
883
+ } from "@pixerate/editor/video-editor";
884
+ import { Fragment as Fragment5, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
885
+ var btn4 = "rounded border border-border px-1.5 py-0.5 text-[11px] disabled:opacity-40";
886
+ function TranscriptPanel({ controller, track, editable = true, className = "" }) {
887
+ const state = useVideoEditorState(controller);
888
+ const { project, time, readOnly } = state;
889
+ const canEdit = editable && !readOnly;
890
+ const [anchor, setAnchor] = useState3(null);
891
+ const [focus, setFocus] = useState3(null);
892
+ const [message, setMessage] = useState3(null);
893
+ const words = useMemo3(() => {
894
+ try {
895
+ return videoTimelineTranscript(project, { track });
896
+ } catch {
897
+ return [];
898
+ }
899
+ }, [project, track]);
900
+ const current = videoWordAt(words, time);
901
+ const selected = anchor !== null && focus !== null && anchor < words.length && focus < words.length ? [Math.min(anchor, focus), Math.max(anchor, focus)] : null;
902
+ const sound = project.assets.filter((a) => a.kind !== "image");
903
+ const cut = (ranges, label, none) => {
904
+ if (!ranges.length) return setMessage(none);
905
+ const r = controller.apply(videoRemoveRangesEdits(ranges), label);
906
+ setMessage(r.ok ? null : r.error);
907
+ setAnchor(null);
908
+ setFocus(null);
909
+ };
910
+ const load = async (assetId, file) => {
911
+ if (!file) return;
912
+ try {
913
+ const r = controller.apply(videoTranscriptEdit(assetId, await file.text()), "Attach transcript");
914
+ setMessage(r.ok ? null : r.error);
915
+ } catch (e) {
916
+ setMessage(e instanceof Error ? e.message : String(e));
917
+ }
918
+ };
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(
922
+ "div",
923
+ {
924
+ className: "max-h-48 overflow-y-auto leading-6",
925
+ role: "listbox",
926
+ "aria-label": "Transcript",
927
+ "aria-multiselectable": "true",
928
+ tabIndex: 0,
929
+ onKeyDown: (e) => {
930
+ if (canEdit && selected && (e.key === "Delete" || e.key === "Backspace")) {
931
+ e.preventDefault();
932
+ cut([videoWordsRange(words, selected[0], selected[1])], "Cut words", "Select words to cut.");
933
+ }
934
+ },
935
+ children: words.map((w, i) => {
936
+ const inSelection = selected !== null && i >= selected[0] && i <= selected[1];
937
+ return /* @__PURE__ */ jsxs5(React4.Fragment, { children: [
938
+ /* @__PURE__ */ jsx6(
939
+ "span",
940
+ {
941
+ role: "option",
942
+ "aria-selected": inSelection,
943
+ tabIndex: -1,
944
+ className: `cursor-pointer rounded px-0.5 ${inSelection ? "bg-rose-500/30" : i === current ? "bg-amber-400/40" : "hover:bg-muted"}`,
945
+ "data-word": i,
946
+ "data-current": i === current ? "" : void 0,
947
+ "data-selected": inSelection ? "" : void 0,
948
+ onClick: (e) => {
949
+ if (e.shiftKey && anchor !== null) setFocus(i);
950
+ else {
951
+ setAnchor(i);
952
+ setFocus(i);
953
+ controller.seek(w.start);
954
+ }
955
+ },
956
+ children: w.word
957
+ }
958
+ ),
959
+ " "
960
+ ] }, `${w.clip}:${w.index}`);
961
+ })
962
+ }
963
+ ),
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" })
968
+ ] })
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: [
974
+ a.name ?? a.id,
975
+ " ",
976
+ a.transcript ? `\xB7 ${a.transcript.length} words` : ""
977
+ ] }),
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]) })
979
+ ] }, a.id)) })
980
+ ] }),
981
+ message && /* @__PURE__ */ jsx6("p", { className: "text-red-500", role: "alert", "data-transcript-error": "", children: message })
982
+ ] });
983
+ }
984
+
852
985
  // src/video-editor/VideoEditor.tsx
853
986
  import { useEffect as useEffect4 } from "react";
854
- import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
855
- function VideoEditor({ controller: given, options, editable = true, pxPerSecond, onPxPerSecondChange, resolution, showIssues = true, className = "" }) {
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 = "" }) {
856
989
  const { controller, state } = useVideoEditor(given ?? options ?? {});
857
990
  useEffect4(() => {
858
991
  if (editable) return;
@@ -861,23 +994,25 @@ function VideoEditor({ controller: given, options, editable = true, pxPerSecond,
861
994
  return () => controller.setReadOnly(was);
862
995
  }, [controller, editable]);
863
996
  const { issues } = state;
864
- 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)) });
865
- return /* @__PURE__ */ jsxs5("div", { className: `flex flex-col gap-3 ${className}`, "data-video-editor": "", children: [
866
- /* @__PURE__ */ jsxs5("div", { className: `grid gap-3 ${editable ? "lg:grid-cols-[16rem_minmax(0,1fr)_18rem]" : ""}`, children: [
867
- editable && /* @__PURE__ */ jsx6(MediaBin, { controller, className: "min-w-0" }),
868
- /* @__PURE__ */ jsxs5("div", { className: "flex min-w-0 flex-col gap-2", children: [
869
- /* @__PURE__ */ jsx6(VideoPreview, { controller, resolution, className: "rounded-md border border-border" }),
870
- /* @__PURE__ */ jsx6(VideoTransport, { controller, editable })
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 })
871
1004
  ] }),
872
- editable && /* @__PURE__ */ jsx6(ClipInspector, { controller, className: "min-w-0" })
1005
+ editable && /* @__PURE__ */ jsx7(ClipInspector, { controller, className: "min-w-0" })
873
1006
  ] }),
874
- /* @__PURE__ */ jsx6(VideoTimeline, { controller, editable, pxPerSecond, onPxPerSecondChange }),
1007
+ /* @__PURE__ */ jsx7(VideoTimeline, { controller, editable, pxPerSecond, onPxPerSecondChange }),
1008
+ showTranscript && /* @__PURE__ */ jsx7(TranscriptPanel, { controller, editable }),
875
1009
  issueList
876
1010
  ] });
877
1011
  }
878
1012
  export {
879
1013
  ClipInspector,
880
1014
  MediaBin,
1015
+ TranscriptPanel,
881
1016
  VideoEditor,
882
1017
  VideoPreview,
883
1018
  VideoTimeline,