@pixerate/editor-react 0.54.0 → 0.55.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.
@@ -849,10 +849,122 @@ function MediaBin({ controller, editable = true, fileToUrl, probe = probeVideoMe
849
849
  );
850
850
  }
851
851
 
852
+ // src/video-editor/TranscriptPanel.tsx
853
+ import React4, { useMemo as useMemo3, useState as useState3 } from "react";
854
+ import {
855
+ findVideoFillers,
856
+ findVideoTranscriptPauses,
857
+ videoRemoveRangesEdits,
858
+ videoTimelineTranscript,
859
+ videoTranscriptEdit,
860
+ videoWordAt,
861
+ videoWordsRange
862
+ } from "@pixerate/editor/video-editor";
863
+ import { Fragment as Fragment5, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
864
+ var btn3 = "rounded border border-border px-1.5 py-0.5 text-[11px] disabled:opacity-40";
865
+ function TranscriptPanel({ controller, track, editable = true, className = "" }) {
866
+ const state = useVideoEditorState(controller);
867
+ const { project, time, readOnly } = state;
868
+ const canEdit = editable && !readOnly;
869
+ const [anchor, setAnchor] = useState3(null);
870
+ const [focus, setFocus] = useState3(null);
871
+ const [message, setMessage] = useState3(null);
872
+ const words = useMemo3(() => {
873
+ try {
874
+ return videoTimelineTranscript(project, { track });
875
+ } catch {
876
+ return [];
877
+ }
878
+ }, [project, track]);
879
+ const current = videoWordAt(words, time);
880
+ const selected = anchor !== null && focus !== null && anchor < words.length && focus < words.length ? [Math.min(anchor, focus), Math.max(anchor, focus)] : null;
881
+ const sound = project.assets.filter((a) => a.kind !== "image");
882
+ const cut = (ranges, label, none) => {
883
+ if (!ranges.length) return setMessage(none);
884
+ const r = controller.apply(videoRemoveRangesEdits(ranges), label);
885
+ setMessage(r.ok ? null : r.error);
886
+ setAnchor(null);
887
+ setFocus(null);
888
+ };
889
+ const load = async (assetId, file) => {
890
+ if (!file) return;
891
+ try {
892
+ const r = controller.apply(videoTranscriptEdit(assetId, await file.text()), "Attach transcript");
893
+ setMessage(r.ok ? null : r.error);
894
+ } catch (e) {
895
+ setMessage(e instanceof Error ? e.message : String(e));
896
+ }
897
+ };
898
+ return /* @__PURE__ */ jsxs5("div", { className: `flex flex-col gap-2 text-xs ${className}`, "data-transcript-panel": "", children: [
899
+ words.length > 0 ? /* @__PURE__ */ jsxs5(Fragment5, { children: [
900
+ /* @__PURE__ */ jsx6(
901
+ "div",
902
+ {
903
+ className: "max-h-48 overflow-y-auto leading-6",
904
+ role: "listbox",
905
+ "aria-label": "Transcript",
906
+ "aria-multiselectable": "true",
907
+ tabIndex: 0,
908
+ onKeyDown: (e) => {
909
+ if (canEdit && selected && (e.key === "Delete" || e.key === "Backspace")) {
910
+ e.preventDefault();
911
+ cut([videoWordsRange(words, selected[0], selected[1])], "Cut words", "Select words to cut.");
912
+ }
913
+ },
914
+ children: words.map((w, i) => {
915
+ const inSelection = selected !== null && i >= selected[0] && i <= selected[1];
916
+ return /* @__PURE__ */ jsxs5(React4.Fragment, { children: [
917
+ /* @__PURE__ */ jsx6(
918
+ "span",
919
+ {
920
+ role: "option",
921
+ "aria-selected": inSelection,
922
+ tabIndex: -1,
923
+ className: `cursor-pointer rounded px-0.5 ${inSelection ? "bg-rose-500/30" : i === current ? "bg-amber-400/40" : "hover:bg-muted"}`,
924
+ "data-word": i,
925
+ "data-current": i === current ? "" : void 0,
926
+ "data-selected": inSelection ? "" : void 0,
927
+ onClick: (e) => {
928
+ if (e.shiftKey && anchor !== null) setFocus(i);
929
+ else {
930
+ setAnchor(i);
931
+ setFocus(i);
932
+ controller.seek(w.start);
933
+ }
934
+ },
935
+ children: w.word
936
+ }
937
+ ),
938
+ " "
939
+ ] }, `${w.clip}:${w.index}`);
940
+ })
941
+ }
942
+ ),
943
+ canEdit && /* @__PURE__ */ jsxs5("div", { className: "flex flex-wrap gap-1", children: [
944
+ /* @__PURE__ */ jsx6("button", { type: "button", className: btn3, disabled: !selected, onClick: () => selected && cut([videoWordsRange(words, selected[0], selected[1])], "Cut words", "Select words to cut."), children: "Delete selected words" }),
945
+ /* @__PURE__ */ jsx6("button", { type: "button", className: btn3, onClick: () => cut(findVideoFillers(words), "Remove fillers", "No filler words found."), children: "Remove fillers" }),
946
+ /* @__PURE__ */ jsx6("button", { type: "button", className: btn3, onClick: () => cut(findVideoTranscriptPauses(words, { fps: project.fps }), "Tighten pauses", "No long pauses found."), children: "Tighten pauses" })
947
+ ] })
948
+ ] }) : /* @__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." }),
949
+ canEdit && sound.length > 0 && /* @__PURE__ */ jsxs5("details", { children: [
950
+ /* @__PURE__ */ jsx6("summary", { className: "cursor-pointer text-muted-foreground", children: "Transcripts" }),
951
+ /* @__PURE__ */ jsx6("ul", { className: "mt-1 space-y-1", children: sound.map((a) => /* @__PURE__ */ jsxs5("li", { className: "flex items-center gap-2", children: [
952
+ /* @__PURE__ */ jsxs5("span", { className: "min-w-0 flex-1 truncate", children: [
953
+ a.name ?? a.id,
954
+ " ",
955
+ a.transcript ? `\xB7 ${a.transcript.length} words` : ""
956
+ ] }),
957
+ /* @__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]) })
958
+ ] }, a.id)) })
959
+ ] }),
960
+ message && /* @__PURE__ */ jsx6("p", { className: "text-red-500", role: "alert", "data-transcript-error": "", children: message })
961
+ ] });
962
+ }
963
+
852
964
  // src/video-editor/VideoEditor.tsx
853
965
  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 = "" }) {
966
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
967
+ function VideoEditor({ controller: given, options, editable = true, pxPerSecond, onPxPerSecondChange, resolution, showIssues = true, showTranscript = true, className = "" }) {
856
968
  const { controller, state } = useVideoEditor(given ?? options ?? {});
857
969
  useEffect4(() => {
858
970
  if (editable) return;
@@ -861,23 +973,25 @@ function VideoEditor({ controller: given, options, editable = true, pxPerSecond,
861
973
  return () => controller.setReadOnly(was);
862
974
  }, [controller, editable]);
863
975
  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 })
976
+ 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)) });
977
+ return /* @__PURE__ */ jsxs6("div", { className: `flex flex-col gap-3 ${className}`, "data-video-editor": "", children: [
978
+ /* @__PURE__ */ jsxs6("div", { className: `grid gap-3 ${editable ? "lg:grid-cols-[16rem_minmax(0,1fr)_18rem]" : ""}`, children: [
979
+ editable && /* @__PURE__ */ jsx7(MediaBin, { controller, className: "min-w-0" }),
980
+ /* @__PURE__ */ jsxs6("div", { className: "flex min-w-0 flex-col gap-2", children: [
981
+ /* @__PURE__ */ jsx7(VideoPreview, { controller, resolution, className: "rounded-md border border-border" }),
982
+ /* @__PURE__ */ jsx7(VideoTransport, { controller, editable })
871
983
  ] }),
872
- editable && /* @__PURE__ */ jsx6(ClipInspector, { controller, className: "min-w-0" })
984
+ editable && /* @__PURE__ */ jsx7(ClipInspector, { controller, className: "min-w-0" })
873
985
  ] }),
874
- /* @__PURE__ */ jsx6(VideoTimeline, { controller, editable, pxPerSecond, onPxPerSecondChange }),
986
+ /* @__PURE__ */ jsx7(VideoTimeline, { controller, editable, pxPerSecond, onPxPerSecondChange }),
987
+ showTranscript && /* @__PURE__ */ jsx7(TranscriptPanel, { controller, editable }),
875
988
  issueList
876
989
  ] });
877
990
  }
878
991
  export {
879
992
  ClipInspector,
880
993
  MediaBin,
994
+ TranscriptPanel,
881
995
  VideoEditor,
882
996
  VideoPreview,
883
997
  VideoTimeline,