@cut-workbench/workbench 0.1.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,68 +1,75 @@
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";
1
2
  import { createRoot } from "react-dom/client";
2
3
  import * as React from "react";
3
- import { createContext, createElement, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
- import { PreviewCanvas, UnsupportedFileError, checkExportSupport, computeTimelineEnd, createEditor, createSubtitleAsset, createTextAsset, detectEnvironment, exportProject, fixFMP4DurationStream, importFile, isEnvironmentUsable, loadAudioClipForAsset, loadPcmForAsset, summarizeCapabilities } from "@cut-workbench/core/engine";
5
- import { ASPECT_PRESETS, DEFAULT_STILL_DURATION_US, EMPTY_SNAP, RESIZE_HANDLES, buildTicks, buildWaveformPath, clamp, computeSnapWithHysteresis, createClip, createIndexedDbStorage, createMemoryStorage, createWebBridge, deserializeProject, formatDuration, formatTimecode, getClipEnd, getProjectDuration, getTrack, getTrackEnd, handleCursor, handleLocalOffset, normalizeAngle, normalizeSize, parseSrt, parseSrtDurationUs, pxToUs, ratioLabel, resizeTransform, sameTransform, usToPx, usToSec } from "@cut-workbench/core";
4
+ import { Suspense, createContext, createElement, lazy, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
+ import { ENGINE_DEPENDENCIES_INSTALL_COMMAND, PreviewCanvas, UnsupportedFileError, checkExportSupport, computeTimelineEnd, createEditor, createSubtitleAsset, createTextAsset, detectEnvironment, exportProject, fixFMP4DurationStream, importFile, isEnvironmentUsable, loadAudioClipForAsset, loadPcmForAsset, summarizeCapabilities } from "@cut-workbench/core/engine";
6
+ import { ASPECT_PRESETS, DEFAULT_STILL_DURATION_US, EMPTY_SNAP, RESIZE_HANDLES, buildTicks, buildWaveformPath, clamp, computeSnapWithHysteresis, createClip, createIndexedDbStorage, createMemoryStorage, createWebBridge, deserializeProject, formatTimecode, getClipEnd, getProjectDuration, getTrack, getTrackEnd, handleCursor, handleLocalOffset, normalizeAngle, normalizeSize, parseSrt, parseSrtDurationUs, pxToUs, ratioLabel, resizeTransform, sameTransform, usToPx, usToSec } from "@cut-workbench/core";
6
7
  import { CutEditorProvider, useCutEditor, useEditor, useHistory } from "@cut-workbench/react";
7
- import { clsx } from "clsx";
8
- import { twMerge } from "tailwind-merge";
9
8
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
- import { AlertOctagon, AlertTriangle, Camera, CheckCircle2, CheckIcon, ChevronDownIcon, ChevronUpIcon, Circle, CircleCheckIcon, Clapperboard, 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, Split, Square, StepBack, StepForward, Subtitles, Trash2, TriangleAlertIcon, Type, Undo2, Upload, Video, Volume2, VolumeX, Wrench, X, XCircle, XIcon, ZoomIn, ZoomOut } from "lucide-react";
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";
11
10
  import { cva } from "class-variance-authority";
12
11
  import { ContextMenu, Dialog, DropdownMenu, Label, Popover, Progress, ScrollArea, Select, Separator, Slider, Slot, Switch, Tabs, Tooltip } from "radix-ui";
13
- import { Toaster, toast } from "sonner";
12
+ import { Toaster } from "sonner";
14
13
  import { useDropzone } from "react-dropzone";
15
- import { AVRecorder, FrameCounterClip, burnSubtitles, concatMp4, createDemoCanvasStream, createMp4Clip, createSubtitlePreviewClip, exportCustomClip, extractKeyframes, fixRecordedDuration, formatRecorderState, isWebGL2Available, mixAudioIntoMp4, readMp4Info, renderChromakeyFrame, renderClipFrameToCanvas, renderSubtitleFrame, splitMp4Tracks } from "@cut-workbench/core/lab";
16
14
  import { createStore } from "zustand/vanilla";
17
- // src/lib/cn.ts
18
- /**
19
- * 合并 className:tailwind-merge 负责消解冲突,clsx 负责条件拼接。
20
- */
21
- function cn(...inputs) {
22
- return twMerge(clsx(inputs));
23
- }
24
15
  // src/components/layout/AppShell.tsx
25
16
  /**
26
- * 三栏 + 底部时间轴骨架。
17
+ * 工作台骨架:顶栏 + 上半区(素材 / 预览 / 属性)+ 底部通栏时间轴。
18
+ *
19
+ * ```
20
+ * ┌───────────────────────────────────────────┐
21
+ * │ 顶栏 │
22
+ * ├──────────┬──────────────┬─────────────────┤
23
+ * │ 素材库 │ 预览 │ 属性 │ 上半区
24
+ * ├──────────┴──────────────┴─────────────────┤
25
+ * │ 时间轴(横跨整行) │ 下栏
26
+ * └───────────────────────────────────────────┘
27
+ * ```
27
28
  *
28
- * 布局用 inline style 的像素宽度做「受控」尺寸,配合 PanelResizer 的拖拽回写;
29
- * 这样不会因为 tailwind 的动态 class 被 purge 掉而失效。
29
+ * 时间轴必须是**通栏**的:它曾被塞进中间列,于是左右面板一拉宽就把时间轴
30
+ * 挤窄,工具条上的按钮直接溢出到属性面板上(实测右栏拉宽时溢出 203px)。
31
+ * 与此同时,几乎所有 NLE 都是「轨道占满底部一条」,通栏也更符合预期。
32
+ *
33
+ * 尺寸一律用 inline style 的像素值(受控),配合 PanelResizer 的拖拽回写;
34
+ * 不用 tailwind 动态 class,避免被 purge 掉导致尺寸失效。
30
35
  */
31
36
  function AppShell({ topBar, left, center, right, timeline, leftWidth, rightWidth, timelineHeight, leftResizer, rightResizer, timelineResizer, className }) {
32
37
  return /* @__PURE__ */ jsxs("div", {
33
38
  className: cn("flex h-full min-h-0 flex-col bg-background", className),
34
- children: [topBar, /* @__PURE__ */ jsxs("div", {
35
- className: "flex min-h-0 flex-1",
36
- children: [
37
- /* @__PURE__ */ jsx("aside", {
38
- className: "flex min-h-0 shrink-0 flex-col border-r bg-card/40",
39
- style: { width: leftWidth },
40
- children: left
41
- }),
42
- leftResizer,
43
- /* @__PURE__ */ jsxs("main", {
44
- className: "flex min-w-0 flex-1 flex-col",
45
- children: [
46
- /* @__PURE__ */ jsx("div", {
47
- className: "flex min-h-0 flex-1 flex-col",
48
- children: center
49
- }),
50
- timelineResizer,
51
- /* @__PURE__ */ jsx("section", {
52
- className: "flex min-h-0 shrink-0 flex-col border-t bg-timeline-bg",
53
- style: { height: timelineHeight },
54
- children: timeline
55
- })
56
- ]
57
- }),
58
- rightResizer,
59
- /* @__PURE__ */ jsx("aside", {
60
- className: "flex min-h-0 shrink-0 flex-col border-l bg-card/40",
61
- style: { width: rightWidth },
62
- children: right
63
- })
64
- ]
65
- })]
39
+ children: [
40
+ topBar,
41
+ /* @__PURE__ */ jsxs("div", {
42
+ className: "flex min-h-0 flex-1 overflow-x-hidden overflow-y-auto",
43
+ children: [
44
+ /* @__PURE__ */ jsx("aside", {
45
+ className: "flex min-h-[220px] shrink-0 flex-col overflow-hidden border-r bg-card/40",
46
+ style: { width: leftWidth },
47
+ "data-panel": "media",
48
+ children: left
49
+ }),
50
+ leftResizer,
51
+ /* @__PURE__ */ jsx("main", {
52
+ className: "flex min-h-[220px] min-w-0 flex-1 flex-col overflow-hidden",
53
+ "data-panel": "preview",
54
+ children: center
55
+ }),
56
+ rightResizer,
57
+ /* @__PURE__ */ jsx("aside", {
58
+ className: "flex min-h-[220px] shrink-0 flex-col overflow-hidden border-l bg-card/40",
59
+ style: { width: rightWidth },
60
+ "data-panel": "inspector",
61
+ children: right
62
+ })
63
+ ]
64
+ }),
65
+ timelineResizer,
66
+ /* @__PURE__ */ jsx("section", {
67
+ className: "flex min-h-0 shrink-0 flex-col overflow-hidden border-t bg-timeline-bg",
68
+ style: { height: timelineHeight },
69
+ "data-panel": "timeline",
70
+ children: timeline
71
+ })
72
+ ]
66
73
  });
67
74
  }
68
75
  // src/components/ui/button.tsx
@@ -197,27 +204,6 @@ function TooltipContent({ className, sideOffset = 0, children, ...props }) {
197
204
  })
198
205
  });
199
206
  }
200
- // src/components/ui/badge.tsx
201
- var badgeVariants = cva("inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", {
202
- variants: { variant: {
203
- default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
204
- secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
205
- destructive: "bg-destructive text-destructive-foreground focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
206
- outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
207
- ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
208
- link: "text-primary underline-offset-4 [a&]:hover:underline"
209
- } },
210
- defaultVariants: { variant: "default" }
211
- });
212
- function Badge({ className, variant = "default", asChild = false, ...props }) {
213
- const Comp = asChild ? Slot.Root : "span";
214
- return /* @__PURE__ */ jsx(Comp, {
215
- "data-slot": "badge",
216
- "data-variant": variant,
217
- className: cn(badgeVariants({ variant }), className),
218
- ...props
219
- });
220
- }
221
207
  // src/components/ui/popover.tsx
222
208
  function Popover$1({ ...props }) {
223
209
  return /* @__PURE__ */ jsx(Popover.Root, {
@@ -319,44 +305,6 @@ function Row$1({ label, ok, optional }) {
319
305
  })]
320
306
  });
321
307
  }
322
- // src/context.tsx
323
- /**
324
- * 工作台自身的 Context:UI 状态(面板尺寸 / 缩放 / 对话框)+ 宿主桥。
325
- *
326
- * 与 `@cut-workbench/react` 的编辑器 Context 分开:编辑器是「数据与引擎」,
327
- * 这里是「某一次挂载的布局与交互」。两者生命周期可以不同(例如宿主切换项目时
328
- * 复用同一个编辑器、只重建 UI)。
329
- */
330
- var WorkbenchContext = createContext(null);
331
- function useWorkbench() {
332
- const value = useContext(WorkbenchContext);
333
- if (value == null) throw new Error("[cut-workbench] 缺少 <WorkbenchProvider>:组件必须挂在工作台内部");
334
- return value;
335
- }
336
- /** 订阅 UI 状态(带 selector) */
337
- function useUi(selector) {
338
- const { ui } = useWorkbench();
339
- return useSyncExternalStore(ui.subscribe, () => selector(ui.getState()), () => selector(ui.getState()));
340
- }
341
- /** 取 UI store 本体(事件回调里读写用) */
342
- function useUiStoreApi() {
343
- return useWorkbench().ui;
344
- }
345
- /** 默认功能开关 */
346
- var DEFAULT_FEATURES = {
347
- mediaLibrary: true,
348
- preview: true,
349
- timeline: true,
350
- inspector: true,
351
- export: true,
352
- canvasSettings: true,
353
- lab: false
354
- };
355
- var DEFAULT_CONFIG = {
356
- features: DEFAULT_FEATURES,
357
- keyboard: "global",
358
- resizable: true
359
- };
360
308
  // src/components/layout/WorkbenchToolbar.tsx
361
309
  /**
362
310
  * 内置顶栏(WorkbenchToolbar)。
@@ -365,10 +313,17 @@ var DEFAULT_CONFIG = {
365
313
  * - 品牌区可替换(`brand`),不强制显示项目名;
366
314
  * - 保存/打开是可选回调,宿主不提供就不渲染按钮;
367
315
  * - 「视图切换(编辑器/实验室)」只在开启 lab 特性时显示。
316
+ *
317
+ * 可自定义的层次(由浅到深):
318
+ * 1. **文案**:`labels` 覆盖工程名占位、能力徽标等;
319
+ * 2. **局部**:`brand` / `title` / `status` / `actions` / `showCapabilityBadge`
320
+ * 逐块替换;
321
+ * 3. **整条**:`slots.topBar`(传 `null` 表示完全不要顶栏)。
368
322
  */
369
323
  /** 顶栏:品牌 / 工程名 / 撤销重做 / 保存 / 导出 / 能力徽标 / 视图切换 */
370
- function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject, brand, showLab = false }) {
324
+ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject, brand, title, status, actions, showCapabilityBadge = true, showLab = false, editableTitle = false, labels }) {
371
325
  const projectName = useEditor((s) => s.project.name);
326
+ const renameProject = useEditor((s) => s.renameProject);
372
327
  const dirty = useEditor((s) => s.dirty);
373
328
  const undo = useEditor((s) => s.undo);
374
329
  const redo = useEditor((s) => s.redo);
@@ -380,44 +335,49 @@ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject,
380
335
  const clipCount = useEditor((s) => s.project.clips.length);
381
336
  const projectWidth = useEditor((s) => s.project.width);
382
337
  const projectHeight = useEditor((s) => s.project.height);
383
- const branding = brand === null ? null : brand ?? /* @__PURE__ */ jsxs("div", {
384
- className: "flex items-center gap-2 pr-2",
385
- children: [/* @__PURE__ */ jsx("div", {
386
- className: "flex size-7 items-center justify-center rounded-md bg-primary text-primary-foreground",
387
- children: /* @__PURE__ */ jsx(Clapperboard, { className: "size-4" })
388
- }), /* @__PURE__ */ jsx("span", {
389
- className: "text-sm font-semibold tracking-tight",
390
- children: "Cut Workbench"
391
- })]
338
+ const text = {
339
+ untitledProject: "未命名工程",
340
+ emptyProject: "空工程",
341
+ export: "导出",
342
+ canvasSettings: "画布设置",
343
+ showLab: "能力实验室",
344
+ backToEditor: "返回编辑器",
345
+ save: "保存工程",
346
+ open: "加载工程",
347
+ undo: "撤销",
348
+ redo: "重做",
349
+ ...labels
350
+ };
351
+ const branding = brand == null ? null : brand;
352
+ const projectTitle = title === null ? null : title ?? /* @__PURE__ */ jsx(ProjectTitle, {
353
+ name: projectName,
354
+ dirty,
355
+ placeholder: text.untitledProject,
356
+ editable: editableTitle,
357
+ onRename: renameProject
358
+ });
359
+ const statusNode = status === null ? null : status ?? /* @__PURE__ */ jsxs("span", {
360
+ className: "hidden text-xs text-muted-foreground sm:inline",
361
+ children: [
362
+ clipCount > 0 ? `${clipCount} 个片段` : text.emptyProject,
363
+ " ·",
364
+ " ",
365
+ ratioLabel(projectWidth, projectHeight)
366
+ ]
392
367
  });
393
368
  return /* @__PURE__ */ jsxs("header", {
394
- className: "flex h-12 shrink-0 items-center gap-2 border-b bg-card px-3",
369
+ className: "flex h-12 shrink-0 items-center gap-2 overflow-hidden border-b bg-card px-3",
370
+ "data-workbench-toolbar": true,
395
371
  children: [
396
372
  branding,
397
- branding != null && /* @__PURE__ */ jsx(Separator$1, {
373
+ branding != null && projectTitle != null && /* @__PURE__ */ jsx(Separator$1, {
398
374
  orientation: "vertical",
399
375
  className: "h-5"
400
376
  }),
377
+ projectTitle,
378
+ statusNode,
401
379
  /* @__PURE__ */ jsxs("div", {
402
- className: "flex min-w-0 items-center gap-1.5",
403
- children: [/* @__PURE__ */ jsx("span", {
404
- className: "truncate text-sm font-medium",
405
- children: projectName
406
- }), dirty && /* @__PURE__ */ jsx("span", {
407
- className: "size-1.5 rounded-full bg-primary",
408
- title: "未保存"
409
- })]
410
- }),
411
- /* @__PURE__ */ jsxs("span", {
412
- className: "hidden text-xs text-muted-foreground sm:inline",
413
- children: [
414
- clipCount > 0 ? `${clipCount} 个片段` : "空工程",
415
- " · ",
416
- ratioLabel(projectWidth, projectHeight)
417
- ]
418
- }),
419
- /* @__PURE__ */ jsxs("div", {
420
- className: "ml-auto flex items-center gap-1",
380
+ className: "ml-auto flex shrink-0 items-center gap-1",
421
381
  children: [
422
382
  /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
423
383
  asChild: true,
@@ -426,10 +386,10 @@ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject,
426
386
  size: "icon-sm",
427
387
  onClick: undo,
428
388
  disabled: !canUndo,
429
- "aria-label": "撤销",
389
+ "aria-label": text.undo,
430
390
  children: /* @__PURE__ */ jsx(Undo2, { className: "size-4" })
431
391
  })
432
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "撤销 (Ctrl+Z)" })] }),
392
+ }), /* @__PURE__ */ jsxs(TooltipContent, { children: [text.undo, " (Ctrl+Z)"] })] }),
433
393
  /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
434
394
  asChild: true,
435
395
  children: /* @__PURE__ */ jsx(Button, {
@@ -437,10 +397,10 @@ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject,
437
397
  size: "icon-sm",
438
398
  onClick: redo,
439
399
  disabled: !canRedo,
440
- "aria-label": "重做",
400
+ "aria-label": text.redo,
441
401
  children: /* @__PURE__ */ jsx(Redo2, { className: "size-4" })
442
402
  })
443
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "重做 (Ctrl+Shift+Z)" })] }),
403
+ }), /* @__PURE__ */ jsxs(TooltipContent, { children: [text.redo, " (Ctrl+Shift+Z)"] })] }),
444
404
  /* @__PURE__ */ jsx(Separator$1, {
445
405
  orientation: "vertical",
446
406
  className: "mx-1 h-5"
@@ -451,29 +411,29 @@ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject,
451
411
  variant: "ghost",
452
412
  size: "icon-sm",
453
413
  onClick: onOpenProject,
454
- "aria-label": "加载工程",
414
+ "aria-label": text.open,
455
415
  children: /* @__PURE__ */ jsx(FolderOpen, { className: "size-4" })
456
416
  })
457
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "加载已保存工程" })] }),
417
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: text.open })] }),
458
418
  onSaveProject != null && /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
459
419
  asChild: true,
460
420
  children: /* @__PURE__ */ jsx(Button, {
461
421
  variant: "ghost",
462
422
  size: "icon-sm",
463
423
  onClick: onSaveProject,
464
- "aria-label": "保存工程",
424
+ "aria-label": text.save,
465
425
  children: /* @__PURE__ */ jsx(Save, { className: "size-4" })
466
426
  })
467
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "保存工程到本地" })] }),
427
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: text.save })] }),
468
428
  /* @__PURE__ */ jsxs(Button, {
469
429
  variant: "ghost",
470
430
  size: "sm",
471
431
  className: "gap-1.5",
472
- "aria-label": "画布设置",
432
+ "aria-label": text.canvasSettings,
473
433
  onClick: () => openDialog("projectSettings"),
474
434
  children: [/* @__PURE__ */ jsx(Monitor, { className: "size-4" }), /* @__PURE__ */ jsx("span", {
475
435
  className: "hidden md:inline",
476
- children: "画布设置"
436
+ children: text.canvasSettings
477
437
  })]
478
438
  }),
479
439
  showLab && /* @__PURE__ */ jsxs(Button, {
@@ -483,21 +443,67 @@ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject,
483
443
  onClick: () => setRoute(route === "lab" ? "editor" : "lab"),
484
444
  children: [route === "lab" ? /* @__PURE__ */ jsx(Video, { className: "size-4" }) : /* @__PURE__ */ jsx(FlaskConical, { className: "size-4" }), /* @__PURE__ */ jsx("span", {
485
445
  className: "hidden md:inline",
486
- children: route === "lab" ? "返回编辑器" : "能力实验室"
446
+ children: route === "lab" ? text.backToEditor : text.showLab
487
447
  })]
488
448
  }),
489
- /* @__PURE__ */ jsx(CapabilityBadge, { info: capability }),
449
+ showCapabilityBadge && /* @__PURE__ */ jsx(CapabilityBadge, { info: capability }),
450
+ actions,
490
451
  /* @__PURE__ */ jsxs(Button, {
491
452
  size: "sm",
492
453
  className: "ml-1 gap-1.5",
493
454
  onClick: onExport,
494
- children: [/* @__PURE__ */ jsx(Download, { className: "size-4" }), "导出"]
455
+ children: [/* @__PURE__ */ jsx(Download, { className: "size-4" }), text.export]
495
456
  })
496
457
  ]
497
458
  })
498
459
  ]
499
460
  });
500
461
  }
462
+ /**
463
+ * 工程名展示(可选双击重命名)。
464
+ *
465
+ * 重命名走编辑器 store 的 `renameProject`,因此会进撤销栈、并标记 dirty ——
466
+ * 直接改 DOM 或本地 state 会让「保存了什么」和「看到什么」对不上。
467
+ */
468
+ function ProjectTitle({ name, dirty, placeholder, editable, onRename }) {
469
+ const [editing, setEditing] = useState(false);
470
+ const [draft, setDraft] = useState(name);
471
+ const commit = () => {
472
+ setEditing(false);
473
+ const next = draft.trim();
474
+ if (next.length > 0 && next !== name) onRename(next);
475
+ };
476
+ if (editing) return /* @__PURE__ */ jsx("input", {
477
+ autoFocus: true,
478
+ value: draft,
479
+ onChange: (e) => setDraft(e.target.value),
480
+ onBlur: commit,
481
+ onKeyDown: (e) => {
482
+ if (e.key === "Enter") commit();
483
+ if (e.key === "Escape") {
484
+ setDraft(name);
485
+ setEditing(false);
486
+ }
487
+ },
488
+ className: "h-7 w-48 min-w-0 rounded border bg-background px-1.5 text-sm outline-none focus:ring-1 focus:ring-ring",
489
+ "aria-label": "工程名称"
490
+ });
491
+ return /* @__PURE__ */ jsxs("div", {
492
+ className: "flex min-w-0 items-center gap-1.5",
493
+ children: [/* @__PURE__ */ jsx("span", {
494
+ className: cn("truncate text-sm font-medium", editable && "cursor-text"),
495
+ title: editable ? "双击重命名" : name.length > 0 ? name : placeholder,
496
+ onDoubleClick: editable ? () => {
497
+ setDraft(name.length > 0 ? name : "");
498
+ setEditing(true);
499
+ } : void 0,
500
+ children: name.length > 0 ? name : placeholder
501
+ }), dirty && /* @__PURE__ */ jsx("span", {
502
+ className: "size-1.5 shrink-0 rounded-full bg-primary",
503
+ title: "未保存"
504
+ })]
505
+ });
506
+ }
501
507
  // src/components/layout/use-panel-resize.ts
502
508
  /**
503
509
  * 面板拖拽调整。内部用 rAF 节流,松手才把最终值写回 store(避免每帧重渲染整棵树)。
@@ -580,42 +586,6 @@ function PanelResizer({ orientation, value, min, max, invert, onChange, classNam
580
586
  children: /* @__PURE__ */ jsx("div", { className: cn("absolute", isVertical ? "-left-1 -right-1 inset-y-0" : "-top-1 -bottom-1 inset-x-0") })
581
587
  });
582
588
  }
583
- // src/components/ui/card.tsx
584
- function Card({ className, ...props }) {
585
- return /* @__PURE__ */ jsx("div", {
586
- "data-slot": "card",
587
- className: cn("flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", className),
588
- ...props
589
- });
590
- }
591
- function CardHeader({ className, ...props }) {
592
- return /* @__PURE__ */ jsx("div", {
593
- "data-slot": "card-header",
594
- className: cn("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className),
595
- ...props
596
- });
597
- }
598
- function CardTitle({ className, ...props }) {
599
- return /* @__PURE__ */ jsx("div", {
600
- "data-slot": "card-title",
601
- className: cn("leading-none font-semibold", className),
602
- ...props
603
- });
604
- }
605
- function CardDescription({ className, ...props }) {
606
- return /* @__PURE__ */ jsx("div", {
607
- "data-slot": "card-description",
608
- className: cn("text-sm text-muted-foreground", className),
609
- ...props
610
- });
611
- }
612
- function CardContent({ className, ...props }) {
613
- return /* @__PURE__ */ jsx("div", {
614
- "data-slot": "card-content",
615
- className: cn("px-6", className),
616
- ...props
617
- });
618
- }
619
589
  // src/components/common/LoadingOverlay.tsx
620
590
  /** 全屏/局部遮罩加载态,用于导出、批量导入等长任务 */
621
591
  function LoadingOverlay({ visible, title = "处理中…", description, progress, className, fullscreen, children }) {
@@ -683,7 +653,12 @@ var CHECKS = [
683
653
  hint: "绿幕抠像(可选)"
684
654
  }
685
655
  ];
686
- /** 不满足运行条件时的阻断页:逐项说明缺失能力,不做静默降级 */
656
+ /**
657
+ * 不满足运行条件时的阻断页:逐项说明缺失能力,不做静默降级。
658
+ *
659
+ * 「依赖没装」与「浏览器不支持」要分开呈现 —— 前者是宿主的构建问题(可执行
660
+ * 安装命令解决),后者只能换浏览器。混在一起会让人对着一条装包报错去升浏览器。
661
+ */
687
662
  function UnsupportedBrowser({ report, loading, error }) {
688
663
  if (loading) return /* @__PURE__ */ jsx("div", {
689
664
  className: "relative flex min-h-screen items-center justify-center bg-background",
@@ -693,6 +668,8 @@ function UnsupportedBrowser({ report, loading, error }) {
693
668
  fullscreen: false
694
669
  })
695
670
  });
671
+ const missing = report?.missingDependencies ?? [];
672
+ const dependenciesMissing = missing.length > 0;
696
673
  return /* @__PURE__ */ jsx("div", {
697
674
  className: "flex min-h-screen items-center justify-center bg-background p-6",
698
675
  children: /* @__PURE__ */ jsxs(Card, {
@@ -701,15 +678,36 @@ function UnsupportedBrowser({ report, loading, error }) {
701
678
  className: "flex items-center gap-2 text-destructive",
702
679
  children: [/* @__PURE__ */ jsx(AlertOctagon, { className: "size-5" }), /* @__PURE__ */ jsx(CardTitle, {
703
680
  className: "text-base",
704
- children: "当前浏览器无法运行本应用"
681
+ children: dependenciesMissing ? "缺少引擎依赖" : "当前浏览器无法运行本应用"
705
682
  })]
706
683
  }), /* @__PURE__ */ jsx("p", {
707
684
  className: "text-sm text-muted-foreground",
708
- children: "WebAV 依赖 WebCodecs 与 OPFS,仅支持 Chromium 内核浏览器。请使用最新版 Chrome 或 Edge(非无痕模式)重新打开。"
685
+ children: dependenciesMissing ? "工作台的预览与导出依赖 WebAV(@webav/av-cliper、@webav/av-canvas)。当前项目里没有解析到它们。" : "WebAV 依赖 WebCodecs 与 OPFS,仅支持 Chromium 内核浏览器。请使用最新版 Chrome 或 Edge(非无痕模式)重新打开。"
709
686
  })] }), /* @__PURE__ */ jsxs(CardContent, {
710
687
  className: "space-y-4",
711
688
  children: [
712
- report != null && /* @__PURE__ */ jsx("div", {
689
+ dependenciesMissing && /* @__PURE__ */ jsxs("div", {
690
+ className: "space-y-2 rounded-md border border-destructive/40 bg-destructive/5 p-3",
691
+ children: [
692
+ /* @__PURE__ */ jsxs("p", {
693
+ className: "text-xs font-medium text-destructive",
694
+ children: ["未解析到:", missing.join("、")]
695
+ }),
696
+ /* @__PURE__ */ jsx("p", {
697
+ className: "text-xs text-muted-foreground",
698
+ children: "在宿主项目里执行:"
699
+ }),
700
+ /* @__PURE__ */ jsx("pre", {
701
+ className: "overflow-x-auto rounded bg-muted px-3 py-2 text-xs",
702
+ children: /* @__PURE__ */ jsx("code", { children: ENGINE_DEPENDENCIES_INSTALL_COMMAND })
703
+ }),
704
+ /* @__PURE__ */ jsx("p", {
705
+ className: "text-xs text-muted-foreground",
706
+ children: "安装后需要重新跑一次构建 —— 缺失依赖只在构建期解析,浏览器里刷新页面没用。"
707
+ })
708
+ ]
709
+ }),
710
+ report != null && !dependenciesMissing && /* @__PURE__ */ jsx("div", {
713
711
  className: "grid gap-2 sm:grid-cols-2",
714
712
  children: CHECKS.map((check) => {
715
713
  const value = report[check.key];
@@ -983,43 +981,164 @@ function EmptyState({ icon: Icon, title, description, action, className, compact
983
981
  ]
984
982
  });
985
983
  }
986
- // src/lib/format.ts
987
- /** 字节数 → 人类可读 */
988
- function formatBytes(bytes) {
989
- if (bytes < 1024) return `${bytes} B`;
990
- const units = [
991
- "KB",
992
- "MB",
993
- "GB"
994
- ];
995
- let value = bytes / 1024;
996
- let i = 0;
997
- while (value >= 1024 && i < units.length - 1) {
998
- value /= 1024;
999
- i += 1;
1000
- }
1001
- return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
1002
- }
1003
- /** 像素尺寸 → `1920×1080` */
1004
- function formatResolution(width, height) {
1005
- if (!width || !height) return "—";
1006
- return `${width}×${height}`;
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
+ });
1007
1008
  }
1008
- /** 秒数 → `12.3s` */
1009
- function formatElapsed(seconds) {
1010
- if (seconds < 60) return `${seconds.toFixed(1)}s`;
1011
- return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
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);
1012
1023
  }
1013
- /** 微秒 → 秒(保留一位小数,用于表单展示) */
1014
- function formatSeconds(us) {
1015
- return usToSec(us).toFixed(2);
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;
1016
1042
  }
1017
- /** 百分比 0~1 → `42%` */
1018
- function formatPercent(ratio) {
1019
- return `${Math.round(ratio * 100)}%`;
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
+ };
1020
1138
  }
1021
1139
  // src/features/media-library/components/DropZone.tsx
1022
- var ACCEPT = {
1140
+ /** react-dropzone 用的 MIME → 扩展名映射 */
1141
+ var DROPZONE_ACCEPT = {
1023
1142
  "video/mp4": [".mp4", ".m4v"],
1024
1143
  "audio/*": [
1025
1144
  ".mp3",
@@ -1042,22 +1161,45 @@ var ACCEPT = {
1042
1161
  /**
1043
1162
  * 拖拽 / 点击导入区域。
1044
1163
  *
1164
+ * 两条入口:
1165
+ * - **拖拽**:走 react-dropzone(宿主无法介入,这是浏览器原生行为);
1166
+ * - **点击**:优先走 `HostBridge.files.pickFiles` —— 宿主可能不用系统文件
1167
+ * 对话框,而是从自己的素材库 / 云端 / URL 导入。宿主返回 `null`(不接管)
1168
+ * 时才回退到内置 `<input type=file>`。
1169
+ *
1045
1170
  * 注意:react-dropzone 的 accept 只做初筛,真正按 MIME + 扩展名校验在
1046
1171
  * core/media/file-reader,从而保证「拖入 .webm 得到明确错误」而不是被静默拒绝。
1047
1172
  */
1048
1173
  function DropZone({ onFiles, disabled, className, compact }) {
1049
- const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
1174
+ const { bridge } = useWorkbench();
1175
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
1050
1176
  onDrop: (accepted) => {
1051
1177
  if (accepted.length > 0) onFiles(accepted);
1052
1178
  },
1053
- noClick: compact,
1054
- noKeyboard: compact,
1179
+ noClick: true,
1180
+ noKeyboard: true,
1055
1181
  multiple: true,
1056
1182
  disabled,
1057
- accept: ACCEPT
1058
- });
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
+ ]);
1059
1199
  return /* @__PURE__ */ jsxs("div", {
1060
1200
  ...getRootProps(),
1201
+ onClick: handleClick,
1202
+ "data-drop-zone": "",
1061
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),
1062
1204
  children: [
1063
1205
  /* @__PURE__ */ jsx("input", { ...getInputProps() }),
@@ -1074,7 +1216,7 @@ function DropZone({ onFiles, disabled, className, compact }) {
1074
1216
  className: "sr-only",
1075
1217
  onClick: (e) => {
1076
1218
  e.stopPropagation();
1077
- open();
1219
+ handleClick();
1078
1220
  },
1079
1221
  children: "选择文件"
1080
1222
  })
@@ -1295,122 +1437,21 @@ function MediaGrid({ assets, selectedAssetId, missingAssetIds = [], progress, on
1295
1437
  ]
1296
1438
  });
1297
1439
  }
1298
- // src/features/media-library/hooks/useMediaImport.ts
1299
- /**
1300
- * 找出可以「重连」的素材。
1301
- *
1302
- * 场景:刷新后工程从 IndexedDB 恢复,但素材二进制消失(File 无法持久化)。
1303
- * 用户重新导入同一个文件时,复用原 assetId,时间轴上已有片段立即恢复。
1304
- * 匹配条件:同名 + 同类型 + 当前确实缺少二进制。
1305
- */
1306
- function findReconnectableAssetId(state, file, registry) {
1307
- if (state.missingAssetIds.length === 0) return null;
1308
- return state.assets.find((asset) => state.missingAssetIds.includes(asset.id) && asset.name === file.name && registry.getFile(asset.id) == null)?.id ?? null;
1309
- }
1440
+ // src/features/media-library/components/MediaLibraryPanel.tsx
1441
+ var KIND_LABEL$1 = {
1442
+ video: "视频",
1443
+ audio: "音频",
1444
+ image: "图片",
1445
+ text: "文字",
1446
+ subtitle: "字幕"
1447
+ };
1310
1448
  /**
1311
- * 批量导入素材。
1312
- *
1313
- * - 串行处理,避免同时开辟多个解码器把内存打爆;
1314
- * - 单个文件失败只影响自身,最后汇总 toast;
1315
- * - 进度写在本地 state(不放进 store,避免高频更新引起全局重渲染);
1316
- * - 若与「缺失源文件」的素材同名同类型,则重连而不是新增。
1449
+ * 素材库面板:导入区 + 新建(文字/字幕)+ 网格 + 详情。
1317
1450
  */
1318
- function useMediaImport() {
1319
- const editor = useCutEditor();
1320
- const clips = editor.runtime.clips;
1321
- const store = editor.store;
1322
- const [progress, setProgress] = useState([]);
1323
- const [importing, setImporting] = useState(false);
1324
- const addAsset = useEditor((s) => s.addAsset);
1325
- const replaceAsset = useEditor((s) => s.replaceAsset);
1326
- const clearMissing = useEditor((s) => s.clearMissing);
1327
- const selectAsset = useEditor((s) => s.selectAsset);
1328
- const beginImport = useUi((s) => s.beginImport);
1329
- const endImport = useUi((s) => s.endImport);
1330
- return {
1331
- importing,
1332
- progress,
1333
- importFiles: useCallback(async (files) => {
1334
- if (files.length === 0) return [];
1335
- setImporting(true);
1336
- beginImport();
1337
- setProgress(files.map((f) => ({
1338
- name: f.name,
1339
- progress: 0
1340
- })));
1341
- const imported = [];
1342
- const failures = [];
1343
- let reconnected = 0;
1344
- for (let i = 0; i < files.length; i += 1) {
1345
- const file = files[i];
1346
- if (!file) continue;
1347
- try {
1348
- const reconnectId = findReconnectableAssetId(store.getState(), file, clips);
1349
- const result = await importFile(clips, file, (p) => {
1350
- setProgress((prev) => prev.map((item, index) => index === i ? {
1351
- ...item,
1352
- progress: p
1353
- } : item));
1354
- });
1355
- if (reconnectId != null) {
1356
- clips.replaceAssetSource(reconnectId, file, result.master ?? null);
1357
- replaceAsset(reconnectId, result.asset);
1358
- clearMissing(reconnectId);
1359
- imported.push({
1360
- ...result.asset,
1361
- id: reconnectId
1362
- });
1363
- reconnected += 1;
1364
- } else {
1365
- addAsset(result.asset);
1366
- imported.push(result.asset);
1367
- }
1368
- } catch (err) {
1369
- const message = err instanceof UnsupportedFileError ? err.message : err instanceof Error ? err.message : String(err);
1370
- failures.push(`${file.name}:${message}`);
1371
- setProgress((prev) => prev.map((item, index) => index === i ? {
1372
- ...item,
1373
- error: message
1374
- } : item));
1375
- }
1376
- }
1377
- setImporting(false);
1378
- endImport();
1379
- if (imported.length > 0) {
1380
- selectAsset(imported[0]?.id ?? null);
1381
- toast.success(reconnected > 0 ? `已导入 ${imported.length} 个素材(其中 ${reconnected} 个重连到已有片段)` : `已导入 ${imported.length} 个素材`);
1382
- }
1383
- if (failures.length > 0) toast.error(`${failures.length} 个文件导入失败`, {
1384
- description: failures.slice(0, 3).join("\n"),
1385
- duration: 8e3
1386
- });
1387
- window.setTimeout(() => setProgress([]), 3e3);
1388
- return imported;
1389
- }, [
1390
- addAsset,
1391
- beginImport,
1392
- clearMissing,
1393
- endImport,
1394
- replaceAsset,
1395
- selectAsset
1396
- ])
1397
- };
1398
- }
1399
- // src/features/media-library/components/MediaLibraryPanel.tsx
1400
- var KIND_LABEL$1 = {
1401
- video: "视频",
1402
- audio: "音频",
1403
- image: "图片",
1404
- text: "文字",
1405
- subtitle: "字幕"
1406
- };
1407
- /**
1408
- * 素材库面板:导入区 + 新建(文字/字幕)+ 网格 + 详情。
1409
- */
1410
- function MediaLibraryPanel({ onAddToTimeline, onRemoveAsset }) {
1411
- const assets = useEditor((s) => s.assets);
1412
- const selectedAssetId = useEditor((s) => s.selectedAssetId);
1413
- const missingAssetIds = useEditor((s) => s.missingAssetIds);
1451
+ function MediaLibraryPanel({ onAddToTimeline, onRemoveAsset }) {
1452
+ const assets = useEditor((s) => s.assets);
1453
+ const selectedAssetId = useEditor((s) => s.selectedAssetId);
1454
+ const missingAssetIds = useEditor((s) => s.missingAssetIds);
1414
1455
  const selectAsset = useEditor((s) => s.selectAsset);
1415
1456
  const { importing, progress, importFiles } = useMediaImport();
1416
1457
  const [metaAssetId, setMetaAssetId] = useState(null);
@@ -1563,6 +1604,7 @@ function CreateTextDialog({ open, onOpenChange, onCreated }) {
1563
1604
  const projectWidth = useEditor((s) => s.project.width);
1564
1605
  const projectHeight = useEditor((s) => s.project.height);
1565
1606
  const addAsset = useEditor((s) => s.addAsset);
1607
+ const { bridge } = useWorkbench();
1566
1608
  const handleCreate = () => {
1567
1609
  const { asset } = createTextAsset({
1568
1610
  registry: editor.runtime.clips,
@@ -1573,7 +1615,10 @@ function CreateTextDialog({ open, onOpenChange, onCreated }) {
1573
1615
  addAsset(asset);
1574
1616
  onCreated(asset.id);
1575
1617
  onOpenChange(false);
1576
- toast.success("已创建文字素材");
1618
+ notify(bridge, {
1619
+ level: "success",
1620
+ title: "已创建文字素材"
1621
+ });
1577
1622
  };
1578
1623
  return /* @__PURE__ */ jsx(Dialog$1, {
1579
1624
  open,
@@ -1607,10 +1652,15 @@ function CreateSubtitleDialog({ open, onOpenChange, onCreated }) {
1607
1652
  const projectWidth = useEditor((s) => s.project.width);
1608
1653
  const projectHeight = useEditor((s) => s.project.height);
1609
1654
  const addAsset = useEditor((s) => s.addAsset);
1655
+ const { bridge } = useWorkbench();
1610
1656
  const handleCreate = () => {
1611
1657
  const durationUs = parseSrtDurationUs(srt);
1612
1658
  if (durationUs <= 0) {
1613
- 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
+ });
1614
1664
  return;
1615
1665
  }
1616
1666
  const { asset } = createSubtitleAsset({
@@ -1623,7 +1673,10 @@ function CreateSubtitleDialog({ open, onOpenChange, onCreated }) {
1623
1673
  addAsset(asset);
1624
1674
  onCreated(asset.id);
1625
1675
  onOpenChange(false);
1626
- toast.success("已创建字幕素材");
1676
+ notify(bridge, {
1677
+ level: "success",
1678
+ title: "已创建字幕素材"
1679
+ });
1627
1680
  };
1628
1681
  return /* @__PURE__ */ jsx(Dialog$1, {
1629
1682
  open,
@@ -1649,11 +1702,22 @@ function CreateSubtitleDialog({ open, onOpenChange, onCreated }) {
1649
1702
  });
1650
1703
  }
1651
1704
  // src/hooks/use-toast-safe.ts
1652
- /** 统一把异常转成可见提示,禁止静默吞掉 */
1705
+ /**
1706
+ * 把异常转成可见提示,禁止静默吞掉。
1707
+ *
1708
+ * 这是**非 hook** 版本,供事件回调 / 非组件代码调用(例如 `useAvCanvas` 的
1709
+ * 初始化失败)。它拿不到 React context,因此走 `notify` 注册的默认桥:
1710
+ * `WorkbenchProvider` 挂载时会把 `bridge` 注册进来,宿主实现了 `notify`
1711
+ * 就交给宿主,否则退回库内 toast。
1712
+ */
1653
1713
  function reportError(error, fallback = "操作失败") {
1654
1714
  const message = error instanceof Error ? error.message : String(error);
1655
1715
  console.error(error);
1656
- toast.error(fallback, { description: message });
1716
+ notify(void 0, {
1717
+ level: "error",
1718
+ title: fallback,
1719
+ description: message
1720
+ });
1657
1721
  }
1658
1722
  // src/features/preview/hooks/useAvCanvas.ts
1659
1723
  /**
@@ -2378,15 +2442,51 @@ var RATES = [
2378
2442
  1.5,
2379
2443
  2
2380
2444
  ];
2445
+ /**
2446
+ * 紧凑档位(按容器宽度,不是视口 —— 预览列宽由拖拽决定)。
2447
+ *
2448
+ * 阈值来自实测的控件总宽:完整形态约 680px,`< 700` 先去音量滑杆与速率、
2449
+ * `< 560` 再去时间码的「总时长」与进度条、`< 420` 再去跳首尾。
2450
+ * 只靠 `shrink-0` + `overflow:hidden` 会在 650px 左右把时间码裁掉,
2451
+ * 且**没有任何报错**,只有截图能看出来 —— 所以这里显式分级。
2452
+ */
2453
+ var FULL_PX = 700;
2454
+ var COMPACT_PX = 620;
2455
+ var TIGHT_PX = 520;
2456
+ var ULTRA_COMPACT_PX = 420;
2457
+ /** 低于此宽度连时间码都放不下:只留播放/逐帧,横向可滚 */
2458
+ var MICRO_PX = 200;
2381
2459
  /** 播放控制条:逐帧 / 跳首尾 / 播放 / 时间码 / 速率 / 音量 */
2382
2460
  function PlaybackControls({ playing, currentUs, durationUs, masterVolume, playbackRate, disabled, onToggle, onJumpStart, onJumpEnd, onStepFrame, onSeek, onVolumeChange, onRateChange, className }) {
2461
+ const rootRef = useRef(null);
2462
+ const [width, setWidth] = useState(0);
2463
+ /**
2464
+ * 用容器宽度而不是 CSS 媒体查询。
2465
+ *
2466
+ * 预览面板宽度由左侧拖拽决定,和视口宽度无关 —— 用户把两侧拉宽时,
2467
+ * 视口一点没变,`max-[560px]:hidden` 这类媒体查询不会触发,时间码照样被压扁。
2468
+ */
2469
+ useEffect(() => {
2470
+ const el = rootRef.current;
2471
+ if (el == null) return;
2472
+ const ro = new ResizeObserver(() => setWidth(el.clientWidth));
2473
+ ro.observe(el);
2474
+ setWidth(el.clientWidth);
2475
+ return () => ro.disconnect();
2476
+ }, []);
2477
+ const compact = width > 0 && width < COMPACT_PX;
2478
+ const tight = width > 0 && width < TIGHT_PX;
2479
+ const ultraCompact = width > 0 && width < ULTRA_COMPACT_PX;
2480
+ const micro = width > 0 && width < MICRO_PX;
2481
+ const full = width === 0 || width >= FULL_PX;
2383
2482
  return /* @__PURE__ */ jsxs("div", {
2384
- className: cn("flex h-11 shrink-0 items-center gap-1.5 border-t bg-card/60 px-3", className),
2483
+ ref: rootRef,
2484
+ className: cn("flex h-11 shrink-0 items-center gap-1.5 overflow-hidden border-t bg-card/60", ultraCompact ? "px-1.5" : "px-3", className),
2385
2485
  children: [
2386
2486
  /* @__PURE__ */ jsxs("div", {
2387
- className: "flex items-center gap-0.5",
2487
+ className: "flex shrink-0 items-center gap-0.5",
2388
2488
  children: [
2389
- /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2489
+ !ultraCompact && /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2390
2490
  asChild: true,
2391
2491
  children: /* @__PURE__ */ jsx(Button, {
2392
2492
  variant: "ghost",
@@ -2428,7 +2528,7 @@ function PlaybackControls({ playing, currentUs, durationUs, masterVolume, playba
2428
2528
  children: /* @__PURE__ */ jsx(StepForward, { className: "size-3.5" })
2429
2529
  })
2430
2530
  }), /* @__PURE__ */ jsx(TooltipContent, { children: "下一帧 (→)" })] }),
2431
- /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2531
+ !ultraCompact && /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2432
2532
  asChild: true,
2433
2533
  children: /* @__PURE__ */ jsx(Button, {
2434
2534
  variant: "ghost",
@@ -2442,23 +2542,19 @@ function PlaybackControls({ playing, currentUs, durationUs, masterVolume, playba
2442
2542
  ]
2443
2543
  }),
2444
2544
  /* @__PURE__ */ jsxs("div", {
2445
- className: "mx-2 flex items-baseline gap-1",
2446
- children: [
2447
- /* @__PURE__ */ jsx(Timecode, {
2448
- us: currentUs,
2449
- className: "text-[13px] font-medium"
2450
- }),
2451
- /* @__PURE__ */ jsx("span", {
2452
- className: "text-xs text-muted-foreground",
2453
- children: "/"
2454
- }),
2455
- /* @__PURE__ */ jsx(Timecode, {
2456
- us: durationUs,
2457
- muted: true
2458
- })
2459
- ]
2545
+ className: cn("mx-2 flex shrink-0 items-baseline gap-1 whitespace-nowrap", ultraCompact && "mx-1", micro && "hidden"),
2546
+ children: [/* @__PURE__ */ jsx(Timecode, {
2547
+ us: currentUs,
2548
+ className: "text-[13px] font-medium"
2549
+ }), !tight && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
2550
+ className: "text-xs text-muted-foreground",
2551
+ children: "/"
2552
+ }), /* @__PURE__ */ jsx(Timecode, {
2553
+ us: durationUs,
2554
+ muted: true
2555
+ })] })]
2460
2556
  }),
2461
- /* @__PURE__ */ jsx("input", {
2557
+ !ultraCompact && /* @__PURE__ */ jsx("input", {
2462
2558
  type: "range",
2463
2559
  className: "mx-1 h-1 min-w-16 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary",
2464
2560
  min: 0,
@@ -2470,9 +2566,9 @@ function PlaybackControls({ playing, currentUs, durationUs, masterVolume, playba
2470
2566
  "aria-label": "播放进度"
2471
2567
  }),
2472
2568
  /* @__PURE__ */ jsxs("div", {
2473
- className: "flex items-center gap-1",
2569
+ className: cn("flex shrink-0 items-center gap-1", compact && "ml-auto", micro && "hidden"),
2474
2570
  children: [
2475
- /* @__PURE__ */ jsx("div", {
2571
+ !compact && /* @__PURE__ */ jsx("div", {
2476
2572
  className: "flex items-center rounded-md border p-0.5",
2477
2573
  children: RATES.map((rate) => /* @__PURE__ */ jsxs("button", {
2478
2574
  type: "button",
@@ -2492,7 +2588,7 @@ function PlaybackControls({ playing, currentUs, durationUs, masterVolume, playba
2492
2588
  children: masterVolume > 0 ? /* @__PURE__ */ jsx(Volume2, { className: "size-3.5" }) : /* @__PURE__ */ jsx(VolumeX, { className: "size-3.5" })
2493
2589
  })
2494
2590
  }), /* @__PURE__ */ jsx(TooltipContent, { children: masterVolume > 0 ? "静音预览" : "取消静音" })] }),
2495
- /* @__PURE__ */ jsx(Slider$1, {
2591
+ full && /* @__PURE__ */ jsx(Slider$1, {
2496
2592
  className: "w-20",
2497
2593
  min: 0,
2498
2594
  max: 1,
@@ -2541,6 +2637,7 @@ function PreviewPanel() {
2541
2637
  const setMasterVolume = useEditor((s) => s.setMasterVolume);
2542
2638
  const playheadUs = useEditor((s) => s.playheadUs);
2543
2639
  const selectedClipIds = useEditor((s) => s.selectedClipIds);
2640
+ const { bridge } = useWorkbench();
2544
2641
  const canvas = useAvCanvas({
2545
2642
  width: project.width,
2546
2643
  height: project.height
@@ -2551,12 +2648,18 @@ function PreviewPanel() {
2551
2648
  if (errors.length === 0) return;
2552
2649
  const missing = errors.filter((e) => e.message.startsWith("素材文件未载入"));
2553
2650
  const other = errors.filter((e) => !e.message.startsWith("素材文件未载入"));
2554
- if (missing.length > 0) toast.warning(`${missing.length} 个片段缺少源文件,已跳过预览`, {
2651
+ if (missing.length > 0) notify(bridge, {
2652
+ level: "warning",
2653
+ title: `${missing.length} 个片段缺少源文件,已跳过预览`,
2555
2654
  description: "刷新后素材二进制不会保留,请重新导入相同文件",
2556
2655
  duration: 8e3
2557
2656
  });
2558
- if (other.length > 0) toast.error("部分片段无法载入预览", { description: Array.from(new Set(other.map((e) => e.message))).slice(0, 3).join("\n") });
2559
- }, []);
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]);
2560
2663
  const clock = usePlaybackClock({
2561
2664
  project,
2562
2665
  assets,
@@ -2692,12 +2795,18 @@ function PreviewPanel() {
2692
2795
  const handleCapture = useCallback(() => {
2693
2796
  const dataUrl = canvas.captureImage();
2694
2797
  if (dataUrl == null) {
2695
- toast.error("截图失败:画布尚未就绪");
2798
+ notify(bridge, {
2799
+ level: "error",
2800
+ title: "截图失败:画布尚未就绪"
2801
+ });
2696
2802
  return;
2697
2803
  }
2698
2804
  downloadDataUrl(dataUrl, `frame-${Date.now()}.png`);
2699
- toast.success("已导出当前帧");
2700
- }, [canvas]);
2805
+ notify(bridge, {
2806
+ level: "success",
2807
+ title: "已导出当前帧"
2808
+ });
2809
+ }, [bridge, canvas]);
2701
2810
  const handleFullscreen = () => {
2702
2811
  const el = canvas.containerRef.current?.parentElement;
2703
2812
  if (el == null) return;
@@ -2776,143 +2885,148 @@ var DEFAULT_BITRATE = 8e6;
2776
2885
  /** 时间轴顶部工具条:分割 / 复制 / 删除 / 吸附开关 / 缩放 / 加轨道 */
2777
2886
  function TimelineToolbar({ playheadUs, durationUs, pxPerSecond, selectionCount, magnetEnabled, onToggleMagnet, onSplit, onDuplicate, onRemove, onZoomIn, onZoomOut, onZoomToFit, onAddTrack, canZoomIn, canZoomOut }) {
2778
2887
  return /* @__PURE__ */ jsxs("div", {
2779
- className: "flex h-9 shrink-0 items-center gap-1 border-b bg-card/60 px-2",
2780
- children: [
2781
- /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2782
- asChild: true,
2783
- children: /* @__PURE__ */ jsx(Button, {
2784
- variant: "ghost",
2785
- size: "icon-sm",
2786
- onClick: onSplit,
2787
- "aria-label": "分割",
2788
- children: /* @__PURE__ */ jsx(Scissors, { className: "size-3.5" })
2789
- })
2790
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "在播放头分割 (S)" })] }),
2791
- /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2792
- asChild: true,
2793
- children: /* @__PURE__ */ jsx(Button, {
2794
- variant: "ghost",
2795
- size: "icon-sm",
2796
- onClick: onDuplicate,
2797
- disabled: selectionCount === 0,
2798
- "aria-label": "复制片段",
2799
- children: /* @__PURE__ */ jsx(Copy, { className: "size-3.5" })
2800
- })
2801
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "复制片段 (Ctrl+D)" })] }),
2802
- /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2803
- asChild: true,
2804
- children: /* @__PURE__ */ jsx(Button, {
2805
- variant: "ghost",
2806
- size: "icon-sm",
2807
- onClick: onRemove,
2808
- disabled: selectionCount === 0,
2809
- "aria-label": "删除片段",
2810
- children: /* @__PURE__ */ jsx(Trash2, { className: "size-3.5" })
2811
- })
2812
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "删除片段 (Delete)" })] }),
2813
- /* @__PURE__ */ jsx(Separator$1, {
2814
- orientation: "vertical",
2815
- className: "mx-1 h-4"
2816
- }),
2817
- /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2818
- asChild: true,
2819
- children: /* @__PURE__ */ jsx(Button, {
2820
- variant: magnetEnabled ? "secondary" : "ghost",
2821
- size: "icon-sm",
2822
- onClick: onToggleMagnet,
2823
- "aria-label": "吸附",
2824
- "aria-pressed": magnetEnabled,
2825
- children: /* @__PURE__ */ jsx(Magnet, { className: "size-3.5" })
2826
- })
2827
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "吸附对齐(阈值 15px)" })] }),
2828
- /* @__PURE__ */ jsx(Separator$1, {
2829
- orientation: "vertical",
2830
- className: "mx-1 h-4"
2831
- }),
2832
- /* @__PURE__ */ jsxs("div", {
2833
- className: "flex items-center gap-0.5",
2834
- children: [
2835
- /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2836
- asChild: true,
2837
- children: /* @__PURE__ */ jsx(Button, {
2838
- variant: "ghost",
2839
- size: "icon-sm",
2840
- onClick: onZoomOut,
2841
- disabled: !canZoomOut,
2842
- "aria-label": "缩小",
2843
- children: /* @__PURE__ */ jsx(ZoomOut, { className: "size-3.5" })
2844
- })
2845
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "缩小 (-)" })] }),
2846
- /* @__PURE__ */ jsx("span", {
2847
- className: "w-11 text-center font-mono text-[10px] text-muted-foreground tabular-nums",
2848
- children: Math.round(pxPerSecond)
2849
- }),
2850
- /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2851
- asChild: true,
2852
- children: /* @__PURE__ */ jsx(Button, {
2888
+ className: "thin-scrollbar @container flex h-9 shrink-0 items-center gap-1 overflow-x-auto overflow-y-hidden border-b bg-card/60 px-2",
2889
+ role: "toolbar",
2890
+ "aria-label": "时间轴工具",
2891
+ "data-timeline-toolbar": true,
2892
+ children: [/* @__PURE__ */ jsxs("div", {
2893
+ className: "flex shrink-0 items-center gap-1",
2894
+ children: [
2895
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2896
+ asChild: true,
2897
+ children: /* @__PURE__ */ jsx(Button, {
2898
+ variant: "ghost",
2899
+ size: "icon-sm",
2900
+ onClick: onSplit,
2901
+ "aria-label": "分割",
2902
+ children: /* @__PURE__ */ jsx(Scissors, { className: "size-3.5" })
2903
+ })
2904
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "在播放头分割 (S)" })] }),
2905
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2906
+ asChild: true,
2907
+ children: /* @__PURE__ */ jsx(Button, {
2908
+ variant: "ghost",
2909
+ size: "icon-sm",
2910
+ onClick: onDuplicate,
2911
+ disabled: selectionCount === 0,
2912
+ "aria-label": "复制片段",
2913
+ children: /* @__PURE__ */ jsx(Copy, { className: "size-3.5" })
2914
+ })
2915
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "复制片段 (Ctrl+D)" })] }),
2916
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2917
+ asChild: true,
2918
+ children: /* @__PURE__ */ jsx(Button, {
2919
+ variant: "ghost",
2920
+ size: "icon-sm",
2921
+ onClick: onRemove,
2922
+ disabled: selectionCount === 0,
2923
+ "aria-label": "删除片段",
2924
+ children: /* @__PURE__ */ jsx(Trash2, { className: "size-3.5" })
2925
+ })
2926
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "删除片段 (Delete)" })] }),
2927
+ /* @__PURE__ */ jsx(Separator$1, {
2928
+ orientation: "vertical",
2929
+ className: "mx-1 h-4"
2930
+ }),
2931
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2932
+ asChild: true,
2933
+ children: /* @__PURE__ */ jsx(Button, {
2934
+ variant: magnetEnabled ? "secondary" : "ghost",
2935
+ size: "icon-sm",
2936
+ onClick: onToggleMagnet,
2937
+ "aria-label": "吸附",
2938
+ "aria-pressed": magnetEnabled,
2939
+ children: /* @__PURE__ */ jsx(Magnet, { className: "size-3.5" })
2940
+ })
2941
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "吸附对齐(阈值 15px)" })] }),
2942
+ /* @__PURE__ */ jsx(Separator$1, {
2943
+ orientation: "vertical",
2944
+ className: "mx-1 h-4"
2945
+ }),
2946
+ /* @__PURE__ */ jsxs("div", {
2947
+ className: "flex items-center gap-0.5",
2948
+ children: [
2949
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2950
+ asChild: true,
2951
+ children: /* @__PURE__ */ jsx(Button, {
2952
+ variant: "ghost",
2953
+ size: "icon-sm",
2954
+ onClick: onZoomOut,
2955
+ disabled: !canZoomOut,
2956
+ "aria-label": "缩小",
2957
+ children: /* @__PURE__ */ jsx(ZoomOut, { className: "size-3.5" })
2958
+ })
2959
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "缩小 (-)" })] }),
2960
+ /* @__PURE__ */ jsx("span", {
2961
+ className: "w-11 text-center font-mono text-[10px] text-muted-foreground tabular-nums",
2962
+ children: Math.round(pxPerSecond)
2963
+ }),
2964
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2965
+ asChild: true,
2966
+ children: /* @__PURE__ */ jsx(Button, {
2967
+ variant: "ghost",
2968
+ size: "icon-sm",
2969
+ onClick: onZoomIn,
2970
+ disabled: !canZoomIn,
2971
+ "aria-label": "放大",
2972
+ children: /* @__PURE__ */ jsx(ZoomIn, { className: "size-3.5" })
2973
+ })
2974
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "放大 (+)" })] }),
2975
+ /* @__PURE__ */ jsx(Button, {
2853
2976
  variant: "ghost",
2854
- size: "icon-sm",
2855
- onClick: onZoomIn,
2856
- disabled: !canZoomIn,
2857
- "aria-label": "放大",
2858
- children: /* @__PURE__ */ jsx(ZoomIn, { className: "size-3.5" })
2977
+ size: "xs",
2978
+ onClick: onZoomToFit,
2979
+ className: "text-[10px]",
2980
+ children: "适应"
2859
2981
  })
2860
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "放大 (+)" })] }),
2861
- /* @__PURE__ */ jsx(Button, {
2982
+ ]
2983
+ }),
2984
+ /* @__PURE__ */ jsx(Separator$1, {
2985
+ orientation: "vertical",
2986
+ className: "mx-1 h-4"
2987
+ }),
2988
+ /* @__PURE__ */ jsxs("div", {
2989
+ className: "flex items-center gap-0.5",
2990
+ children: [/* @__PURE__ */ jsxs(Button, {
2862
2991
  variant: "ghost",
2863
2992
  size: "xs",
2864
- onClick: onZoomToFit,
2865
- className: "text-[10px]",
2866
- children: "适应"
2867
- })
2868
- ]
2869
- }),
2870
- /* @__PURE__ */ jsx(Separator$1, {
2871
- orientation: "vertical",
2872
- className: "mx-1 h-4"
2873
- }),
2874
- /* @__PURE__ */ jsxs("div", {
2875
- className: "flex items-center gap-0.5",
2876
- children: [/* @__PURE__ */ jsxs(Button, {
2877
- variant: "ghost",
2878
- size: "xs",
2879
- onClick: () => onAddTrack("visual"),
2880
- children: [/* @__PURE__ */ jsx(Plus, { className: "size-3" }), "视频轨"]
2881
- }), /* @__PURE__ */ jsxs(Button, {
2882
- variant: "ghost",
2883
- size: "xs",
2884
- onClick: () => onAddTrack("audio"),
2885
- children: [/* @__PURE__ */ jsx(Plus, { className: "size-3" }), "音频轨"]
2886
- })]
2887
- }),
2888
- /* @__PURE__ */ jsxs("div", {
2889
- className: "ml-auto flex items-center gap-2 text-[11px] text-muted-foreground",
2890
- children: [
2891
- selectionCount > 0 && /* @__PURE__ */ jsxs("span", {
2892
- className: "rounded bg-primary/15 px-1.5 py-0.5 text-primary",
2893
- children: ["已选 ", selectionCount]
2894
- }),
2895
- /* @__PURE__ */ jsx(Info, { className: "size-3" }),
2896
- /* @__PURE__ */ jsxs("span", {
2897
- className: "tabular-nums",
2898
- children: [
2899
- /* @__PURE__ */ jsx(Timecode, {
2900
- us: playheadUs,
2901
- className: "text-foreground"
2902
- }),
2903
- /* @__PURE__ */ jsx("span", {
2904
- className: "mx-1",
2905
- children: "/"
2906
- }),
2907
- /* @__PURE__ */ jsx(Timecode, {
2908
- us: durationUs,
2909
- muted: true
2910
- })
2911
- ]
2912
- })
2913
- ]
2914
- })
2915
- ]
2993
+ onClick: () => onAddTrack("visual"),
2994
+ children: [/* @__PURE__ */ jsx(Plus, { className: "size-3" }), "视频轨"]
2995
+ }), /* @__PURE__ */ jsxs(Button, {
2996
+ variant: "ghost",
2997
+ size: "xs",
2998
+ onClick: () => onAddTrack("audio"),
2999
+ children: [/* @__PURE__ */ jsx(Plus, { className: "size-3" }), "音频轨"]
3000
+ })]
3001
+ })
3002
+ ]
3003
+ }), /* @__PURE__ */ jsxs("div", {
3004
+ className: "ml-auto hidden shrink-0 items-center gap-2 pl-2 text-[11px] text-muted-foreground @[720px]:flex",
3005
+ children: [
3006
+ selectionCount > 0 && /* @__PURE__ */ jsxs("span", {
3007
+ className: "rounded bg-primary/15 px-1.5 py-0.5 text-primary",
3008
+ children: ["已选 ", selectionCount]
3009
+ }),
3010
+ /* @__PURE__ */ jsx(Info, { className: "size-3" }),
3011
+ /* @__PURE__ */ jsxs("span", {
3012
+ className: "tabular-nums whitespace-nowrap",
3013
+ children: [
3014
+ /* @__PURE__ */ jsx(Timecode, {
3015
+ us: playheadUs,
3016
+ className: "text-foreground"
3017
+ }),
3018
+ /* @__PURE__ */ jsx("span", {
3019
+ className: "mx-1",
3020
+ children: "/"
3021
+ }),
3022
+ /* @__PURE__ */ jsx(Timecode, {
3023
+ us: durationUs,
3024
+ muted: true
3025
+ })
3026
+ ]
3027
+ })
3028
+ ]
3029
+ })]
2916
3030
  });
2917
3031
  }
2918
3032
  // src/features/timeline/components/Ruler.tsx
@@ -3876,7 +3990,7 @@ function Timeline() {
3876
3990
  splitClip
3877
3991
  ]);
3878
3992
  return /* @__PURE__ */ jsxs("div", {
3879
- className: "flex min-h-0 flex-1 flex-col",
3993
+ className: "flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
3880
3994
  children: [
3881
3995
  /* @__PURE__ */ jsx(TimelineToolbar, {
3882
3996
  playheadUs,
@@ -3896,7 +4010,7 @@ function Timeline() {
3896
4010
  canZoomOut: zoom.canZoomOut
3897
4011
  }),
3898
4012
  /* @__PURE__ */ jsxs("div", {
3899
- className: "relative flex min-h-0 flex-1",
4013
+ className: "relative flex min-h-0 min-w-0 flex-1 overflow-hidden",
3900
4014
  children: [/* @__PURE__ */ jsxs("div", {
3901
4015
  className: "thin-scrollbar flex shrink-0 flex-col overflow-y-auto border-r",
3902
4016
  style: { width: 150 },
@@ -6146,16 +6260,23 @@ function useAddToTimeline() {
6146
6260
  const assets = useEditor((s) => s.assets);
6147
6261
  const playheadUs = useEditor((s) => s.playheadUs);
6148
6262
  const addClip = useEditor((s) => s.addClip);
6263
+ const { bridge } = useWorkbench();
6149
6264
  return useCallback((assetId) => {
6150
6265
  const asset = assets.find((a) => a.id === assetId);
6151
6266
  if (!asset) {
6152
- toast.error("素材不存在,可能已被删除");
6267
+ notify(bridge, {
6268
+ level: "error",
6269
+ title: "素材不存在,可能已被删除"
6270
+ });
6153
6271
  return;
6154
6272
  }
6155
6273
  const kindIsAudio = asset.kind === "audio";
6156
6274
  const track = project.tracks.find((t) => t.kind === (kindIsAudio ? "audio" : "visual")) ?? project.tracks[0];
6157
6275
  if (!track) {
6158
- toast.error("没有可用轨道");
6276
+ notify(bridge, {
6277
+ level: "error",
6278
+ title: "没有可用轨道"
6279
+ });
6159
6280
  return;
6160
6281
  }
6161
6282
  const trackEnd = getTrackEnd(project, track.id);
@@ -6188,6 +6309,7 @@ function useAddToTimeline() {
6188
6309
  }, [
6189
6310
  addClip,
6190
6311
  assets,
6312
+ bridge,
6191
6313
  playheadUs,
6192
6314
  project
6193
6315
  ]);
@@ -6225,1339 +6347,172 @@ var FRAME_US = 1e6 / 30;
6225
6347
  *
6226
6348
  * | 键 | 行为 |
6227
6349
  * |---|---|
6228
- * | Space | 播放 / 暂停 |
6229
- * | S | 在播放头分割选中片段(未选中则分割命中的全部片段) |
6230
- * | Delete / Backspace | 删除选中片段 |
6231
- * | Ctrl+Z / Ctrl+Shift+Z | 撤销 / 重做 |
6232
- * | Ctrl+D | 复制选中片段 |
6233
- * | Ctrl+A | 全选 |
6234
- * | ← / → | 逐帧(1/30 s) |
6235
- * | Home / End | 跳首 / 跳尾 |
6236
- * | + / - | 时间轴缩放 |
6237
- * | Esc | 清空选择 |
6238
- *
6239
- * 设计:编辑类操作直接调 store;播放类操作通过 `cut-test:*` 自定义事件通知预览面板,
6240
- * 避免 store → features 的反向依赖。
6241
- */
6242
- function useKeyboardShortcuts() {
6243
- const editor = useCutEditor();
6244
- const uiApi = useUiStoreApi();
6245
- const handler = useCallback((event) => {
6246
- const target = event.target;
6247
- const isEditable = target != null && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable || target.getAttribute("role") === "textbox" || target.closest("[role=menu]") != null);
6248
- const mod = event.ctrlKey || event.metaKey;
6249
- if (mod && event.key.toLowerCase() === "z") {
6250
- event.preventDefault();
6251
- if (event.shiftKey) editor.store.getState().redo();
6252
- else editor.store.getState().undo();
6253
- return;
6254
- }
6255
- if (mod && event.key.toLowerCase() === "d") {
6256
- event.preventDefault();
6257
- editor.store.getState().duplicateSelection(5e5);
6258
- return;
6259
- }
6260
- if (mod && event.key.toLowerCase() === "a" && !isEditable) {
6261
- event.preventDefault();
6262
- const state = editor.store.getState();
6263
- state.selectClips(state.project.clips.map((c) => c.id));
6264
- return;
6265
- }
6266
- if (mod && (event.key === "+" || event.key === "=" || event.key === "-" || event.key === "_")) {
6267
- event.preventDefault();
6268
- const ui = uiApi.getState();
6269
- const factor = event.key === "-" || event.key === "_" ? 1 / 1.25 : 1.25;
6270
- ui.setPxPerSecond(clamp(ui.pxPerSecond * factor, 10, 500));
6271
- return;
6272
- }
6273
- if (isEditable || mod) return;
6274
- switch (event.key) {
6275
- case " ":
6276
- event.preventDefault();
6277
- window.dispatchEvent(new CustomEvent("cut-test:toggle-play"));
6278
- break;
6279
- case "s":
6280
- case "S": {
6281
- event.preventDefault();
6282
- const state = editor.store.getState();
6283
- state.splitClipsAt(state.playheadUs, state.selectedClipIds.length > 0 ? state.selectedClipIds : void 0);
6284
- break;
6285
- }
6286
- case "Delete":
6287
- case "Backspace": {
6288
- const state = editor.store.getState();
6289
- if (state.selectedClipIds.length === 0) return;
6290
- event.preventDefault();
6291
- state.removeClips(state.selectedClipIds);
6292
- break;
6293
- }
6294
- case "ArrowLeft":
6295
- case "ArrowRight": {
6296
- event.preventDefault();
6297
- const direction = event.key === "ArrowLeft" ? -1 : 1;
6298
- const state = editor.store.getState();
6299
- const total = state.project.clips.reduce((max, c) => Math.max(max, c.startUs + c.durationUs), 0);
6300
- const next = Math.max(0, Math.min(total, state.playheadUs + direction * FRAME_US));
6301
- state.setPlayhead(next);
6302
- window.dispatchEvent(new CustomEvent("cut-test:step-frame", { detail: { direction } }));
6303
- break;
6304
- }
6305
- case "Home":
6306
- event.preventDefault();
6307
- editor.store.getState().setPlayhead(0);
6308
- window.dispatchEvent(new CustomEvent("cut-test:seek", { detail: { us: 0 } }));
6309
- break;
6310
- case "End": {
6311
- event.preventDefault();
6312
- const state = editor.store.getState();
6313
- const total = state.project.clips.reduce((max, c) => Math.max(max, c.startUs + c.durationUs), 0);
6314
- state.setPlayhead(total);
6315
- window.dispatchEvent(new CustomEvent("cut-test:seek", { detail: { us: total } }));
6316
- break;
6317
- }
6318
- case "+":
6319
- case "=": {
6320
- event.preventDefault();
6321
- const ui = uiApi.getState();
6322
- ui.setPxPerSecond(clamp(ui.pxPerSecond * 1.25, 10, 500));
6323
- break;
6324
- }
6325
- case "-":
6326
- case "_": {
6327
- event.preventDefault();
6328
- const ui = uiApi.getState();
6329
- ui.setPxPerSecond(clamp(ui.pxPerSecond / 1.25, 10, 500));
6330
- break;
6331
- }
6332
- case "Escape": editor.store.getState().selectClips([]);
6333
- }
6334
- }, []);
6335
- useEffect(() => {
6336
- window.addEventListener("keydown", handler);
6337
- return () => window.removeEventListener("keydown", handler);
6338
- }, [handler]);
6339
- }
6340
- // src/hooks/use-capability.ts
6341
- var cached = null;
6342
- /**
6343
- * 启动时探测一次浏览器能力(结果在会话内缓存)。
6344
- *
6345
- * 必须在渲染任何 WebAV 组件前完成,否则 opfs-tools 的模块副作用可能直接抛错。
6346
- */
6347
- function useCapability() {
6348
- const [state, setState] = useState({
6349
- report: cached,
6350
- loading: cached == null,
6351
- error: null
6352
- });
6353
- useEffect(() => {
6354
- if (cached != null) return;
6355
- let cancelled = false;
6356
- detectEnvironment().then((report) => {
6357
- cached = report;
6358
- if (!cancelled) setState({
6359
- report,
6360
- loading: false,
6361
- error: null
6362
- });
6363
- }).catch((err) => {
6364
- if (!cancelled) setState({
6365
- report: null,
6366
- loading: false,
6367
- error: err instanceof Error ? err.message : String(err)
6368
- });
6369
- });
6370
- return () => {
6371
- cancelled = true;
6372
- };
6373
- }, []);
6374
- return state;
6375
- }
6376
- // src/components/layout/use-capability-info.ts
6377
- function deriveStatus(report, loading) {
6378
- if (loading) return "checking";
6379
- if (report == null) return "unsupported";
6380
- if (report.opfs && report.composerSupported && report.issues.length === 0) return "ready";
6381
- if (report.opfs && report.composerSupported) return "degraded";
6382
- return "unsupported";
6383
- }
6384
- /** 能力探测的 UI 适配层:把 core 的报告转成状态与文案 */
6385
- function useCapabilityInfo() {
6386
- const { report, loading, error } = useCapability();
6387
- return {
6388
- report,
6389
- loading,
6390
- error,
6391
- status: deriveStatus(report, loading),
6392
- summary: summarizeCapabilities(report)
6393
- };
6394
- }
6395
- // src/features/lab/components/LabCard.tsx
6396
- /** Lab 卡片外壳:标题 + 用到的 API + (不可用时的原因) */
6397
- function LabCard({ title, description, apis, children, unavailable, className }) {
6398
- return /* @__PURE__ */ jsxs(Card, {
6399
- className: cn("flex flex-col", className),
6400
- children: [/* @__PURE__ */ jsxs(CardHeader, {
6401
- className: "gap-1",
6402
- children: [
6403
- /* @__PURE__ */ jsxs("div", {
6404
- className: "flex items-center gap-2",
6405
- children: [/* @__PURE__ */ jsx("div", {
6406
- className: "flex size-6 items-center justify-center rounded bg-primary/10 text-primary",
6407
- children: /* @__PURE__ */ jsx(FlaskConical, { className: "size-3.5" })
6408
- }), /* @__PURE__ */ jsx(CardTitle, {
6409
- className: "text-sm",
6410
- children: title
6411
- })]
6412
- }),
6413
- /* @__PURE__ */ jsx(CardDescription, {
6414
- className: "text-xs",
6415
- children: description
6416
- }),
6417
- /* @__PURE__ */ jsx("div", {
6418
- className: "flex flex-wrap gap-1 pt-1",
6419
- children: apis.map((api) => /* @__PURE__ */ jsx(Badge, {
6420
- variant: "outline",
6421
- className: "font-mono text-[10px] font-normal text-muted-foreground",
6422
- children: api
6423
- }, api))
6424
- })
6425
- ]
6426
- }), /* @__PURE__ */ jsx(CardContent, {
6427
- className: "flex flex-1 flex-col gap-2.5",
6428
- children: unavailable != null ? /* @__PURE__ */ jsx("div", {
6429
- className: "rounded-md border border-dashed border-warning/40 bg-warning/5 p-3 text-xs text-warning",
6430
- children: unavailable
6431
- }) : children
6432
- })]
6433
- });
6434
- }
6435
- // src/features/lab/hooks/use-lab-action.ts
6436
- /** 生成可下载链接(Lab 通用) */
6437
- function useDownload() {
6438
- const urlsRef = useRef([]);
6439
- return useCallback((blob, filename) => {
6440
- const url = URL.createObjectURL(blob);
6441
- urlsRef.current.push(url);
6442
- const link = document.createElement("a");
6443
- link.href = url;
6444
- link.download = filename;
6445
- document.body.appendChild(link);
6446
- link.click();
6447
- link.remove();
6448
- window.setTimeout(() => URL.revokeObjectURL(url), 15e3);
6449
- }, []);
6450
- }
6451
- /** 带 busy 状态的异步执行器:统一错误 toast,禁止静默吞异常 */
6452
- function useLabAction() {
6453
- const [busy, setBusy] = useState(null);
6454
- return {
6455
- busy,
6456
- run: useCallback(async (label, task) => {
6457
- setBusy(label);
6458
- try {
6459
- return await task();
6460
- } catch (err) {
6461
- const message = err instanceof Error ? err.message : String(err);
6462
- console.error(err);
6463
- toast.error("操作失败", { description: message });
6464
- return null;
6465
- } finally {
6466
- setBusy(null);
6467
- }
6468
- }, [])
6469
- };
6470
- }
6471
- // src/features/lab/components/shared.tsx
6472
- /**
6473
- * Lab 共享的可视组件。
6474
- *
6475
- * 只导出组件(React Fast Refresh 要求单文件只导出组件);
6476
- * 工具函数与 hook 放在 `../hooks/use-lab-action`。
6477
- */
6478
- /** 文件选择按钮 */
6479
- function FilePickButton({ label, accept, multiple, onFiles, disabled }) {
6480
- const inputRef = useRef(null);
6481
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("input", {
6482
- ref: inputRef,
6483
- type: "file",
6484
- accept,
6485
- multiple,
6486
- className: "hidden",
6487
- onChange: (e) => {
6488
- const files = Array.from(e.target.files ?? []);
6489
- if (files.length > 0) onFiles(files);
6490
- e.target.value = "";
6491
- }
6492
- }), /* @__PURE__ */ jsx("button", {
6493
- type: "button",
6494
- disabled,
6495
- onClick: () => inputRef.current?.click(),
6496
- className: "flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed px-3 py-3.5 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:pointer-events-none disabled:opacity-50",
6497
- children: /* @__PURE__ */ jsx("span", {
6498
- className: "max-w-full truncate",
6499
- children: label
6500
- })
6501
- })] });
6502
- }
6503
- /** 结果预览:视频 + 下载 */
6504
- function ResultPreview({ blob, filename }) {
6505
- const download = useDownload();
6506
- const [url] = useState(() => URL.createObjectURL(blob));
6507
- useEffect(() => () => URL.revokeObjectURL(url), [url]);
6508
- return /* @__PURE__ */ jsxs("div", {
6509
- className: "space-y-1.5",
6510
- children: [/* @__PURE__ */ jsx("video", {
6511
- src: url,
6512
- controls: true,
6513
- className: "w-full rounded-md border bg-stage-bg",
6514
- style: { maxHeight: 200 }
6515
- }), /* @__PURE__ */ jsxs("div", {
6516
- className: "flex items-center justify-between gap-2",
6517
- children: [/* @__PURE__ */ jsxs("span", {
6518
- className: "min-w-0 flex-1 truncate text-[10px] text-muted-foreground",
6519
- children: [(blob.size / 1024 / 1024).toFixed(2), " MB"]
6520
- }), /* @__PURE__ */ jsx("button", {
6521
- type: "button",
6522
- onClick: () => download(blob, filename),
6523
- className: "rounded-md bg-primary px-2 py-1 text-[11px] text-primary-foreground hover:bg-primary/90",
6524
- children: "下载"
6525
- })]
6526
- })]
6527
- });
6528
- }
6529
- /** 忙碌提示 */
6530
- function Busy({ label }) {
6531
- return /* @__PURE__ */ jsxs("div", {
6532
- className: "flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground",
6533
- children: [/* @__PURE__ */ jsx(Loader2, { className: "size-3 animate-spin" }), label]
6534
- });
6535
- }
6536
- /** 简易滑杆 */
6537
- function SliderRow({ label, value, min, max, step, format, onChange }) {
6538
- return /* @__PURE__ */ jsxs("div", {
6539
- className: "space-y-1",
6540
- children: [/* @__PURE__ */ jsxs("div", {
6541
- className: "flex justify-between text-[10px] text-muted-foreground",
6542
- children: [/* @__PURE__ */ jsx("span", { children: label }), /* @__PURE__ */ jsx("span", {
6543
- className: "font-mono tabular-nums",
6544
- children: format ? format(value) : value.toFixed(2)
6545
- })]
6546
- }), /* @__PURE__ */ jsx("input", {
6547
- type: "range",
6548
- min,
6549
- max,
6550
- step,
6551
- value,
6552
- onChange: (e) => onChange(Number(e.target.value)),
6553
- className: "h-1 w-full cursor-pointer appearance-none rounded-full bg-muted accent-primary"
6554
- })]
6555
- });
6556
- }
6557
- // src/features/lab/components/ConcatenateLab.tsx
6558
- /**
6559
- * Lab 1:无损拼接。
6560
- *
6561
- * 用 `fastConcatMP4` 直接拼接多个 MP4 的样本,不重编码。
6562
- * 前提是编码 / 分辨率 / 采样率完全一致;不一致时本 Lab 会先预检并给出具体差异。
6563
- */
6564
- function ConcatenateLab() {
6565
- const [files, setFiles] = useState([]);
6566
- const [infos, setInfos] = useState([]);
6567
- const [result, setResult] = useState(null);
6568
- const { busy, run } = useLabAction();
6569
- const handleFiles = async (picked) => {
6570
- setFiles(picked);
6571
- setResult(null);
6572
- setInfos([]);
6573
- const detected = await run("正在读取文件信息…", async () => {
6574
- const list = [];
6575
- for (const file of picked) list.push(await readMp4Info(file));
6576
- return list;
6577
- });
6578
- if (detected != null) setInfos(detected);
6579
- };
6580
- const handleConcat = async () => {
6581
- const output = await run("正在无损拼接…", () => concatMp4(files));
6582
- if (output != null) {
6583
- setResult(output.blob);
6584
- setInfos(output.infos);
6585
- }
6586
- };
6587
- const first = infos[0];
6588
- const mismatch = first != null && infos.some((info) => info.width !== first.width || info.height !== first.height || info.codec !== first.codec || info.audioSampleRate !== first.audioSampleRate);
6589
- return /* @__PURE__ */ jsxs(LabCard, {
6590
- title: "无损拼接",
6591
- description: "直接把多个 MP4 的样本拼在一起,不重编码,速度极快。要求规格完全一致。",
6592
- apis: ["fastConcatMP4", "MP4Clip.getFileHeaderBinData"],
6593
- children: [
6594
- /* @__PURE__ */ jsx(FilePickButton, {
6595
- label: files.length > 0 ? `已选 ${files.length} 个文件(重新选择)` : "选择 2 个以上 MP4",
6596
- accept: "video/mp4",
6597
- multiple: true,
6598
- onFiles: (picked) => void handleFiles(picked),
6599
- disabled: busy != null
6600
- }),
6601
- files.length > 0 && /* @__PURE__ */ jsx("ul", {
6602
- className: "space-y-1 text-[10px] text-muted-foreground",
6603
- children: files.map((file, index) => {
6604
- const info = infos[index];
6605
- return /* @__PURE__ */ jsxs("li", {
6606
- className: "flex justify-between gap-2",
6607
- children: [/* @__PURE__ */ jsx("span", {
6608
- className: "truncate",
6609
- children: file.name
6610
- }), /* @__PURE__ */ jsx("span", {
6611
- className: "shrink-0 font-mono",
6612
- children: info != null ? `${formatResolution(info.width, info.height)} · ${formatDuration(info.durationUs)} · ${info.codec}` : "检测中…"
6613
- })]
6614
- }, `${file.name}-${index}`);
6615
- })
6616
- }),
6617
- mismatch && /* @__PURE__ */ jsxs("div", {
6618
- className: "flex items-start gap-1.5 rounded-md bg-warning/10 p-2 text-[10px] text-warning",
6619
- children: [/* @__PURE__ */ jsx(AlertTriangle, { className: "mt-0.5 size-3 shrink-0" }), "检测到规格不一致,拼接会失败并给出具体原因(fastConcatMP4 的硬性要求)。"]
6620
- }),
6621
- /* @__PURE__ */ jsx("button", {
6622
- type: "button",
6623
- disabled: files.length < 2 || busy != null,
6624
- onClick: () => void handleConcat(),
6625
- className: "rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50",
6626
- children: "开始拼接"
6627
- }),
6628
- busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
6629
- result != null && /* @__PURE__ */ jsx(ResultPreview, {
6630
- blob: result,
6631
- filename: "concat.mp4"
6632
- })
6633
- ]
6634
- });
6635
- }
6636
- // src/features/lab/components/RemuxLab.tsx
6637
- /**
6638
- * Lab 2:配音。
6639
- *
6640
- * `mixinMP4AndAudio` 只重编码音频轨,视频轨原样复制 —— 因此速度快、画质无损。
6641
- */
6642
- function RemuxLab() {
6643
- const [video, setVideo] = useState(null);
6644
- const [audio, setAudio] = useState(null);
6645
- const [volume, setVolume] = useState(1);
6646
- const [loop, setLoop] = useState(true);
6647
- const [result, setResult] = useState(null);
6648
- const { busy, run } = useLabAction();
6649
- const handleMix = async () => {
6650
- if (video == null || audio == null) return;
6651
- const output = await run("正在混合音频…", () => mixAudioIntoMp4(video, audio, volume, loop));
6652
- if (output != null) setResult(output);
6653
- };
6654
- return /* @__PURE__ */ jsxs(LabCard, {
6655
- title: "视频配音",
6656
- description: "把音轨混入 MP4:只重编码音频,视频轨不动,画质零损失。",
6657
- apis: ["mixinMP4AndAudio"],
6658
- children: [
6659
- /* @__PURE__ */ jsx(FilePickButton, {
6660
- label: video == null ? "选择视频(MP4)" : video.name,
6661
- accept: "video/mp4",
6662
- onFiles: (files) => {
6663
- setVideo(files[0] ?? null);
6664
- setResult(null);
6665
- },
6666
- disabled: busy != null
6667
- }),
6668
- /* @__PURE__ */ jsx(FilePickButton, {
6669
- label: audio == null ? "选择音频(MP3/WAV/M4A)" : audio.name,
6670
- accept: "audio/*",
6671
- onFiles: (files) => {
6672
- setAudio(files[0] ?? null);
6673
- setResult(null);
6674
- },
6675
- disabled: busy != null
6676
- }),
6677
- /* @__PURE__ */ jsx(SliderRow, {
6678
- label: "音量",
6679
- value: volume,
6680
- min: 0,
6681
- max: 2,
6682
- step: .05,
6683
- format: (v) => `${Math.round(v * 100)}%`,
6684
- onChange: setVolume
6685
- }),
6686
- /* @__PURE__ */ jsxs("label", {
6687
- className: "flex items-center gap-2 text-[11px] text-muted-foreground",
6688
- children: [/* @__PURE__ */ jsx("input", {
6689
- type: "checkbox",
6690
- checked: loop,
6691
- onChange: (e) => setLoop(e.target.checked),
6692
- className: "accent-primary"
6693
- }), "音频短于视频时循环填充"]
6694
- }),
6695
- /* @__PURE__ */ jsx("button", {
6696
- type: "button",
6697
- disabled: video == null || audio == null || busy != null,
6698
- onClick: () => void handleMix(),
6699
- className: "rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50",
6700
- children: "开始混合"
6701
- }),
6702
- busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
6703
- result != null && /* @__PURE__ */ jsx(ResultPreview, {
6704
- blob: result,
6705
- filename: "mixin.mp4"
6706
- })
6707
- ]
6708
- });
6709
- }
6710
- // src/features/lab/components/ChromakeyLab.tsx
6711
- /**
6712
- * Lab 3:绿幕抠像。
6713
- *
6714
- * `createChromakey` 返回一个函数,把 VideoFrame 转成去背后的 ImageBitmap。
6715
- * 配合 `MP4Clip.tickInterceptor` 即可对整段视频生效(导出时同样有效)。
6716
- *
6717
- * ⚠️ 依赖 WebGL2;不支持时本卡片隐藏操作、仅显示原因。
6718
- */
6719
- /**
6720
- * 抠像结果的兜底底色:读取主题令牌,而不是写死 `#101018`。
6721
- *
6722
- * 抠像后的 VideoFrame 带透明通道,需要一层底色才能看清;用 `--cw-stage-bg`
6723
- * 让 Lab 预览区与工作台舞台保持一致,宿主换主题时一起变。
6724
- * 取不到时退回深色(纯逻辑环境 / 未包 `.cw-root`)。
6725
- */
6726
- function readStageBackground() {
6727
- if (typeof document === "undefined") return "#101018";
6728
- const root = document.querySelector(".cw-root");
6729
- if (root == null) return "#101018";
6730
- const value = getComputedStyle(root).getPropertyValue("--cw-stage-bg").trim();
6731
- return value.length > 0 ? value : "#101018";
6732
- }
6733
- function ChromakeyLab() {
6734
- const [webgl2] = useState(isWebGL2Available);
6735
- const [file, setFile] = useState(null);
6736
- const [similarity, setSimilarity] = useState(.4);
6737
- const [smoothness, setSmoothness] = useState(.1);
6738
- const [spill, setSpill] = useState(.1);
6739
- const [timeUs, setTimeUs] = useState(0);
6740
- const [durationUs, setDurationUs] = useState(0);
6741
- const canvasRef = useRef(null);
6742
- const clipRef = useRef(null);
6743
- const { busy, run } = useLabAction();
6744
- useEffect(() => {
6745
- return () => {
6746
- clipRef.current?.destroy();
6747
- clipRef.current = null;
6748
- };
6749
- }, []);
6750
- const render = useCallback(async () => {
6751
- const clip = clipRef.current;
6752
- const canvas = canvasRef.current;
6753
- if (clip == null || canvas == null) return;
6754
- await renderChromakeyFrame(clip, timeUs, {
6755
- similarity,
6756
- smoothness,
6757
- spill,
6758
- keyColor: [
6759
- 0,
6760
- 255,
6761
- 0
6762
- ]
6763
- }, canvas, readStageBackground());
6764
- }, [
6765
- similarity,
6766
- smoothness,
6767
- spill,
6768
- timeUs
6769
- ]);
6770
- const handleFile = async (files) => {
6771
- const picked = files[0];
6772
- if (picked == null) return;
6773
- const result = await run("正在解析视频…", async () => {
6774
- clipRef.current?.destroy();
6775
- return await createMp4Clip(picked);
6776
- });
6777
- if (result == null) return;
6778
- clipRef.current = result.clip;
6779
- setFile(picked);
6780
- setDurationUs(result.meta.duration);
6781
- setTimeUs(Math.min(result.meta.duration / 2, 1e6));
6782
- toast.success("已载入视频,拖动参数查看抠像效果");
6783
- };
6784
- useEffect(() => {
6785
- if (clipRef.current == null || busy != null) return;
6786
- render();
6787
- }, [busy, render]);
6788
- return /* @__PURE__ */ jsxs(LabCard, {
6789
- title: "绿幕抠像",
6790
- description: "用 WebGL2 实时扣除背景色,通过 tickInterceptor 作用到整段视频。",
6791
- apis: ["createChromakey", "MP4Clip.tickInterceptor"],
6792
- unavailable: webgl2 ? null : "当前设备不支持 WebGL2,createChromakey 无法运行。",
6793
- children: [
6794
- /* @__PURE__ */ jsx(FilePickButton, {
6795
- label: file?.name ?? "选择绿幕视频(MP4)",
6796
- accept: "video/mp4",
6797
- onFiles: (files) => void handleFile(files),
6798
- disabled: busy != null
6799
- }),
6800
- /* @__PURE__ */ jsxs("div", {
6801
- className: "relative overflow-hidden rounded-md border bg-stage-bg",
6802
- children: [/* @__PURE__ */ jsx("canvas", {
6803
- ref: canvasRef,
6804
- className: "block w-full",
6805
- style: { maxHeight: 180 }
6806
- }), file == null && /* @__PURE__ */ jsx("div", {
6807
- className: "pointer-events-none absolute inset-0 flex h-24 items-center justify-center text-[10px] text-muted-foreground",
6808
- children: "选择视频后显示抠像结果"
6809
- })]
6810
- }),
6811
- /* @__PURE__ */ jsx(SliderRow, {
6812
- label: "相似度 similarity",
6813
- value: similarity,
6814
- min: 0,
6815
- max: 1,
6816
- step: .01,
6817
- format: (v) => v.toFixed(2),
6818
- onChange: setSimilarity
6819
- }),
6820
- /* @__PURE__ */ jsx(SliderRow, {
6821
- label: "平滑度 smoothness",
6822
- value: smoothness,
6823
- min: 0,
6824
- max: 1,
6825
- step: .01,
6826
- format: (v) => v.toFixed(2),
6827
- onChange: setSmoothness
6828
- }),
6829
- /* @__PURE__ */ jsx(SliderRow, {
6830
- label: "饱和度 spill",
6831
- value: spill,
6832
- min: 0,
6833
- max: 1,
6834
- step: .01,
6835
- format: (v) => v.toFixed(2),
6836
- onChange: setSpill
6837
- }),
6838
- durationUs > 0 && /* @__PURE__ */ jsx(SliderRow, {
6839
- label: "取帧时间",
6840
- value: timeUs,
6841
- min: 0,
6842
- max: Math.max(1, durationUs - 1),
6843
- step: 1e5,
6844
- format: (v) => `${(v / 1e6).toFixed(2)}s`,
6845
- onChange: setTimeUs
6846
- }),
6847
- busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
6848
- /* @__PURE__ */ jsx("p", {
6849
- className: "text-[10px] leading-relaxed text-muted-foreground",
6850
- children: "keyColor 固定为纯绿 (0,255,0)。实际项目中可读取首帧像素自动取色。"
6851
- })
6852
- ]
6853
- });
6854
- }
6855
- // src/features/lab/components/SubtitleLab.tsx
6856
- var SAMPLE_SRT = `1
6857
- 00:00:00,000 --> 00:00:02,000
6858
- 这是第一句字幕
6859
-
6860
- 2
6861
- 00:00:02,000 --> 00:00:04,000
6862
- 第二句会带描边
6863
-
6864
- 3
6865
- 00:00:04,000 --> 00:00:06,000
6866
- EmbedSubtitlesClip 烧录进画面
6867
- `;
6868
- /**
6869
- * Lab 4:字幕烧录。
6870
- *
6871
- * `EmbedSubtitlesClip` 本身就是一个 IClip:tick 返回带字幕的 VideoFrame。
6872
- * 因此既能放进 Combinator 导出,也能直接逐帧预览。
6873
- */
6874
- function SubtitleLab() {
6875
- const [srt, setSrt] = useState(SAMPLE_SRT);
6876
- const [timeUs, setTimeUs] = useState(0);
6877
- const [playing, setPlaying] = useState(false);
6878
- const [result, setResult] = useState(null);
6879
- const { busy, run } = useLabAction();
6880
- const canvasRef = useRef(null);
6881
- const clipRef = useRef(null);
6882
- const rafRef = useRef(null);
6883
- const cues = parseSrt(srt);
6884
- const durationUs = cues.length > 0 ? cues[cues.length - 1].endMs * 1e3 : 0;
6885
- useEffect(() => {
6886
- clipRef.current?.destroy();
6887
- if (cues.length === 0) {
6888
- clipRef.current = null;
6889
- return;
6890
- }
6891
- try {
6892
- clipRef.current = createSubtitlePreviewClip(srt, 640, 360);
6893
- } catch (err) {
6894
- console.warn("[SubtitleLab] create failed", err);
6895
- clipRef.current = null;
6896
- }
6897
- return () => {
6898
- clipRef.current?.destroy();
6899
- clipRef.current = null;
6900
- };
6901
- }, [cues.length, srt]);
6902
- const draw = useCallback(async () => {
6903
- const clip = clipRef.current;
6904
- const canvas = canvasRef.current;
6905
- if (clip == null || canvas == null) return;
6906
- await renderSubtitleFrame(clip, timeUs, canvas);
6907
- }, [timeUs]);
6908
- useEffect(() => {
6909
- draw();
6910
- }, [draw]);
6911
- useEffect(() => {
6912
- if (!playing) return;
6913
- let last = performance.now();
6914
- let current = timeUs;
6915
- const tick = () => {
6916
- const now = performance.now();
6917
- current += (now - last) * 1e3;
6918
- last = now;
6919
- if (durationUs > 0 && current >= durationUs) {
6920
- setTimeUs(durationUs);
6921
- setPlaying(false);
6922
- return;
6923
- }
6924
- setTimeUs(current);
6925
- rafRef.current = requestAnimationFrame(tick);
6926
- };
6927
- rafRef.current = requestAnimationFrame(tick);
6928
- return () => {
6929
- if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
6930
- };
6931
- }, [playing, durationUs]);
6932
- const handleBurn = async () => {
6933
- const width = 1280;
6934
- const height = 720;
6935
- const output = await run("正在烧录字幕…", () => burnSubtitles(srt, width, height, durationUs));
6936
- if (output != null) setResult(output);
6937
- };
6938
- return /* @__PURE__ */ jsxs(LabCard, {
6939
- title: "字幕烧录",
6940
- description: "SRT 解析后逐帧绘制到画面,最终编码进视频流(硬字幕)。",
6941
- apis: ["EmbedSubtitlesClip", "Combinator"],
6942
- children: [
6943
- /* @__PURE__ */ jsx("textarea", {
6944
- value: srt,
6945
- onChange: (e) => setSrt(e.target.value),
6946
- rows: 5,
6947
- className: "w-full rounded-md border bg-transparent p-2 font-mono text-[10px] outline-none focus-visible:border-ring",
6948
- placeholder: "粘贴 SRT 内容"
6949
- }),
6950
- /* @__PURE__ */ jsx("div", {
6951
- className: "overflow-hidden rounded-md border bg-stage-bg",
6952
- children: /* @__PURE__ */ jsx("canvas", {
6953
- ref: canvasRef,
6954
- width: 640,
6955
- height: 360,
6956
- className: "block w-full",
6957
- style: { maxHeight: 180 }
6958
- })
6959
- }),
6960
- /* @__PURE__ */ jsxs("div", {
6961
- className: "flex items-center gap-2",
6962
- children: [/* @__PURE__ */ jsxs("button", {
6963
- type: "button",
6964
- onClick: () => setPlaying((p) => !p),
6965
- disabled: cues.length === 0,
6966
- className: "flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-[11px] disabled:opacity-50",
6967
- children: [playing ? /* @__PURE__ */ jsx(Square, { className: "size-3" }) : /* @__PURE__ */ jsx(Play, { className: "size-3" }), playing ? "停止" : "预览"]
6968
- }), /* @__PURE__ */ jsxs("span", {
6969
- className: "text-[10px] text-muted-foreground",
6970
- children: [
6971
- cues.length,
6972
- " 条 · ",
6973
- (timeUs / 1e6).toFixed(2),
6974
- "s / ",
6975
- (durationUs / 1e6).toFixed(2),
6976
- "s"
6977
- ]
6978
- })]
6979
- }),
6980
- /* @__PURE__ */ jsx(SliderRow, {
6981
- label: "时间",
6982
- value: timeUs,
6983
- min: 0,
6984
- max: Math.max(1, durationUs),
6985
- step: 1e4,
6986
- format: (v) => `${(v / 1e6).toFixed(2)}s`,
6987
- onChange: (v) => {
6988
- setPlaying(false);
6989
- setTimeUs(v);
6990
- }
6991
- }),
6992
- /* @__PURE__ */ jsx("button", {
6993
- type: "button",
6994
- disabled: cues.length === 0 || busy != null,
6995
- onClick: () => void handleBurn(),
6996
- className: "rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50",
6997
- children: "烧录并导出 MP4"
6998
- }),
6999
- busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
7000
- result != null && /* @__PURE__ */ jsx(ResultPreview, {
7001
- blob: result,
7002
- filename: "subtitles.mp4"
7003
- })
7004
- ]
7005
- });
7006
- }
7007
- // src/features/lab/components/TrackSplitLab.tsx
7008
- /**
7009
- * Lab 5:轨道拆分。
7010
- *
7011
- * `splitTrack()` 从一个 MP4Clip 派生出「纯视频」与「纯音频」两个 clip,
7012
- * 它们与源共享样本表,因此内存开销很小。
7013
- */
7014
- function TrackSplitLab() {
7015
- const [file, setFile] = useState(null);
7016
- const [result, setResult] = useState(null);
7017
- const { busy, run } = useLabAction();
7018
- const cleanupRef = useRef(null);
7019
- useEffect(() => {
7020
- return () => cleanupRef.current?.();
7021
- }, []);
7022
- const handleSplit = async () => {
7023
- if (file == null) return;
7024
- cleanupRef.current?.();
7025
- const output = await run("正在拆分轨道…", () => splitMp4Tracks(file));
7026
- setResult(output);
7027
- cleanupRef.current = () => {
7028
- output?.videoClip?.destroy();
7029
- output?.audioClip?.destroy();
7030
- };
7031
- };
7032
- return /* @__PURE__ */ jsxs(LabCard, {
7033
- title: "轨道拆分",
7034
- description: "把一个 MP4 拆成纯视频轨与纯音频轨两个独立 IClip,用于分别编辑。",
7035
- apis: ["MP4Clip.splitTrack"],
7036
- children: [
7037
- /* @__PURE__ */ jsx(FilePickButton, {
7038
- label: file?.name ?? "选择 MP4",
7039
- accept: "video/mp4",
7040
- onFiles: (files) => {
7041
- setFile(files[0] ?? null);
7042
- setResult(null);
7043
- },
7044
- disabled: busy != null
7045
- }),
7046
- /* @__PURE__ */ jsxs("button", {
7047
- type: "button",
7048
- disabled: file == null || busy != null,
7049
- onClick: () => void handleSplit(),
7050
- className: "flex items-center justify-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50",
7051
- children: [/* @__PURE__ */ jsx(Split, { className: "size-3" }), "拆分轨道"]
7052
- }),
7053
- busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
7054
- result != null && /* @__PURE__ */ jsxs("div", {
7055
- className: "grid gap-1.5 text-[10px]",
7056
- children: [
7057
- /* @__PURE__ */ jsx(TrackResult, {
7058
- label: "视频轨",
7059
- present: result.videoMeta != null,
7060
- detail: result.videoMeta != null ? `${result.videoMeta.width}×${result.videoMeta.height} · ${formatDuration(result.videoMeta.duration)}` : "源文件无视频轨"
7061
- }),
7062
- /* @__PURE__ */ jsx(TrackResult, {
7063
- label: "音频轨",
7064
- present: result.audioMeta != null,
7065
- detail: result.audioMeta != null ? `48kHz 双声道 · ${formatDuration(result.audioMeta.duration)}` : "源文件无音频轨"
7066
- }),
7067
- /* @__PURE__ */ jsx("p", {
7068
- className: "pt-1 leading-relaxed text-muted-foreground",
7069
- children: "拆分出的 clip 与源共享样本表,可分别创建 Sprite 放到不同轨道;两者在时间轴上对齐即等于原片。"
7070
- })
7071
- ]
7072
- })
7073
- ]
7074
- });
7075
- }
7076
- function TrackResult({ label, present, detail }) {
7077
- return /* @__PURE__ */ jsxs("div", {
7078
- className: "flex items-center justify-between gap-2 rounded-md border px-2 py-1.5",
7079
- children: [/* @__PURE__ */ jsx("span", {
7080
- className: present ? "text-foreground" : "text-muted-foreground",
7081
- children: label
7082
- }), /* @__PURE__ */ jsx("span", {
7083
- className: present ? "font-mono text-success" : "text-muted-foreground",
7084
- children: present ? detail : detail
7085
- })]
7086
- });
7087
- }
7088
- // src/features/lab/components/ThumbnailLab.tsx
7089
- /**
7090
- * Lab 6:缩略图抽帧。
7091
- *
7092
- * `MP4Clip.thumbnails()` 默认每个关键帧生成一张;也可以传 step 按固定间隔取。
7093
- * 这里展示全部关键帧与时间戳。
7094
- */
7095
- function ThumbnailLab() {
7096
- const [file, setFile] = useState(null);
7097
- const [frames, setFrames] = useState([]);
7098
- const [width, setWidth] = useState(120);
7099
- const { busy, run } = useLabAction();
7100
- useEffect(() => {
7101
- return () => {
7102
- for (const frame of frames) URL.revokeObjectURL(frame.url);
7103
- };
7104
- }, [frames]);
7105
- const handleExtract = async () => {
7106
- if (file == null) return;
7107
- const output = await run("正在抽取关键帧…", () => extractKeyframes(file, width));
7108
- if (output != null) setFrames(output);
7109
- };
7110
- return /* @__PURE__ */ jsxs(LabCard, {
7111
- title: "缩略图抽帧",
7112
- description: "按关键帧抽取缩略图,可用于时间轴缩略图带或场景切分预览。",
7113
- apis: ["MP4Clip.thumbnails"],
7114
- children: [
7115
- /* @__PURE__ */ jsx(FilePickButton, {
7116
- label: file?.name ?? "选择 MP4",
7117
- accept: "video/mp4",
7118
- onFiles: (files) => {
7119
- setFile(files[0] ?? null);
7120
- setFrames([]);
7121
- },
7122
- disabled: busy != null
7123
- }),
7124
- /* @__PURE__ */ jsxs("div", {
7125
- className: "flex items-center gap-2",
7126
- children: [
7127
- /* @__PURE__ */ jsx("label", {
7128
- className: "text-[10px] text-muted-foreground",
7129
- children: "宽度"
7130
- }),
7131
- /* @__PURE__ */ jsx("input", {
7132
- type: "number",
7133
- min: 40,
7134
- max: 480,
7135
- step: 20,
7136
- value: width,
7137
- onChange: (e) => setWidth(Number(e.target.value)),
7138
- className: "h-6 w-16 rounded border bg-transparent px-1.5 text-[11px]"
7139
- }),
7140
- /* @__PURE__ */ jsx("button", {
7141
- type: "button",
7142
- disabled: file == null || busy != null,
7143
- onClick: () => void handleExtract(),
7144
- className: "ml-auto rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50",
7145
- children: "抽取"
7146
- })
7147
- ]
7148
- }),
7149
- busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
7150
- frames.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("p", {
7151
- className: "text-[10px] text-muted-foreground",
7152
- children: [
7153
- "共 ",
7154
- frames.length,
7155
- " 张关键帧"
7156
- ]
7157
- }), /* @__PURE__ */ jsx("div", {
7158
- className: "thin-scrollbar grid max-h-52 grid-cols-3 gap-1.5 overflow-auto",
7159
- children: frames.map((frame, index) => /* @__PURE__ */ jsxs("figure", {
7160
- className: "space-y-0.5",
7161
- children: [/* @__PURE__ */ jsx("img", {
7162
- src: frame.url,
7163
- alt: "",
7164
- className: "w-full rounded border object-cover",
7165
- loading: "lazy"
7166
- }), /* @__PURE__ */ jsx("figcaption", {
7167
- className: "font-mono text-[9px] text-muted-foreground tabular-nums",
7168
- children: formatTimecode(frame.ts)
7169
- })]
7170
- }, `${frame.ts}-${index}`))
7171
- })] })
7172
- ]
7173
- });
7174
- }
7175
- // src/features/lab/components/RecorderLab.tsx
7176
- /**
7177
- * Lab 7:屏幕 / 画布录制。
6350
+ * | Space | 播放 / 暂停 |
6351
+ * | S | 在播放头分割选中片段(未选中则分割命中的全部片段) |
6352
+ * | Delete / Backspace | 删除选中片段 |
6353
+ * | Ctrl+Z / Ctrl+Shift+Z | 撤销 / 重做 |
6354
+ * | Ctrl+D | 复制选中片段 |
6355
+ * | Ctrl+A | 全选 |
6356
+ * | ← / → | 逐帧(1/30 s) |
6357
+ * | Home / End | 跳首 / 跳尾 |
6358
+ * | + / - | 时间轴缩放 |
6359
+ * | Esc | 清空选择 |
7178
6360
  *
7179
- * `AVRecorder` 接收任意 MediaStream 并输出 MP4(H.264 + AAC)。
7180
- * 这里提供两种源:
7181
- * - 程序化画布(Canvas.captureStream,无需授权);
7182
- * - 摄像头(getUserMedia,需要用户授权)。
6361
+ * 设计:编辑类操作直接调 store;播放类操作通过 `cut-test:*` 自定义事件通知预览面板,
6362
+ * 避免 store → features 的反向依赖。
7183
6363
  */
7184
- function RecorderLab() {
7185
- const [mode, setMode] = useState("demo");
7186
- const [recording, setRecording] = useState(false);
7187
- const [paused, setPaused] = useState(false);
7188
- const [bytes, setBytes] = useState(0);
7189
- const [state, setState] = useState("inactive");
7190
- const [result, setResult] = useState(null);
7191
- const [previewStream, setPreviewStream] = useState(null);
7192
- const videoRef = useRef(null);
7193
- const recorderRef = useRef(null);
7194
- const demoRef = useRef(null);
7195
- const cameraRef = useRef(null);
7196
- const { run } = useLabAction();
7197
- useEffect(() => {
7198
- const video = videoRef.current;
7199
- if (video == null) return;
7200
- video.srcObject = previewStream;
7201
- if (previewStream != null) video.play().catch(() => void 0);
7202
- return () => {
7203
- if (video.srcObject === previewStream) video.srcObject = null;
7204
- };
7205
- }, [previewStream]);
7206
- const cleanup = useCallback(() => {
7207
- demoRef.current?.stop();
7208
- demoRef.current = null;
7209
- cameraRef.current?.getTracks().forEach((t) => t.stop());
7210
- cameraRef.current = null;
7211
- recorderRef.current?.dispose();
7212
- recorderRef.current = null;
7213
- setPreviewStream(null);
7214
- }, []);
7215
- useEffect(() => cleanup, [cleanup]);
7216
- const acquireStream = async () => {
7217
- cleanup();
7218
- if (mode === "demo") {
7219
- const demo = createDemoCanvasStream(1280, 720);
7220
- demoRef.current = demo;
7221
- setPreviewStream(demo.stream);
7222
- return demo.stream;
6364
+ function useKeyboardShortcuts() {
6365
+ const editor = useCutEditor();
6366
+ const uiApi = useUiStoreApi();
6367
+ const handler = useCallback((event) => {
6368
+ const target = event.target;
6369
+ const isEditable = target != null && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable || target.getAttribute("role") === "textbox" || target.closest("[role=menu]") != null);
6370
+ const mod = event.ctrlKey || event.metaKey;
6371
+ if (mod && event.key.toLowerCase() === "z") {
6372
+ event.preventDefault();
6373
+ if (event.shiftKey) editor.store.getState().redo();
6374
+ else editor.store.getState().undo();
6375
+ return;
7223
6376
  }
7224
- try {
7225
- const stream = await navigator.mediaDevices.getUserMedia({
7226
- video: true,
7227
- audio: true
7228
- });
7229
- cameraRef.current = stream;
7230
- setPreviewStream(stream);
7231
- return stream;
7232
- } catch (err) {
7233
- const message = err instanceof Error ? err.message : String(err);
7234
- toast.error("无法访问摄像头", { description: message });
7235
- return null;
6377
+ if (mod && event.key.toLowerCase() === "d") {
6378
+ event.preventDefault();
6379
+ editor.store.getState().duplicateSelection(5e5);
6380
+ return;
7236
6381
  }
7237
- };
7238
- const handleStart = async () => {
7239
- const stream = await acquireStream();
7240
- if (stream == null) return;
7241
- setBytes(0);
7242
- setResult(null);
7243
- const recorder = createRecorderAdapter(stream, (total) => setBytes(total));
7244
- recorderRef.current = recorder;
7245
- recorder.start();
7246
- setRecording(true);
7247
- setPaused(false);
7248
- setState(recorder.state());
7249
- };
7250
- const handlePauseToggle = () => {
7251
- const recorder = recorderRef.current;
7252
- if (recorder == null) return;
7253
- if (paused) {
7254
- recorder.resume();
7255
- setPaused(false);
7256
- } else {
7257
- recorder.pause();
7258
- setPaused(true);
6382
+ if (mod && event.key.toLowerCase() === "a" && !isEditable) {
6383
+ event.preventDefault();
6384
+ const state = editor.store.getState();
6385
+ state.selectClips(state.project.clips.map((c) => c.id));
6386
+ return;
7259
6387
  }
7260
- setState(recorder.state());
7261
- };
7262
- const handleStop = async () => {
7263
- const recorder = recorderRef.current;
7264
- if (recorder == null) return;
7265
- const output = await run("正在封装 MP4…", async () => {
7266
- const raw = await recorder.stop();
7267
- return fixRecordedDuration(raw);
7268
- });
7269
- setRecording(false);
7270
- setPaused(false);
7271
- setState(recorder.state());
7272
- recorderRef.current = null;
7273
- if (output != null) {
7274
- setResult(output);
7275
- toast.success(`录制完成 · ${(output.size / 1024 / 1024).toFixed(2)} MB`);
6388
+ if (mod && (event.key === "+" || event.key === "=" || event.key === "-" || event.key === "_")) {
6389
+ event.preventDefault();
6390
+ const ui = uiApi.getState();
6391
+ const factor = event.key === "-" || event.key === "_" ? 1 / 1.25 : 1.25;
6392
+ ui.setPxPerSecond(clamp(ui.pxPerSecond * factor, 10, 500));
6393
+ return;
7276
6394
  }
7277
- };
7278
- return /* @__PURE__ */ jsxs(LabCard, {
7279
- title: "屏幕 / 画布录制",
7280
- description: "用 AVRecorder 把 MediaStream 实时编码为 MP4。可录制程序化画布或摄像头。",
7281
- apis: ["AVCanvas.captureStream", "AVRecorder.start/pause/resume/stop"],
7282
- children: [
7283
- /* @__PURE__ */ jsx("div", {
7284
- className: "flex gap-1.5",
7285
- children: ["demo", "camera"].map((item) => /* @__PURE__ */ jsx("button", {
7286
- type: "button",
7287
- disabled: recording,
7288
- onClick: () => setMode(item),
7289
- className: `flex-1 rounded-md border px-2 py-1 text-[11px] disabled:opacity-50 ${mode === item ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground"}`,
7290
- children: item === "demo" ? "动态画布" : "摄像头"
7291
- }, item))
7292
- }),
7293
- /* @__PURE__ */ jsx("div", {
7294
- className: "overflow-hidden rounded-md border bg-stage-bg",
7295
- children: /* @__PURE__ */ jsx("video", {
7296
- ref: videoRef,
7297
- muted: true,
7298
- playsInline: true,
7299
- className: "block w-full",
7300
- style: { maxHeight: 170 }
7301
- })
7302
- }),
7303
- /* @__PURE__ */ jsxs("div", {
7304
- className: "flex flex-wrap items-center gap-1.5",
7305
- children: [!recording ? /* @__PURE__ */ jsxs("button", {
7306
- type: "button",
7307
- onClick: () => void handleStart(),
7308
- className: "flex items-center gap-1 rounded-md bg-destructive px-2.5 py-1 text-[11px] text-destructive-foreground",
7309
- children: [/* @__PURE__ */ jsx(Circle, { className: "size-3 fill-current" }), "开始录制"]
7310
- }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
7311
- type: "button",
7312
- onClick: handlePauseToggle,
7313
- className: "flex items-center gap-1 rounded-md bg-secondary px-2.5 py-1 text-[11px]",
7314
- children: [paused ? /* @__PURE__ */ jsx(Play, { className: "size-3" }) : /* @__PURE__ */ jsx(Pause, { className: "size-3" }), paused ? "继续" : "暂停"]
7315
- }), /* @__PURE__ */ jsxs("button", {
7316
- type: "button",
7317
- onClick: () => void handleStop(),
7318
- className: "flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-[11px] text-primary-foreground",
7319
- children: [/* @__PURE__ */ jsx(Square, { className: "size-3" }), "停止"]
7320
- })] }), /* @__PURE__ */ jsxs("span", {
7321
- className: "ml-auto font-mono text-[10px] text-muted-foreground tabular-nums",
7322
- children: [
7323
- (bytes / 1024 / 1024).toFixed(2),
7324
- " MB · ",
7325
- formatRecorderState(state)
7326
- ]
7327
- })]
7328
- }),
7329
- result != null && /* @__PURE__ */ jsx(ResultPreview, {
7330
- blob: result,
7331
- filename: "recording.mp4"
7332
- })
7333
- ]
7334
- });
7335
- }
7336
- /** 适配 RecorderHandle(隔离 AVRecorder 细节) */
7337
- function createRecorderAdapter(stream, onChunk) {
7338
- const recorder = new AVRecorder(stream, { expectFPS: 30 });
7339
- const chunks = [];
7340
- let total = 0;
7341
- return {
7342
- start: () => {
7343
- const readable = recorder.start(300);
7344
- (async () => {
7345
- const reader = readable.getReader();
7346
- for (;;) try {
7347
- const { done, value } = await reader.read();
7348
- if (done) break;
7349
- if (value != null) {
7350
- chunks.push(new Uint8Array(value));
7351
- total += value.byteLength;
7352
- onChunk(total);
7353
- }
7354
- } catch {
7355
- break;
7356
- }
7357
- })();
7358
- },
7359
- pause: () => recorder.pause(),
7360
- resume: () => recorder.resume(),
7361
- stop: async () => {
7362
- await recorder.stop();
7363
- return new Blob(chunks, { type: "video/mp4" });
7364
- },
7365
- state: () => recorder.state,
7366
- dispose: () => {
7367
- stream.getTracks().forEach((t) => t.stop());
6395
+ if (isEditable || mod) return;
6396
+ switch (event.key) {
6397
+ case " ":
6398
+ event.preventDefault();
6399
+ window.dispatchEvent(new CustomEvent("cut-test:toggle-play"));
6400
+ break;
6401
+ case "s":
6402
+ case "S": {
6403
+ event.preventDefault();
6404
+ const state = editor.store.getState();
6405
+ state.splitClipsAt(state.playheadUs, state.selectedClipIds.length > 0 ? state.selectedClipIds : void 0);
6406
+ break;
6407
+ }
6408
+ case "Delete":
6409
+ case "Backspace": {
6410
+ const state = editor.store.getState();
6411
+ if (state.selectedClipIds.length === 0) return;
6412
+ event.preventDefault();
6413
+ state.removeClips(state.selectedClipIds);
6414
+ break;
6415
+ }
6416
+ case "ArrowLeft":
6417
+ case "ArrowRight": {
6418
+ event.preventDefault();
6419
+ const direction = event.key === "ArrowLeft" ? -1 : 1;
6420
+ const state = editor.store.getState();
6421
+ const total = state.project.clips.reduce((max, c) => Math.max(max, c.startUs + c.durationUs), 0);
6422
+ const next = Math.max(0, Math.min(total, state.playheadUs + direction * FRAME_US));
6423
+ state.setPlayhead(next);
6424
+ window.dispatchEvent(new CustomEvent("cut-test:step-frame", { detail: { direction } }));
6425
+ break;
6426
+ }
6427
+ case "Home":
6428
+ event.preventDefault();
6429
+ editor.store.getState().setPlayhead(0);
6430
+ window.dispatchEvent(new CustomEvent("cut-test:seek", { detail: { us: 0 } }));
6431
+ break;
6432
+ case "End": {
6433
+ event.preventDefault();
6434
+ const state = editor.store.getState();
6435
+ const total = state.project.clips.reduce((max, c) => Math.max(max, c.startUs + c.durationUs), 0);
6436
+ state.setPlayhead(total);
6437
+ window.dispatchEvent(new CustomEvent("cut-test:seek", { detail: { us: total } }));
6438
+ break;
6439
+ }
6440
+ case "+":
6441
+ case "=": {
6442
+ event.preventDefault();
6443
+ const ui = uiApi.getState();
6444
+ ui.setPxPerSecond(clamp(ui.pxPerSecond * 1.25, 10, 500));
6445
+ break;
6446
+ }
6447
+ case "-":
6448
+ case "_": {
6449
+ event.preventDefault();
6450
+ const ui = uiApi.getState();
6451
+ ui.setPxPerSecond(clamp(ui.pxPerSecond / 1.25, 10, 500));
6452
+ break;
6453
+ }
6454
+ case "Escape": editor.store.getState().selectClips([]);
7368
6455
  }
7369
- };
6456
+ }, []);
6457
+ useEffect(() => {
6458
+ window.addEventListener("keydown", handler);
6459
+ return () => window.removeEventListener("keydown", handler);
6460
+ }, [handler]);
7370
6461
  }
7371
- // src/features/lab/components/CustomClipLab.tsx
6462
+ // src/hooks/use-capability.ts
6463
+ var cached = null;
7372
6464
  /**
7373
- * Lab 8:自定义 IClip。
7374
- *
7375
- * `FrameCounterClip` 是本项目实现的 IClip:不依赖任何媒体文件,
7376
- * 逐帧用 OffscreenCanvas 画出帧号 / 进度条 / 色相变化。
6465
+ * 启动时探测一次浏览器能力(结果在会话内缓存)。
7377
6466
  *
7378
- * 它证明 IClip 接口(tick / ready / meta / clone / split / destroy)可以被任意扩展,
7379
- * 且能直接被 Combinator 编码成 MP4。
6467
+ * 必须在渲染任何 WebAV 组件前完成,否则 opfs-tools 的模块副作用可能直接抛错。
7380
6468
  */
7381
- function CustomClipLab() {
7382
- const [durationSec, setDurationSec] = useState(5);
7383
- const [timeUs, setTimeUs] = useState(0);
7384
- const [playing, setPlaying] = useState(false);
7385
- const [result, setResult] = useState(null);
7386
- const canvasRef = useRef(null);
7387
- const clipRef = useRef(null);
7388
- const rafRef = useRef(null);
7389
- const { busy, run } = useLabAction();
7390
- const durationUs = Math.round(durationSec * 1e6);
7391
- useEffect(() => {
7392
- clipRef.current?.destroy();
7393
- try {
7394
- clipRef.current = new FrameCounterClip(640, 360, durationUs, 30);
7395
- } catch (err) {
7396
- console.warn("[CustomClipLab] create failed", err);
7397
- clipRef.current = null;
7398
- }
7399
- return () => {
7400
- clipRef.current?.destroy();
7401
- clipRef.current = null;
7402
- };
7403
- }, [durationUs]);
7404
- const draw = useCallback(async () => {
7405
- const clip = clipRef.current;
7406
- const canvas = canvasRef.current;
7407
- if (clip == null || canvas == null) return;
7408
- await renderClipFrameToCanvas(clip, timeUs, canvas);
7409
- }, [timeUs]);
7410
- useEffect(() => {
7411
- draw();
7412
- }, [draw]);
6469
+ function useCapability() {
6470
+ const [state, setState] = useState({
6471
+ report: cached,
6472
+ loading: cached == null,
6473
+ error: null
6474
+ });
7413
6475
  useEffect(() => {
7414
- if (!playing) return;
7415
- let last = performance.now();
7416
- let current = timeUs;
7417
- const tick = () => {
7418
- const now = performance.now();
7419
- current += (now - last) * 1e3;
7420
- last = now;
7421
- if (current >= durationUs) {
7422
- setTimeUs(durationUs - 1);
7423
- setPlaying(false);
7424
- return;
7425
- }
7426
- setTimeUs(current);
7427
- rafRef.current = requestAnimationFrame(tick);
7428
- };
7429
- rafRef.current = requestAnimationFrame(tick);
6476
+ if (cached != null) return;
6477
+ let cancelled = false;
6478
+ detectEnvironment().then((report) => {
6479
+ cached = report;
6480
+ if (!cancelled) setState({
6481
+ report,
6482
+ loading: false,
6483
+ error: null
6484
+ });
6485
+ }).catch((err) => {
6486
+ if (!cancelled) setState({
6487
+ report: null,
6488
+ loading: false,
6489
+ error: err instanceof Error ? err.message : String(err)
6490
+ });
6491
+ });
7430
6492
  return () => {
7431
- if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
6493
+ cancelled = true;
7432
6494
  };
7433
- }, [playing, durationUs]);
7434
- const handleExport = async () => {
7435
- const clip = clipRef.current;
7436
- if (clip == null) return;
7437
- const output = await run("正在编码自定义 Clip…", () => exportCustomClip(clip, durationUs));
7438
- if (output != null) setResult(output);
7439
- };
7440
- return /* @__PURE__ */ jsxs(LabCard, {
7441
- title: "自定义 IClip",
7442
- description: "纯程序化渲染的 Clip(帧计数器),不依赖任何媒体文件,证明接口可扩展。",
7443
- apis: [
7444
- "IClip.tick",
7445
- "IClip.clone",
7446
- "IClip.split",
7447
- "Combinator"
7448
- ],
7449
- children: [
7450
- /* @__PURE__ */ jsx("div", {
7451
- className: "overflow-hidden rounded-md border bg-stage-bg",
7452
- children: /* @__PURE__ */ jsx("canvas", {
7453
- ref: canvasRef,
7454
- width: 640,
7455
- height: 360,
7456
- className: "block w-full",
7457
- style: { maxHeight: 180 }
7458
- })
7459
- }),
7460
- /* @__PURE__ */ jsxs("div", {
7461
- className: "flex items-center gap-2",
7462
- children: [/* @__PURE__ */ jsxs("button", {
7463
- type: "button",
7464
- onClick: () => {
7465
- if (!playing && timeUs >= durationUs - 1) setTimeUs(0);
7466
- setPlaying((p) => !p);
7467
- },
7468
- className: "flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-[11px]",
7469
- children: [playing ? /* @__PURE__ */ jsx(Square, { className: "size-3" }) : /* @__PURE__ */ jsx(Play, { className: "size-3" }), playing ? "停止" : "预览"]
7470
- }), /* @__PURE__ */ jsxs("span", {
7471
- className: "font-mono text-[10px] text-muted-foreground tabular-nums",
7472
- children: [
7473
- formatTimecode(timeUs),
7474
- " / ",
7475
- formatTimecode(durationUs)
7476
- ]
7477
- })]
7478
- }),
7479
- /* @__PURE__ */ jsx(SliderRow, {
7480
- label: "时间",
7481
- value: timeUs,
7482
- min: 0,
7483
- max: Math.max(1, durationUs - 1),
7484
- step: 1e4,
7485
- format: (v) => `${(v / 1e6).toFixed(2)}s`,
7486
- onChange: (v) => {
7487
- setPlaying(false);
7488
- setTimeUs(v);
7489
- }
7490
- }),
7491
- /* @__PURE__ */ jsx(SliderRow, {
7492
- label: "时长",
7493
- value: durationSec,
7494
- min: 1,
7495
- max: 20,
7496
- step: .5,
7497
- format: (v) => `${v.toFixed(1)}s`,
7498
- onChange: (v) => {
7499
- setPlaying(false);
7500
- setDurationSec(v);
7501
- setTimeUs(0);
7502
- }
7503
- }),
7504
- /* @__PURE__ */ jsx("button", {
7505
- type: "button",
7506
- disabled: busy != null,
7507
- onClick: () => void handleExport(),
7508
- className: "rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50",
7509
- children: "编码为 MP4"
7510
- }),
7511
- busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
7512
- result != null && /* @__PURE__ */ jsx(ResultPreview, {
7513
- blob: result,
7514
- filename: "custom-clip.mp4"
7515
- })
7516
- ]
7517
- });
6495
+ }, []);
6496
+ return state;
7518
6497
  }
7519
- // src/features/lab/components/LabPage.tsx
7520
- /**
7521
- * 能力实验室:每个 WebAV 高级 API 一个可独立运行的卡片。
7522
- *
7523
- * 与主编辑器共享同一套能力探测结果,因此这里不再重复检测。
7524
- */
7525
- function LabPage() {
7526
- return /* @__PURE__ */ jsxs("div", {
7527
- className: "mx-auto max-w-7xl space-y-4 p-4",
7528
- children: [
7529
- /* @__PURE__ */ jsxs("header", {
7530
- className: "space-y-1",
7531
- children: [/* @__PURE__ */ jsxs("div", {
7532
- className: "flex items-center gap-2",
7533
- children: [/* @__PURE__ */ jsx(FlaskConical, { className: "size-4 text-primary" }), /* @__PURE__ */ jsx("h1", {
7534
- className: "text-sm font-semibold",
7535
- children: "能力实验室"
7536
- })]
7537
- }), /* @__PURE__ */ jsx("p", {
7538
- className: "text-xs text-muted-foreground",
7539
- children: "覆盖 av-cliper / av-canvas / av-recorder 的进阶 API。每个卡片可独立运行,失败时给出可读原因。"
7540
- })]
7541
- }),
7542
- /* @__PURE__ */ jsxs("div", {
7543
- className: "grid gap-3 md:grid-cols-2 xl:grid-cols-3",
7544
- children: [
7545
- /* @__PURE__ */ jsx(ConcatenateLab, {}),
7546
- /* @__PURE__ */ jsx(RemuxLab, {}),
7547
- /* @__PURE__ */ jsx(ChromakeyLab, {}),
7548
- /* @__PURE__ */ jsx(SubtitleLab, {}),
7549
- /* @__PURE__ */ jsx(TrackSplitLab, {}),
7550
- /* @__PURE__ */ jsx(ThumbnailLab, {}),
7551
- /* @__PURE__ */ jsx(RecorderLab, {}),
7552
- /* @__PURE__ */ jsx(CustomClipLab, {})
7553
- ]
7554
- }),
7555
- /* @__PURE__ */ jsx("footer", {
7556
- className: "border-t pt-3 text-[10px] leading-relaxed text-muted-foreground",
7557
- children: "说明:所有输出均为 MP4(H.264 + AAC 48kHz 双声道)。WebAV 生成的是 fMP4 分片, 部分播放器需「修复时长」;浏览器本地处理,文件不会上传。"
7558
- })
7559
- ]
7560
- });
6498
+ // src/components/layout/use-capability-info.ts
6499
+ function deriveStatus(report, loading) {
6500
+ if (loading) return "checking";
6501
+ if (report == null) return "unsupported";
6502
+ if (report.opfs && report.composerSupported && report.issues.length === 0) return "ready";
6503
+ if (report.opfs && report.composerSupported) return "degraded";
6504
+ return "unsupported";
6505
+ }
6506
+ /** 能力探测的 UI 适配层:把 core 的报告转成状态与文案 */
6507
+ function useCapabilityInfo() {
6508
+ const { report, loading, error } = useCapability();
6509
+ return {
6510
+ report,
6511
+ loading,
6512
+ error,
6513
+ status: deriveStatus(report, loading),
6514
+ summary: summarizeCapabilities(report)
6515
+ };
7561
6516
  }
7562
6517
  // src/CutWorkbench.tsx
7563
6518
  /**
@@ -7570,11 +6525,22 @@ function LabPage() {
7570
6525
  * - 不强制能力阻断页(宿主可自行决定)。
7571
6526
  */
7572
6527
  /**
6528
+ * Lab 的懒加载器。
6529
+ *
6530
+ * ⚠️ 用**字面量**模块说明符做动态 import,而不是先从 `./features/lab` 拿组件:
6531
+ * 前者会被打包成 external(`import('@cut-workbench/workbench/lab')`),由宿主
6532
+ * 打包器解析;后者会把 lab 整棵子树(含 `@webav/av-recorder`)拉回本包的模块图。
6533
+ *
6534
+ * 解析到 lab 还是 stub,由宿主的 `resolve.alias` 决定 —— 这是移除 lab 的
6535
+ * 唯一可靠方式(运行时开关删不掉已产出的 chunk,见 src/lab.ts 顶部说明)。
6536
+ */
6537
+ var LabPage = lazy(() => import("@cut-workbench/workbench/lab").then((m) => ({ default: m.LabPage })));
6538
+ /**
7573
6539
  * 工作台主体。
7574
6540
  *
7575
6541
  * 必须在 `<WorkbenchProvider>` + `<CutEditorProvider>` 之内使用。
7576
6542
  */
7577
- function CutWorkbench({ slots, className, onSaveProject, onOpenProject, enforceCapability = true }) {
6543
+ function CutWorkbench({ slots, toolbar: toolbarOptions, className, onSaveProject, onOpenProject, enforceCapability = true }) {
7578
6544
  const editor = useCutEditor();
7579
6545
  const capability = useCapabilityInfo();
7580
6546
  const leftWidth = useUi((s) => s.leftWidth);
@@ -7638,10 +6604,18 @@ function CutWorkbench({ slots, className, onSaveProject, onOpenProject, enforceC
7638
6604
  onExport: handleExport,
7639
6605
  onSaveProject: onSaveProject != null ? handleSave : void 0,
7640
6606
  onOpenProject: onOpenProject != null ? handleOpen : void 0,
6607
+ brand: toolbarOptions?.brand,
6608
+ title: toolbarOptions?.title,
6609
+ status: toolbarOptions?.status,
6610
+ actions: toolbarOptions?.actions,
6611
+ showCapabilityBadge: toolbarOptions?.showCapabilityBadge,
6612
+ editableTitle: toolbarOptions?.editableTitle,
6613
+ labels: toolbarOptions?.labels,
7641
6614
  showLab: config.features.lab
7642
6615
  });
7643
6616
  }, [
7644
6617
  slots?.topBar,
6618
+ toolbarOptions,
7645
6619
  capability,
7646
6620
  handleExport,
7647
6621
  handleSave,
@@ -7668,7 +6642,17 @@ function CutWorkbench({ slots, className, onSaveProject, onOpenProject, enforceC
7668
6642
  toolbar,
7669
6643
  /* @__PURE__ */ jsx("div", {
7670
6644
  className: "min-h-0 flex-1 overflow-auto",
7671
- children: /* @__PURE__ */ jsx(LabPage, {})
6645
+ children: /* @__PURE__ */ jsx(Suspense, {
6646
+ fallback: /* @__PURE__ */ jsx("div", {
6647
+ className: "flex h-full items-center justify-center",
6648
+ children: /* @__PURE__ */ jsx(LoadingOverlay, {
6649
+ visible: true,
6650
+ title: "正在加载能力实验室…",
6651
+ fullscreen: false
6652
+ })
6653
+ }),
6654
+ children: /* @__PURE__ */ jsx(LabPage, {})
6655
+ })
7672
6656
  }),
7673
6657
  /* @__PURE__ */ jsx(ExportDialog, {}),
7674
6658
  /* @__PURE__ */ jsx(CanvasSettingsDialog, {
@@ -8616,6 +7600,10 @@ function CutWorkbenchProvider({ children, bridge, theme = "dark", tokens, config
8616
7600
  useCutEditor();
8617
7601
  const [ui] = useState(() => createUiStore());
8618
7602
  const resolvedBridge = useMemo(() => bridge ?? createDefaultBridge(), [bridge]);
7603
+ useEffect(() => {
7604
+ setDefaultBridge(resolvedBridge);
7605
+ return () => setDefaultBridge(null);
7606
+ }, [resolvedBridge]);
8619
7607
  const mergedConfig = useMemo(() => ({
8620
7608
  ...DEFAULT_CONFIG,
8621
7609
  ...config,
@@ -8746,6 +7734,7 @@ function initCutWorkbench(container, options = {}) {
8746
7734
  }, createElement(CutWorkbench, {
8747
7735
  className: options.className ?? "h-full w-full",
8748
7736
  enforceCapability: options.enforceCapability ?? true,
7737
+ toolbar: options.toolbar,
8749
7738
  onSaveProject: () => void instance.saveProject(),
8750
7739
  onOpenProject: async () => {
8751
7740
  const saved = await storage.load(namespace);