@cut-workbench/workbench 0.2.0 → 0.3.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.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as formatResolution, c as CardContent, d as CardTitle, f as Badge, i as formatPercent, n as formatDuration, o as formatSeconds, p as cn, r as formatElapsed, s as Card, t as formatBytes, u as CardHeader } from "./format-CV2aoZZC.js";
1
+ import { _ as useUi, a as formatElapsed, b as Badge, c as formatSeconds, f as CardHeader, g as WorkbenchContext, h as DEFAULT_FEATURES, i as formatDuration, l as Card, m as DEFAULT_CONFIG, n as setDefaultBridge, o as formatPercent, p as CardTitle, r as formatBytes, s as formatResolution, t as notify, u as CardContent, v as useUiStoreApi, x as cn, y as useWorkbench } from "./notify-CEwFQgjC.js";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import * as React from "react";
4
4
  import { Suspense, createContext, createElement, lazy, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
@@ -9,7 +9,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
9
  import { AlertOctagon, AlertTriangle, Camera, CheckCircle2, CheckIcon, ChevronDownIcon, ChevronUpIcon, CircleCheckIcon, Copy, Download, Eye, EyeOff, Film, FlaskConical, FlipHorizontal2, FlipVertical2, FolderOpen, Image, Info, InfoIcon, Layers, Link2, Link2Off, Loader2, Loader2Icon, Lock, LockOpen, Magnet, Maximize2, Monitor, Music, OctagonXIcon, Pause, Play, Plus, Redo2, RotateCcw, RotateCw, Save, Scissors, Settings2, SkipBack, SkipForward, StepBack, StepForward, Subtitles, Trash2, TriangleAlertIcon, Type, Undo2, Upload, Video, Volume2, VolumeX, Wrench, X, XCircle, XIcon, ZoomIn, ZoomOut } from "lucide-react";
10
10
  import { cva } from "class-variance-authority";
11
11
  import { ContextMenu, Dialog, DropdownMenu, Label, Popover, Progress, ScrollArea, Select, Separator, Slider, Slot, Switch, Tabs, Tooltip } from "radix-ui";
12
- import { Toaster, toast } from "sonner";
12
+ import { Toaster } from "sonner";
13
13
  import { useDropzone } from "react-dropzone";
14
14
  import { createStore } from "zustand/vanilla";
15
15
  // src/components/layout/AppShell.tsx
@@ -305,44 +305,6 @@ function Row$1({ label, ok, optional }) {
305
305
  })]
306
306
  });
307
307
  }
308
- // src/context.tsx
309
- /**
310
- * 工作台自身的 Context:UI 状态(面板尺寸 / 缩放 / 对话框)+ 宿主桥。
311
- *
312
- * 与 `@cut-workbench/react` 的编辑器 Context 分开:编辑器是「数据与引擎」,
313
- * 这里是「某一次挂载的布局与交互」。两者生命周期可以不同(例如宿主切换项目时
314
- * 复用同一个编辑器、只重建 UI)。
315
- */
316
- var WorkbenchContext = createContext(null);
317
- function useWorkbench() {
318
- const value = useContext(WorkbenchContext);
319
- if (value == null) throw new Error("[cut-workbench] 缺少 <WorkbenchProvider>:组件必须挂在工作台内部");
320
- return value;
321
- }
322
- /** 订阅 UI 状态(带 selector) */
323
- function useUi(selector) {
324
- const { ui } = useWorkbench();
325
- return useSyncExternalStore(ui.subscribe, () => selector(ui.getState()), () => selector(ui.getState()));
326
- }
327
- /** 取 UI store 本体(事件回调里读写用) */
328
- function useUiStoreApi() {
329
- return useWorkbench().ui;
330
- }
331
- /** 默认功能开关 */
332
- var DEFAULT_FEATURES = {
333
- mediaLibrary: true,
334
- preview: true,
335
- timeline: true,
336
- inspector: true,
337
- export: true,
338
- canvasSettings: true,
339
- lab: false
340
- };
341
- var DEFAULT_CONFIG = {
342
- features: DEFAULT_FEATURES,
343
- keyboard: "global",
344
- resizable: true
345
- };
346
308
  // src/components/layout/WorkbenchToolbar.tsx
347
309
  /**
348
310
  * 内置顶栏(WorkbenchToolbar)。
@@ -1019,8 +981,164 @@ function EmptyState({ icon: Icon, title, description, action, className, compact
1019
981
  ]
1020
982
  });
1021
983
  }
984
+ // src/features/media-library/lib/pick-files.ts
985
+ var INPUT_ID = "cut-workbench-file-input";
986
+ /** 内置回退:隐藏 input,选完即弃 */
987
+ function pickWithInput(accept, multiple) {
988
+ return new Promise((resolve) => {
989
+ const input = document.createElement("input");
990
+ input.type = "file";
991
+ input.id = INPUT_ID;
992
+ input.multiple = multiple;
993
+ if (accept.length > 0) input.accept = accept.join(",");
994
+ input.style.display = "none";
995
+ document.body.appendChild(input);
996
+ const finish = (files) => {
997
+ input.remove();
998
+ resolve(files);
999
+ };
1000
+ input.addEventListener("change", () => finish(Array.from(input.files ?? [])), { once: true });
1001
+ window.addEventListener("focus", () => {
1002
+ window.setTimeout(() => {
1003
+ if (document.body.contains(input)) finish([]);
1004
+ }, 300);
1005
+ }, { once: true });
1006
+ input.click();
1007
+ });
1008
+ }
1009
+ /**
1010
+ * @returns 选中的文件;用户取消时为空数组
1011
+ */
1012
+ async function pickMediaFiles(bridge, options) {
1013
+ const multiple = options.multiple ?? true;
1014
+ if (bridge?.files?.pickFiles != null) {
1015
+ const result = await bridge.files.pickFiles({
1016
+ accept: options.accept,
1017
+ multiple,
1018
+ description: options.description
1019
+ });
1020
+ if (result != null) return result;
1021
+ }
1022
+ return pickWithInput(options.accept, multiple);
1023
+ }
1024
+ // src/features/media-library/hooks/useMediaImport.ts
1025
+ var ACCEPT = [
1026
+ "video/mp4",
1027
+ "audio/*",
1028
+ "image/*",
1029
+ ".srt",
1030
+ "text/plain"
1031
+ ];
1032
+ /**
1033
+ * 找出可以「重连」的素材。
1034
+ *
1035
+ * 场景:刷新后工程从 IndexedDB 恢复,但素材二进制消失(File 无法持久化)。
1036
+ * 用户重新导入同一个文件时,复用原 assetId,时间轴上已有片段立即恢复。
1037
+ * 匹配条件:同名 + 同类型 + 当前确实缺少二进制。
1038
+ */
1039
+ function findReconnectableAssetId(state, file, registry) {
1040
+ if (state.missingAssetIds.length === 0) return null;
1041
+ return state.assets.find((asset) => state.missingAssetIds.includes(asset.id) && asset.name === file.name && registry.getFile(asset.id) == null)?.id ?? null;
1042
+ }
1043
+ /**
1044
+ * 批量导入素材。
1045
+ *
1046
+ * - 串行处理,避免同时开辟多个解码器把内存打爆;
1047
+ * - 单个文件失败只影响自身,最后汇总 toast;
1048
+ * - 进度写在本地 state(不放进 store,避免高频更新引起全局重渲染);
1049
+ * - 若与「缺失源文件」的素材同名同类型,则重连而不是新增。
1050
+ */
1051
+ function useMediaImport() {
1052
+ const editor = useCutEditor();
1053
+ const clips = editor.runtime.clips;
1054
+ const store = editor.store;
1055
+ const [progress, setProgress] = useState([]);
1056
+ const [importing, setImporting] = useState(false);
1057
+ const addAsset = useEditor((s) => s.addAsset);
1058
+ const replaceAsset = useEditor((s) => s.replaceAsset);
1059
+ const clearMissing = useEditor((s) => s.clearMissing);
1060
+ const selectAsset = useEditor((s) => s.selectAsset);
1061
+ const beginImport = useUi((s) => s.beginImport);
1062
+ const endImport = useUi((s) => s.endImport);
1063
+ const { bridge } = useWorkbench();
1064
+ return {
1065
+ importing,
1066
+ progress,
1067
+ importFiles: useCallback(async (files) => {
1068
+ if (files.length === 0) return [];
1069
+ setImporting(true);
1070
+ beginImport();
1071
+ setProgress(files.map((f) => ({
1072
+ name: f.name,
1073
+ progress: 0
1074
+ })));
1075
+ const imported = [];
1076
+ const failures = [];
1077
+ let reconnected = 0;
1078
+ for (let i = 0; i < files.length; i += 1) {
1079
+ const file = files[i];
1080
+ if (!file) continue;
1081
+ try {
1082
+ const reconnectId = findReconnectableAssetId(store.getState(), file, clips);
1083
+ const result = await importFile(clips, file, (p) => {
1084
+ setProgress((prev) => prev.map((item, index) => index === i ? {
1085
+ ...item,
1086
+ progress: p
1087
+ } : item));
1088
+ });
1089
+ if (reconnectId != null) {
1090
+ clips.replaceAssetSource(reconnectId, file, result.master ?? null);
1091
+ replaceAsset(reconnectId, result.asset);
1092
+ clearMissing(reconnectId);
1093
+ imported.push({
1094
+ ...result.asset,
1095
+ id: reconnectId
1096
+ });
1097
+ reconnected += 1;
1098
+ } else {
1099
+ addAsset(result.asset);
1100
+ imported.push(result.asset);
1101
+ }
1102
+ } catch (err) {
1103
+ const message = err instanceof UnsupportedFileError ? err.message : err instanceof Error ? err.message : String(err);
1104
+ failures.push(`${file.name}:${message}`);
1105
+ setProgress((prev) => prev.map((item, index) => index === i ? {
1106
+ ...item,
1107
+ error: message
1108
+ } : item));
1109
+ }
1110
+ }
1111
+ setImporting(false);
1112
+ endImport();
1113
+ if (imported.length > 0) {
1114
+ selectAsset(imported[0]?.id ?? null);
1115
+ notify(bridge, {
1116
+ level: "success",
1117
+ title: reconnected > 0 ? `已导入 ${imported.length} 个素材(其中 ${reconnected} 个重连到已有片段)` : `已导入 ${imported.length} 个素材`
1118
+ });
1119
+ }
1120
+ if (failures.length > 0) notify(bridge, {
1121
+ level: "error",
1122
+ title: `${failures.length} 个文件导入失败`,
1123
+ description: failures.slice(0, 3).join("\n"),
1124
+ duration: 8e3
1125
+ });
1126
+ window.setTimeout(() => setProgress([]), 3e3);
1127
+ return imported;
1128
+ }, [
1129
+ addAsset,
1130
+ beginImport,
1131
+ bridge,
1132
+ clearMissing,
1133
+ endImport,
1134
+ replaceAsset,
1135
+ selectAsset
1136
+ ])
1137
+ };
1138
+ }
1022
1139
  // src/features/media-library/components/DropZone.tsx
1023
- var ACCEPT = {
1140
+ /** react-dropzone 用的 MIME → 扩展名映射 */
1141
+ var DROPZONE_ACCEPT = {
1024
1142
  "video/mp4": [".mp4", ".m4v"],
1025
1143
  "audio/*": [
1026
1144
  ".mp3",
@@ -1043,22 +1161,45 @@ var ACCEPT = {
1043
1161
  /**
1044
1162
  * 拖拽 / 点击导入区域。
1045
1163
  *
1164
+ * 两条入口:
1165
+ * - **拖拽**:走 react-dropzone(宿主无法介入,这是浏览器原生行为);
1166
+ * - **点击**:优先走 `HostBridge.files.pickFiles` —— 宿主可能不用系统文件
1167
+ * 对话框,而是从自己的素材库 / 云端 / URL 导入。宿主返回 `null`(不接管)
1168
+ * 时才回退到内置 `<input type=file>`。
1169
+ *
1046
1170
  * 注意:react-dropzone 的 accept 只做初筛,真正按 MIME + 扩展名校验在
1047
1171
  * core/media/file-reader,从而保证「拖入 .webm 得到明确错误」而不是被静默拒绝。
1048
1172
  */
1049
1173
  function DropZone({ onFiles, disabled, className, compact }) {
1050
- const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
1174
+ const { bridge } = useWorkbench();
1175
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
1051
1176
  onDrop: (accepted) => {
1052
1177
  if (accepted.length > 0) onFiles(accepted);
1053
1178
  },
1054
- noClick: compact,
1055
- noKeyboard: compact,
1179
+ noClick: true,
1180
+ noKeyboard: true,
1056
1181
  multiple: true,
1057
1182
  disabled,
1058
- accept: ACCEPT
1059
- });
1183
+ accept: DROPZONE_ACCEPT
1184
+ });
1185
+ const handleClick = useCallback(() => {
1186
+ if (disabled) return;
1187
+ pickMediaFiles(bridge, {
1188
+ accept: ACCEPT,
1189
+ multiple: true,
1190
+ description: "选择要导入的素材"
1191
+ }).then((files) => {
1192
+ if (files.length > 0) onFiles(files);
1193
+ });
1194
+ }, [
1195
+ bridge,
1196
+ disabled,
1197
+ onFiles
1198
+ ]);
1060
1199
  return /* @__PURE__ */ jsxs("div", {
1061
1200
  ...getRootProps(),
1201
+ onClick: handleClick,
1202
+ "data-drop-zone": "",
1062
1203
  className: cn("group relative flex cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed text-center transition-colors", compact ? "px-3 py-2.5" : "px-4 py-8", isDragActive ? "border-primary bg-primary/10" : "border-border hover:border-primary/60 hover:bg-muted/40", disabled && "pointer-events-none opacity-50", className),
1063
1204
  children: [
1064
1205
  /* @__PURE__ */ jsx("input", { ...getInputProps() }),
@@ -1075,7 +1216,7 @@ function DropZone({ onFiles, disabled, className, compact }) {
1075
1216
  className: "sr-only",
1076
1217
  onClick: (e) => {
1077
1218
  e.stopPropagation();
1078
- open();
1219
+ handleClick();
1079
1220
  },
1080
1221
  children: "选择文件"
1081
1222
  })
@@ -1296,107 +1437,6 @@ function MediaGrid({ assets, selectedAssetId, missingAssetIds = [], progress, on
1296
1437
  ]
1297
1438
  });
1298
1439
  }
1299
- // src/features/media-library/hooks/useMediaImport.ts
1300
- /**
1301
- * 找出可以「重连」的素材。
1302
- *
1303
- * 场景:刷新后工程从 IndexedDB 恢复,但素材二进制消失(File 无法持久化)。
1304
- * 用户重新导入同一个文件时,复用原 assetId,时间轴上已有片段立即恢复。
1305
- * 匹配条件:同名 + 同类型 + 当前确实缺少二进制。
1306
- */
1307
- function findReconnectableAssetId(state, file, registry) {
1308
- if (state.missingAssetIds.length === 0) return null;
1309
- return state.assets.find((asset) => state.missingAssetIds.includes(asset.id) && asset.name === file.name && registry.getFile(asset.id) == null)?.id ?? null;
1310
- }
1311
- /**
1312
- * 批量导入素材。
1313
- *
1314
- * - 串行处理,避免同时开辟多个解码器把内存打爆;
1315
- * - 单个文件失败只影响自身,最后汇总 toast;
1316
- * - 进度写在本地 state(不放进 store,避免高频更新引起全局重渲染);
1317
- * - 若与「缺失源文件」的素材同名同类型,则重连而不是新增。
1318
- */
1319
- function useMediaImport() {
1320
- const editor = useCutEditor();
1321
- const clips = editor.runtime.clips;
1322
- const store = editor.store;
1323
- const [progress, setProgress] = useState([]);
1324
- const [importing, setImporting] = useState(false);
1325
- const addAsset = useEditor((s) => s.addAsset);
1326
- const replaceAsset = useEditor((s) => s.replaceAsset);
1327
- const clearMissing = useEditor((s) => s.clearMissing);
1328
- const selectAsset = useEditor((s) => s.selectAsset);
1329
- const beginImport = useUi((s) => s.beginImport);
1330
- const endImport = useUi((s) => s.endImport);
1331
- return {
1332
- importing,
1333
- progress,
1334
- importFiles: useCallback(async (files) => {
1335
- if (files.length === 0) return [];
1336
- setImporting(true);
1337
- beginImport();
1338
- setProgress(files.map((f) => ({
1339
- name: f.name,
1340
- progress: 0
1341
- })));
1342
- const imported = [];
1343
- const failures = [];
1344
- let reconnected = 0;
1345
- for (let i = 0; i < files.length; i += 1) {
1346
- const file = files[i];
1347
- if (!file) continue;
1348
- try {
1349
- const reconnectId = findReconnectableAssetId(store.getState(), file, clips);
1350
- const result = await importFile(clips, file, (p) => {
1351
- setProgress((prev) => prev.map((item, index) => index === i ? {
1352
- ...item,
1353
- progress: p
1354
- } : item));
1355
- });
1356
- if (reconnectId != null) {
1357
- clips.replaceAssetSource(reconnectId, file, result.master ?? null);
1358
- replaceAsset(reconnectId, result.asset);
1359
- clearMissing(reconnectId);
1360
- imported.push({
1361
- ...result.asset,
1362
- id: reconnectId
1363
- });
1364
- reconnected += 1;
1365
- } else {
1366
- addAsset(result.asset);
1367
- imported.push(result.asset);
1368
- }
1369
- } catch (err) {
1370
- const message = err instanceof UnsupportedFileError ? err.message : err instanceof Error ? err.message : String(err);
1371
- failures.push(`${file.name}:${message}`);
1372
- setProgress((prev) => prev.map((item, index) => index === i ? {
1373
- ...item,
1374
- error: message
1375
- } : item));
1376
- }
1377
- }
1378
- setImporting(false);
1379
- endImport();
1380
- if (imported.length > 0) {
1381
- selectAsset(imported[0]?.id ?? null);
1382
- toast.success(reconnected > 0 ? `已导入 ${imported.length} 个素材(其中 ${reconnected} 个重连到已有片段)` : `已导入 ${imported.length} 个素材`);
1383
- }
1384
- if (failures.length > 0) toast.error(`${failures.length} 个文件导入失败`, {
1385
- description: failures.slice(0, 3).join("\n"),
1386
- duration: 8e3
1387
- });
1388
- window.setTimeout(() => setProgress([]), 3e3);
1389
- return imported;
1390
- }, [
1391
- addAsset,
1392
- beginImport,
1393
- clearMissing,
1394
- endImport,
1395
- replaceAsset,
1396
- selectAsset
1397
- ])
1398
- };
1399
- }
1400
1440
  // src/features/media-library/components/MediaLibraryPanel.tsx
1401
1441
  var KIND_LABEL$1 = {
1402
1442
  video: "视频",
@@ -1564,6 +1604,7 @@ function CreateTextDialog({ open, onOpenChange, onCreated }) {
1564
1604
  const projectWidth = useEditor((s) => s.project.width);
1565
1605
  const projectHeight = useEditor((s) => s.project.height);
1566
1606
  const addAsset = useEditor((s) => s.addAsset);
1607
+ const { bridge } = useWorkbench();
1567
1608
  const handleCreate = () => {
1568
1609
  const { asset } = createTextAsset({
1569
1610
  registry: editor.runtime.clips,
@@ -1574,7 +1615,10 @@ function CreateTextDialog({ open, onOpenChange, onCreated }) {
1574
1615
  addAsset(asset);
1575
1616
  onCreated(asset.id);
1576
1617
  onOpenChange(false);
1577
- toast.success("已创建文字素材");
1618
+ notify(bridge, {
1619
+ level: "success",
1620
+ title: "已创建文字素材"
1621
+ });
1578
1622
  };
1579
1623
  return /* @__PURE__ */ jsx(Dialog$1, {
1580
1624
  open,
@@ -1608,10 +1652,15 @@ function CreateSubtitleDialog({ open, onOpenChange, onCreated }) {
1608
1652
  const projectWidth = useEditor((s) => s.project.width);
1609
1653
  const projectHeight = useEditor((s) => s.project.height);
1610
1654
  const addAsset = useEditor((s) => s.addAsset);
1655
+ const { bridge } = useWorkbench();
1611
1656
  const handleCreate = () => {
1612
1657
  const durationUs = parseSrtDurationUs(srt);
1613
1658
  if (durationUs <= 0) {
1614
- toast.error("SRT 解析失败", { description: "请检查时间轴格式:00:00:00,000 --> 00:00:02,000" });
1659
+ notify(bridge, {
1660
+ level: "error",
1661
+ title: "SRT 解析失败",
1662
+ description: "请检查时间轴格式:00:00:00,000 --> 00:00:02,000"
1663
+ });
1615
1664
  return;
1616
1665
  }
1617
1666
  const { asset } = createSubtitleAsset({
@@ -1624,7 +1673,10 @@ function CreateSubtitleDialog({ open, onOpenChange, onCreated }) {
1624
1673
  addAsset(asset);
1625
1674
  onCreated(asset.id);
1626
1675
  onOpenChange(false);
1627
- toast.success("已创建字幕素材");
1676
+ notify(bridge, {
1677
+ level: "success",
1678
+ title: "已创建字幕素材"
1679
+ });
1628
1680
  };
1629
1681
  return /* @__PURE__ */ jsx(Dialog$1, {
1630
1682
  open,
@@ -1650,11 +1702,22 @@ function CreateSubtitleDialog({ open, onOpenChange, onCreated }) {
1650
1702
  });
1651
1703
  }
1652
1704
  // src/hooks/use-toast-safe.ts
1653
- /** 统一把异常转成可见提示,禁止静默吞掉 */
1705
+ /**
1706
+ * 把异常转成可见提示,禁止静默吞掉。
1707
+ *
1708
+ * 这是**非 hook** 版本,供事件回调 / 非组件代码调用(例如 `useAvCanvas` 的
1709
+ * 初始化失败)。它拿不到 React context,因此走 `notify` 注册的默认桥:
1710
+ * `WorkbenchProvider` 挂载时会把 `bridge` 注册进来,宿主实现了 `notify`
1711
+ * 就交给宿主,否则退回库内 toast。
1712
+ */
1654
1713
  function reportError(error, fallback = "操作失败") {
1655
1714
  const message = error instanceof Error ? error.message : String(error);
1656
1715
  console.error(error);
1657
- toast.error(fallback, { description: message });
1716
+ notify(void 0, {
1717
+ level: "error",
1718
+ title: fallback,
1719
+ description: message
1720
+ });
1658
1721
  }
1659
1722
  // src/features/preview/hooks/useAvCanvas.ts
1660
1723
  /**
@@ -2574,6 +2637,7 @@ function PreviewPanel() {
2574
2637
  const setMasterVolume = useEditor((s) => s.setMasterVolume);
2575
2638
  const playheadUs = useEditor((s) => s.playheadUs);
2576
2639
  const selectedClipIds = useEditor((s) => s.selectedClipIds);
2640
+ const { bridge } = useWorkbench();
2577
2641
  const canvas = useAvCanvas({
2578
2642
  width: project.width,
2579
2643
  height: project.height
@@ -2584,12 +2648,18 @@ function PreviewPanel() {
2584
2648
  if (errors.length === 0) return;
2585
2649
  const missing = errors.filter((e) => e.message.startsWith("素材文件未载入"));
2586
2650
  const other = errors.filter((e) => !e.message.startsWith("素材文件未载入"));
2587
- if (missing.length > 0) toast.warning(`${missing.length} 个片段缺少源文件,已跳过预览`, {
2651
+ if (missing.length > 0) notify(bridge, {
2652
+ level: "warning",
2653
+ title: `${missing.length} 个片段缺少源文件,已跳过预览`,
2588
2654
  description: "刷新后素材二进制不会保留,请重新导入相同文件",
2589
2655
  duration: 8e3
2590
2656
  });
2591
- if (other.length > 0) toast.error("部分片段无法载入预览", { description: Array.from(new Set(other.map((e) => e.message))).slice(0, 3).join("\n") });
2592
- }, []);
2657
+ if (other.length > 0) notify(bridge, {
2658
+ level: "error",
2659
+ title: "部分片段无法载入预览",
2660
+ description: Array.from(new Set(other.map((e) => e.message))).slice(0, 3).join("\n")
2661
+ });
2662
+ }, [bridge]);
2593
2663
  const clock = usePlaybackClock({
2594
2664
  project,
2595
2665
  assets,
@@ -2725,12 +2795,18 @@ function PreviewPanel() {
2725
2795
  const handleCapture = useCallback(() => {
2726
2796
  const dataUrl = canvas.captureImage();
2727
2797
  if (dataUrl == null) {
2728
- toast.error("截图失败:画布尚未就绪");
2798
+ notify(bridge, {
2799
+ level: "error",
2800
+ title: "截图失败:画布尚未就绪"
2801
+ });
2729
2802
  return;
2730
2803
  }
2731
2804
  downloadDataUrl(dataUrl, `frame-${Date.now()}.png`);
2732
- toast.success("已导出当前帧");
2733
- }, [canvas]);
2805
+ notify(bridge, {
2806
+ level: "success",
2807
+ title: "已导出当前帧"
2808
+ });
2809
+ }, [bridge, canvas]);
2734
2810
  const handleFullscreen = () => {
2735
2811
  const el = canvas.containerRef.current?.parentElement;
2736
2812
  if (el == null) return;
@@ -6184,16 +6260,23 @@ function useAddToTimeline() {
6184
6260
  const assets = useEditor((s) => s.assets);
6185
6261
  const playheadUs = useEditor((s) => s.playheadUs);
6186
6262
  const addClip = useEditor((s) => s.addClip);
6263
+ const { bridge } = useWorkbench();
6187
6264
  return useCallback((assetId) => {
6188
6265
  const asset = assets.find((a) => a.id === assetId);
6189
6266
  if (!asset) {
6190
- toast.error("素材不存在,可能已被删除");
6267
+ notify(bridge, {
6268
+ level: "error",
6269
+ title: "素材不存在,可能已被删除"
6270
+ });
6191
6271
  return;
6192
6272
  }
6193
6273
  const kindIsAudio = asset.kind === "audio";
6194
6274
  const track = project.tracks.find((t) => t.kind === (kindIsAudio ? "audio" : "visual")) ?? project.tracks[0];
6195
6275
  if (!track) {
6196
- toast.error("没有可用轨道");
6276
+ notify(bridge, {
6277
+ level: "error",
6278
+ title: "没有可用轨道"
6279
+ });
6197
6280
  return;
6198
6281
  }
6199
6282
  const trackEnd = getTrackEnd(project, track.id);
@@ -6226,6 +6309,7 @@ function useAddToTimeline() {
6226
6309
  }, [
6227
6310
  addClip,
6228
6311
  assets,
6312
+ bridge,
6229
6313
  playheadUs,
6230
6314
  project
6231
6315
  ]);
@@ -7516,6 +7600,10 @@ function CutWorkbenchProvider({ children, bridge, theme = "dark", tokens, config
7516
7600
  useCutEditor();
7517
7601
  const [ui] = useState(() => createUiStore());
7518
7602
  const resolvedBridge = useMemo(() => bridge ?? createDefaultBridge(), [bridge]);
7603
+ useEffect(() => {
7604
+ setDefaultBridge(resolvedBridge);
7605
+ return () => setDefaultBridge(null);
7606
+ }, [resolvedBridge]);
7519
7607
  const mergedConfig = useMemo(() => ({
7520
7608
  ...DEFAULT_CONFIG,
7521
7609
  ...config,