@cut-workbench/workbench 0.1.0 → 0.2.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 { a as formatResolution, c as CardContent, d as CardTitle, f as Badge, i as formatPercent, n as formatDuration, o as formatSeconds, p as cn, r as formatElapsed, s as Card, t as formatBytes, u as CardHeader } from "./format-CV2aoZZC.js";
1
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
12
  import { Toaster, toast } 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, {
@@ -365,10 +351,17 @@ var DEFAULT_CONFIG = {
365
351
  * - 品牌区可替换(`brand`),不强制显示项目名;
366
352
  * - 保存/打开是可选回调,宿主不提供就不渲染按钮;
367
353
  * - 「视图切换(编辑器/实验室)」只在开启 lab 特性时显示。
354
+ *
355
+ * 可自定义的层次(由浅到深):
356
+ * 1. **文案**:`labels` 覆盖工程名占位、能力徽标等;
357
+ * 2. **局部**:`brand` / `title` / `status` / `actions` / `showCapabilityBadge`
358
+ * 逐块替换;
359
+ * 3. **整条**:`slots.topBar`(传 `null` 表示完全不要顶栏)。
368
360
  */
369
361
  /** 顶栏:品牌 / 工程名 / 撤销重做 / 保存 / 导出 / 能力徽标 / 视图切换 */
370
- function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject, brand, showLab = false }) {
362
+ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject, brand, title, status, actions, showCapabilityBadge = true, showLab = false, editableTitle = false, labels }) {
371
363
  const projectName = useEditor((s) => s.project.name);
364
+ const renameProject = useEditor((s) => s.renameProject);
372
365
  const dirty = useEditor((s) => s.dirty);
373
366
  const undo = useEditor((s) => s.undo);
374
367
  const redo = useEditor((s) => s.redo);
@@ -380,44 +373,49 @@ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject,
380
373
  const clipCount = useEditor((s) => s.project.clips.length);
381
374
  const projectWidth = useEditor((s) => s.project.width);
382
375
  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
- })]
376
+ const text = {
377
+ untitledProject: "未命名工程",
378
+ emptyProject: "空工程",
379
+ export: "导出",
380
+ canvasSettings: "画布设置",
381
+ showLab: "能力实验室",
382
+ backToEditor: "返回编辑器",
383
+ save: "保存工程",
384
+ open: "加载工程",
385
+ undo: "撤销",
386
+ redo: "重做",
387
+ ...labels
388
+ };
389
+ const branding = brand == null ? null : brand;
390
+ const projectTitle = title === null ? null : title ?? /* @__PURE__ */ jsx(ProjectTitle, {
391
+ name: projectName,
392
+ dirty,
393
+ placeholder: text.untitledProject,
394
+ editable: editableTitle,
395
+ onRename: renameProject
396
+ });
397
+ const statusNode = status === null ? null : status ?? /* @__PURE__ */ jsxs("span", {
398
+ className: "hidden text-xs text-muted-foreground sm:inline",
399
+ children: [
400
+ clipCount > 0 ? `${clipCount} 个片段` : text.emptyProject,
401
+ " ·",
402
+ " ",
403
+ ratioLabel(projectWidth, projectHeight)
404
+ ]
392
405
  });
393
406
  return /* @__PURE__ */ jsxs("header", {
394
- className: "flex h-12 shrink-0 items-center gap-2 border-b bg-card px-3",
407
+ className: "flex h-12 shrink-0 items-center gap-2 overflow-hidden border-b bg-card px-3",
408
+ "data-workbench-toolbar": true,
395
409
  children: [
396
410
  branding,
397
- branding != null && /* @__PURE__ */ jsx(Separator$1, {
411
+ branding != null && projectTitle != null && /* @__PURE__ */ jsx(Separator$1, {
398
412
  orientation: "vertical",
399
413
  className: "h-5"
400
414
  }),
415
+ projectTitle,
416
+ statusNode,
401
417
  /* @__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",
418
+ className: "ml-auto flex shrink-0 items-center gap-1",
421
419
  children: [
422
420
  /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
423
421
  asChild: true,
@@ -426,10 +424,10 @@ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject,
426
424
  size: "icon-sm",
427
425
  onClick: undo,
428
426
  disabled: !canUndo,
429
- "aria-label": "撤销",
427
+ "aria-label": text.undo,
430
428
  children: /* @__PURE__ */ jsx(Undo2, { className: "size-4" })
431
429
  })
432
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "撤销 (Ctrl+Z)" })] }),
430
+ }), /* @__PURE__ */ jsxs(TooltipContent, { children: [text.undo, " (Ctrl+Z)"] })] }),
433
431
  /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
434
432
  asChild: true,
435
433
  children: /* @__PURE__ */ jsx(Button, {
@@ -437,10 +435,10 @@ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject,
437
435
  size: "icon-sm",
438
436
  onClick: redo,
439
437
  disabled: !canRedo,
440
- "aria-label": "重做",
438
+ "aria-label": text.redo,
441
439
  children: /* @__PURE__ */ jsx(Redo2, { className: "size-4" })
442
440
  })
443
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "重做 (Ctrl+Shift+Z)" })] }),
441
+ }), /* @__PURE__ */ jsxs(TooltipContent, { children: [text.redo, " (Ctrl+Shift+Z)"] })] }),
444
442
  /* @__PURE__ */ jsx(Separator$1, {
445
443
  orientation: "vertical",
446
444
  className: "mx-1 h-5"
@@ -451,29 +449,29 @@ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject,
451
449
  variant: "ghost",
452
450
  size: "icon-sm",
453
451
  onClick: onOpenProject,
454
- "aria-label": "加载工程",
452
+ "aria-label": text.open,
455
453
  children: /* @__PURE__ */ jsx(FolderOpen, { className: "size-4" })
456
454
  })
457
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "加载已保存工程" })] }),
455
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: text.open })] }),
458
456
  onSaveProject != null && /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
459
457
  asChild: true,
460
458
  children: /* @__PURE__ */ jsx(Button, {
461
459
  variant: "ghost",
462
460
  size: "icon-sm",
463
461
  onClick: onSaveProject,
464
- "aria-label": "保存工程",
462
+ "aria-label": text.save,
465
463
  children: /* @__PURE__ */ jsx(Save, { className: "size-4" })
466
464
  })
467
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "保存工程到本地" })] }),
465
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: text.save })] }),
468
466
  /* @__PURE__ */ jsxs(Button, {
469
467
  variant: "ghost",
470
468
  size: "sm",
471
469
  className: "gap-1.5",
472
- "aria-label": "画布设置",
470
+ "aria-label": text.canvasSettings,
473
471
  onClick: () => openDialog("projectSettings"),
474
472
  children: [/* @__PURE__ */ jsx(Monitor, { className: "size-4" }), /* @__PURE__ */ jsx("span", {
475
473
  className: "hidden md:inline",
476
- children: "画布设置"
474
+ children: text.canvasSettings
477
475
  })]
478
476
  }),
479
477
  showLab && /* @__PURE__ */ jsxs(Button, {
@@ -483,21 +481,67 @@ function WorkbenchToolbar({ capability, onExport, onSaveProject, onOpenProject,
483
481
  onClick: () => setRoute(route === "lab" ? "editor" : "lab"),
484
482
  children: [route === "lab" ? /* @__PURE__ */ jsx(Video, { className: "size-4" }) : /* @__PURE__ */ jsx(FlaskConical, { className: "size-4" }), /* @__PURE__ */ jsx("span", {
485
483
  className: "hidden md:inline",
486
- children: route === "lab" ? "返回编辑器" : "能力实验室"
484
+ children: route === "lab" ? text.backToEditor : text.showLab
487
485
  })]
488
486
  }),
489
- /* @__PURE__ */ jsx(CapabilityBadge, { info: capability }),
487
+ showCapabilityBadge && /* @__PURE__ */ jsx(CapabilityBadge, { info: capability }),
488
+ actions,
490
489
  /* @__PURE__ */ jsxs(Button, {
491
490
  size: "sm",
492
491
  className: "ml-1 gap-1.5",
493
492
  onClick: onExport,
494
- children: [/* @__PURE__ */ jsx(Download, { className: "size-4" }), "导出"]
493
+ children: [/* @__PURE__ */ jsx(Download, { className: "size-4" }), text.export]
495
494
  })
496
495
  ]
497
496
  })
498
497
  ]
499
498
  });
500
499
  }
500
+ /**
501
+ * 工程名展示(可选双击重命名)。
502
+ *
503
+ * 重命名走编辑器 store 的 `renameProject`,因此会进撤销栈、并标记 dirty ——
504
+ * 直接改 DOM 或本地 state 会让「保存了什么」和「看到什么」对不上。
505
+ */
506
+ function ProjectTitle({ name, dirty, placeholder, editable, onRename }) {
507
+ const [editing, setEditing] = useState(false);
508
+ const [draft, setDraft] = useState(name);
509
+ const commit = () => {
510
+ setEditing(false);
511
+ const next = draft.trim();
512
+ if (next.length > 0 && next !== name) onRename(next);
513
+ };
514
+ if (editing) return /* @__PURE__ */ jsx("input", {
515
+ autoFocus: true,
516
+ value: draft,
517
+ onChange: (e) => setDraft(e.target.value),
518
+ onBlur: commit,
519
+ onKeyDown: (e) => {
520
+ if (e.key === "Enter") commit();
521
+ if (e.key === "Escape") {
522
+ setDraft(name);
523
+ setEditing(false);
524
+ }
525
+ },
526
+ 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",
527
+ "aria-label": "工程名称"
528
+ });
529
+ return /* @__PURE__ */ jsxs("div", {
530
+ className: "flex min-w-0 items-center gap-1.5",
531
+ children: [/* @__PURE__ */ jsx("span", {
532
+ className: cn("truncate text-sm font-medium", editable && "cursor-text"),
533
+ title: editable ? "双击重命名" : name.length > 0 ? name : placeholder,
534
+ onDoubleClick: editable ? () => {
535
+ setDraft(name.length > 0 ? name : "");
536
+ setEditing(true);
537
+ } : void 0,
538
+ children: name.length > 0 ? name : placeholder
539
+ }), dirty && /* @__PURE__ */ jsx("span", {
540
+ className: "size-1.5 shrink-0 rounded-full bg-primary",
541
+ title: "未保存"
542
+ })]
543
+ });
544
+ }
501
545
  // src/components/layout/use-panel-resize.ts
502
546
  /**
503
547
  * 面板拖拽调整。内部用 rAF 节流,松手才把最终值写回 store(避免每帧重渲染整棵树)。
@@ -580,42 +624,6 @@ function PanelResizer({ orientation, value, min, max, invert, onChange, classNam
580
624
  children: /* @__PURE__ */ jsx("div", { className: cn("absolute", isVertical ? "-left-1 -right-1 inset-y-0" : "-top-1 -bottom-1 inset-x-0") })
581
625
  });
582
626
  }
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
627
  // src/components/common/LoadingOverlay.tsx
620
628
  /** 全屏/局部遮罩加载态,用于导出、批量导入等长任务 */
621
629
  function LoadingOverlay({ visible, title = "处理中…", description, progress, className, fullscreen, children }) {
@@ -683,7 +691,12 @@ var CHECKS = [
683
691
  hint: "绿幕抠像(可选)"
684
692
  }
685
693
  ];
686
- /** 不满足运行条件时的阻断页:逐项说明缺失能力,不做静默降级 */
694
+ /**
695
+ * 不满足运行条件时的阻断页:逐项说明缺失能力,不做静默降级。
696
+ *
697
+ * 「依赖没装」与「浏览器不支持」要分开呈现 —— 前者是宿主的构建问题(可执行
698
+ * 安装命令解决),后者只能换浏览器。混在一起会让人对着一条装包报错去升浏览器。
699
+ */
687
700
  function UnsupportedBrowser({ report, loading, error }) {
688
701
  if (loading) return /* @__PURE__ */ jsx("div", {
689
702
  className: "relative flex min-h-screen items-center justify-center bg-background",
@@ -693,6 +706,8 @@ function UnsupportedBrowser({ report, loading, error }) {
693
706
  fullscreen: false
694
707
  })
695
708
  });
709
+ const missing = report?.missingDependencies ?? [];
710
+ const dependenciesMissing = missing.length > 0;
696
711
  return /* @__PURE__ */ jsx("div", {
697
712
  className: "flex min-h-screen items-center justify-center bg-background p-6",
698
713
  children: /* @__PURE__ */ jsxs(Card, {
@@ -701,15 +716,36 @@ function UnsupportedBrowser({ report, loading, error }) {
701
716
  className: "flex items-center gap-2 text-destructive",
702
717
  children: [/* @__PURE__ */ jsx(AlertOctagon, { className: "size-5" }), /* @__PURE__ */ jsx(CardTitle, {
703
718
  className: "text-base",
704
- children: "当前浏览器无法运行本应用"
719
+ children: dependenciesMissing ? "缺少引擎依赖" : "当前浏览器无法运行本应用"
705
720
  })]
706
721
  }), /* @__PURE__ */ jsx("p", {
707
722
  className: "text-sm text-muted-foreground",
708
- children: "WebAV 依赖 WebCodecs 与 OPFS,仅支持 Chromium 内核浏览器。请使用最新版 Chrome 或 Edge(非无痕模式)重新打开。"
723
+ children: dependenciesMissing ? "工作台的预览与导出依赖 WebAV(@webav/av-cliper、@webav/av-canvas)。当前项目里没有解析到它们。" : "WebAV 依赖 WebCodecs 与 OPFS,仅支持 Chromium 内核浏览器。请使用最新版 Chrome 或 Edge(非无痕模式)重新打开。"
709
724
  })] }), /* @__PURE__ */ jsxs(CardContent, {
710
725
  className: "space-y-4",
711
726
  children: [
712
- report != null && /* @__PURE__ */ jsx("div", {
727
+ dependenciesMissing && /* @__PURE__ */ jsxs("div", {
728
+ className: "space-y-2 rounded-md border border-destructive/40 bg-destructive/5 p-3",
729
+ children: [
730
+ /* @__PURE__ */ jsxs("p", {
731
+ className: "text-xs font-medium text-destructive",
732
+ children: ["未解析到:", missing.join("、")]
733
+ }),
734
+ /* @__PURE__ */ jsx("p", {
735
+ className: "text-xs text-muted-foreground",
736
+ children: "在宿主项目里执行:"
737
+ }),
738
+ /* @__PURE__ */ jsx("pre", {
739
+ className: "overflow-x-auto rounded bg-muted px-3 py-2 text-xs",
740
+ children: /* @__PURE__ */ jsx("code", { children: ENGINE_DEPENDENCIES_INSTALL_COMMAND })
741
+ }),
742
+ /* @__PURE__ */ jsx("p", {
743
+ className: "text-xs text-muted-foreground",
744
+ children: "安装后需要重新跑一次构建 —— 缺失依赖只在构建期解析,浏览器里刷新页面没用。"
745
+ })
746
+ ]
747
+ }),
748
+ report != null && !dependenciesMissing && /* @__PURE__ */ jsx("div", {
713
749
  className: "grid gap-2 sm:grid-cols-2",
714
750
  children: CHECKS.map((check) => {
715
751
  const value = report[check.key];
@@ -983,41 +1019,6 @@ function EmptyState({ icon: Icon, title, description, action, className, compact
983
1019
  ]
984
1020
  });
985
1021
  }
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}`;
1007
- }
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`;
1012
- }
1013
- /** 微秒 → 秒(保留一位小数,用于表单展示) */
1014
- function formatSeconds(us) {
1015
- return usToSec(us).toFixed(2);
1016
- }
1017
- /** 百分比 0~1 → `42%` */
1018
- function formatPercent(ratio) {
1019
- return `${Math.round(ratio * 100)}%`;
1020
- }
1021
1022
  // src/features/media-library/components/DropZone.tsx
1022
1023
  var ACCEPT = {
1023
1024
  "video/mp4": [".mp4", ".m4v"],
@@ -2378,15 +2379,51 @@ var RATES = [
2378
2379
  1.5,
2379
2380
  2
2380
2381
  ];
2382
+ /**
2383
+ * 紧凑档位(按容器宽度,不是视口 —— 预览列宽由拖拽决定)。
2384
+ *
2385
+ * 阈值来自实测的控件总宽:完整形态约 680px,`< 700` 先去音量滑杆与速率、
2386
+ * `< 560` 再去时间码的「总时长」与进度条、`< 420` 再去跳首尾。
2387
+ * 只靠 `shrink-0` + `overflow:hidden` 会在 650px 左右把时间码裁掉,
2388
+ * 且**没有任何报错**,只有截图能看出来 —— 所以这里显式分级。
2389
+ */
2390
+ var FULL_PX = 700;
2391
+ var COMPACT_PX = 620;
2392
+ var TIGHT_PX = 520;
2393
+ var ULTRA_COMPACT_PX = 420;
2394
+ /** 低于此宽度连时间码都放不下:只留播放/逐帧,横向可滚 */
2395
+ var MICRO_PX = 200;
2381
2396
  /** 播放控制条:逐帧 / 跳首尾 / 播放 / 时间码 / 速率 / 音量 */
2382
2397
  function PlaybackControls({ playing, currentUs, durationUs, masterVolume, playbackRate, disabled, onToggle, onJumpStart, onJumpEnd, onStepFrame, onSeek, onVolumeChange, onRateChange, className }) {
2398
+ const rootRef = useRef(null);
2399
+ const [width, setWidth] = useState(0);
2400
+ /**
2401
+ * 用容器宽度而不是 CSS 媒体查询。
2402
+ *
2403
+ * 预览面板宽度由左侧拖拽决定,和视口宽度无关 —— 用户把两侧拉宽时,
2404
+ * 视口一点没变,`max-[560px]:hidden` 这类媒体查询不会触发,时间码照样被压扁。
2405
+ */
2406
+ useEffect(() => {
2407
+ const el = rootRef.current;
2408
+ if (el == null) return;
2409
+ const ro = new ResizeObserver(() => setWidth(el.clientWidth));
2410
+ ro.observe(el);
2411
+ setWidth(el.clientWidth);
2412
+ return () => ro.disconnect();
2413
+ }, []);
2414
+ const compact = width > 0 && width < COMPACT_PX;
2415
+ const tight = width > 0 && width < TIGHT_PX;
2416
+ const ultraCompact = width > 0 && width < ULTRA_COMPACT_PX;
2417
+ const micro = width > 0 && width < MICRO_PX;
2418
+ const full = width === 0 || width >= FULL_PX;
2383
2419
  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),
2420
+ ref: rootRef,
2421
+ 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
2422
  children: [
2386
2423
  /* @__PURE__ */ jsxs("div", {
2387
- className: "flex items-center gap-0.5",
2424
+ className: "flex shrink-0 items-center gap-0.5",
2388
2425
  children: [
2389
- /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2426
+ !ultraCompact && /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2390
2427
  asChild: true,
2391
2428
  children: /* @__PURE__ */ jsx(Button, {
2392
2429
  variant: "ghost",
@@ -2428,7 +2465,7 @@ function PlaybackControls({ playing, currentUs, durationUs, masterVolume, playba
2428
2465
  children: /* @__PURE__ */ jsx(StepForward, { className: "size-3.5" })
2429
2466
  })
2430
2467
  }), /* @__PURE__ */ jsx(TooltipContent, { children: "下一帧 (→)" })] }),
2431
- /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2468
+ !ultraCompact && /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2432
2469
  asChild: true,
2433
2470
  children: /* @__PURE__ */ jsx(Button, {
2434
2471
  variant: "ghost",
@@ -2442,23 +2479,19 @@ function PlaybackControls({ playing, currentUs, durationUs, masterVolume, playba
2442
2479
  ]
2443
2480
  }),
2444
2481
  /* @__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
- ]
2482
+ className: cn("mx-2 flex shrink-0 items-baseline gap-1 whitespace-nowrap", ultraCompact && "mx-1", micro && "hidden"),
2483
+ children: [/* @__PURE__ */ jsx(Timecode, {
2484
+ us: currentUs,
2485
+ className: "text-[13px] font-medium"
2486
+ }), !tight && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
2487
+ className: "text-xs text-muted-foreground",
2488
+ children: "/"
2489
+ }), /* @__PURE__ */ jsx(Timecode, {
2490
+ us: durationUs,
2491
+ muted: true
2492
+ })] })]
2460
2493
  }),
2461
- /* @__PURE__ */ jsx("input", {
2494
+ !ultraCompact && /* @__PURE__ */ jsx("input", {
2462
2495
  type: "range",
2463
2496
  className: "mx-1 h-1 min-w-16 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary",
2464
2497
  min: 0,
@@ -2470,9 +2503,9 @@ function PlaybackControls({ playing, currentUs, durationUs, masterVolume, playba
2470
2503
  "aria-label": "播放进度"
2471
2504
  }),
2472
2505
  /* @__PURE__ */ jsxs("div", {
2473
- className: "flex items-center gap-1",
2506
+ className: cn("flex shrink-0 items-center gap-1", compact && "ml-auto", micro && "hidden"),
2474
2507
  children: [
2475
- /* @__PURE__ */ jsx("div", {
2508
+ !compact && /* @__PURE__ */ jsx("div", {
2476
2509
  className: "flex items-center rounded-md border p-0.5",
2477
2510
  children: RATES.map((rate) => /* @__PURE__ */ jsxs("button", {
2478
2511
  type: "button",
@@ -2492,7 +2525,7 @@ function PlaybackControls({ playing, currentUs, durationUs, masterVolume, playba
2492
2525
  children: masterVolume > 0 ? /* @__PURE__ */ jsx(Volume2, { className: "size-3.5" }) : /* @__PURE__ */ jsx(VolumeX, { className: "size-3.5" })
2493
2526
  })
2494
2527
  }), /* @__PURE__ */ jsx(TooltipContent, { children: masterVolume > 0 ? "静音预览" : "取消静音" })] }),
2495
- /* @__PURE__ */ jsx(Slider$1, {
2528
+ full && /* @__PURE__ */ jsx(Slider$1, {
2496
2529
  className: "w-20",
2497
2530
  min: 0,
2498
2531
  max: 1,
@@ -2776,143 +2809,148 @@ var DEFAULT_BITRATE = 8e6;
2776
2809
  /** 时间轴顶部工具条:分割 / 复制 / 删除 / 吸附开关 / 缩放 / 加轨道 */
2777
2810
  function TimelineToolbar({ playheadUs, durationUs, pxPerSecond, selectionCount, magnetEnabled, onToggleMagnet, onSplit, onDuplicate, onRemove, onZoomIn, onZoomOut, onZoomToFit, onAddTrack, canZoomIn, canZoomOut }) {
2778
2811
  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, {
2812
+ 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",
2813
+ role: "toolbar",
2814
+ "aria-label": "时间轴工具",
2815
+ "data-timeline-toolbar": true,
2816
+ children: [/* @__PURE__ */ jsxs("div", {
2817
+ className: "flex shrink-0 items-center gap-1",
2818
+ children: [
2819
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2820
+ asChild: true,
2821
+ children: /* @__PURE__ */ jsx(Button, {
2822
+ variant: "ghost",
2823
+ size: "icon-sm",
2824
+ onClick: onSplit,
2825
+ "aria-label": "分割",
2826
+ children: /* @__PURE__ */ jsx(Scissors, { className: "size-3.5" })
2827
+ })
2828
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "在播放头分割 (S)" })] }),
2829
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2830
+ asChild: true,
2831
+ children: /* @__PURE__ */ jsx(Button, {
2832
+ variant: "ghost",
2833
+ size: "icon-sm",
2834
+ onClick: onDuplicate,
2835
+ disabled: selectionCount === 0,
2836
+ "aria-label": "复制片段",
2837
+ children: /* @__PURE__ */ jsx(Copy, { className: "size-3.5" })
2838
+ })
2839
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "复制片段 (Ctrl+D)" })] }),
2840
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2841
+ asChild: true,
2842
+ children: /* @__PURE__ */ jsx(Button, {
2843
+ variant: "ghost",
2844
+ size: "icon-sm",
2845
+ onClick: onRemove,
2846
+ disabled: selectionCount === 0,
2847
+ "aria-label": "删除片段",
2848
+ children: /* @__PURE__ */ jsx(Trash2, { className: "size-3.5" })
2849
+ })
2850
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "删除片段 (Delete)" })] }),
2851
+ /* @__PURE__ */ jsx(Separator$1, {
2852
+ orientation: "vertical",
2853
+ className: "mx-1 h-4"
2854
+ }),
2855
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2856
+ asChild: true,
2857
+ children: /* @__PURE__ */ jsx(Button, {
2858
+ variant: magnetEnabled ? "secondary" : "ghost",
2859
+ size: "icon-sm",
2860
+ onClick: onToggleMagnet,
2861
+ "aria-label": "吸附",
2862
+ "aria-pressed": magnetEnabled,
2863
+ children: /* @__PURE__ */ jsx(Magnet, { className: "size-3.5" })
2864
+ })
2865
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "吸附对齐(阈值 15px)" })] }),
2866
+ /* @__PURE__ */ jsx(Separator$1, {
2867
+ orientation: "vertical",
2868
+ className: "mx-1 h-4"
2869
+ }),
2870
+ /* @__PURE__ */ jsxs("div", {
2871
+ className: "flex items-center gap-0.5",
2872
+ children: [
2873
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2874
+ asChild: true,
2875
+ children: /* @__PURE__ */ jsx(Button, {
2876
+ variant: "ghost",
2877
+ size: "icon-sm",
2878
+ onClick: onZoomOut,
2879
+ disabled: !canZoomOut,
2880
+ "aria-label": "缩小",
2881
+ children: /* @__PURE__ */ jsx(ZoomOut, { className: "size-3.5" })
2882
+ })
2883
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "缩小 (-)" })] }),
2884
+ /* @__PURE__ */ jsx("span", {
2885
+ className: "w-11 text-center font-mono text-[10px] text-muted-foreground tabular-nums",
2886
+ children: Math.round(pxPerSecond)
2887
+ }),
2888
+ /* @__PURE__ */ jsxs(Tooltip$1, { children: [/* @__PURE__ */ jsx(TooltipTrigger, {
2889
+ asChild: true,
2890
+ children: /* @__PURE__ */ jsx(Button, {
2891
+ variant: "ghost",
2892
+ size: "icon-sm",
2893
+ onClick: onZoomIn,
2894
+ disabled: !canZoomIn,
2895
+ "aria-label": "放大",
2896
+ children: /* @__PURE__ */ jsx(ZoomIn, { className: "size-3.5" })
2897
+ })
2898
+ }), /* @__PURE__ */ jsx(TooltipContent, { children: "放大 (+)" })] }),
2899
+ /* @__PURE__ */ jsx(Button, {
2853
2900
  variant: "ghost",
2854
- size: "icon-sm",
2855
- onClick: onZoomIn,
2856
- disabled: !canZoomIn,
2857
- "aria-label": "放大",
2858
- children: /* @__PURE__ */ jsx(ZoomIn, { className: "size-3.5" })
2901
+ size: "xs",
2902
+ onClick: onZoomToFit,
2903
+ className: "text-[10px]",
2904
+ children: "适应"
2859
2905
  })
2860
- }), /* @__PURE__ */ jsx(TooltipContent, { children: "放大 (+)" })] }),
2861
- /* @__PURE__ */ jsx(Button, {
2906
+ ]
2907
+ }),
2908
+ /* @__PURE__ */ jsx(Separator$1, {
2909
+ orientation: "vertical",
2910
+ className: "mx-1 h-4"
2911
+ }),
2912
+ /* @__PURE__ */ jsxs("div", {
2913
+ className: "flex items-center gap-0.5",
2914
+ children: [/* @__PURE__ */ jsxs(Button, {
2862
2915
  variant: "ghost",
2863
2916
  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
- ]
2917
+ onClick: () => onAddTrack("visual"),
2918
+ children: [/* @__PURE__ */ jsx(Plus, { className: "size-3" }), "视频轨"]
2919
+ }), /* @__PURE__ */ jsxs(Button, {
2920
+ variant: "ghost",
2921
+ size: "xs",
2922
+ onClick: () => onAddTrack("audio"),
2923
+ children: [/* @__PURE__ */ jsx(Plus, { className: "size-3" }), "音频轨"]
2924
+ })]
2925
+ })
2926
+ ]
2927
+ }), /* @__PURE__ */ jsxs("div", {
2928
+ className: "ml-auto hidden shrink-0 items-center gap-2 pl-2 text-[11px] text-muted-foreground @[720px]:flex",
2929
+ children: [
2930
+ selectionCount > 0 && /* @__PURE__ */ jsxs("span", {
2931
+ className: "rounded bg-primary/15 px-1.5 py-0.5 text-primary",
2932
+ children: ["已选 ", selectionCount]
2933
+ }),
2934
+ /* @__PURE__ */ jsx(Info, { className: "size-3" }),
2935
+ /* @__PURE__ */ jsxs("span", {
2936
+ className: "tabular-nums whitespace-nowrap",
2937
+ children: [
2938
+ /* @__PURE__ */ jsx(Timecode, {
2939
+ us: playheadUs,
2940
+ className: "text-foreground"
2941
+ }),
2942
+ /* @__PURE__ */ jsx("span", {
2943
+ className: "mx-1",
2944
+ children: "/"
2945
+ }),
2946
+ /* @__PURE__ */ jsx(Timecode, {
2947
+ us: durationUs,
2948
+ muted: true
2949
+ })
2950
+ ]
2951
+ })
2952
+ ]
2953
+ })]
2916
2954
  });
2917
2955
  }
2918
2956
  // src/features/timeline/components/Ruler.tsx
@@ -3876,7 +3914,7 @@ function Timeline() {
3876
3914
  splitClip
3877
3915
  ]);
3878
3916
  return /* @__PURE__ */ jsxs("div", {
3879
- className: "flex min-h-0 flex-1 flex-col",
3917
+ className: "flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
3880
3918
  children: [
3881
3919
  /* @__PURE__ */ jsx(TimelineToolbar, {
3882
3920
  playheadUs,
@@ -3896,7 +3934,7 @@ function Timeline() {
3896
3934
  canZoomOut: zoom.canZoomOut
3897
3935
  }),
3898
3936
  /* @__PURE__ */ jsxs("div", {
3899
- className: "relative flex min-h-0 flex-1",
3937
+ className: "relative flex min-h-0 min-w-0 flex-1 overflow-hidden",
3900
3938
  children: [/* @__PURE__ */ jsxs("div", {
3901
3939
  className: "thin-scrollbar flex shrink-0 flex-col overflow-y-auto border-r",
3902
3940
  style: { width: 150 },
@@ -6392,1176 +6430,9 @@ function useCapabilityInfo() {
6392
6430
  summary: summarizeCapabilities(report)
6393
6431
  };
6394
6432
  }
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
6433
+ // src/CutWorkbench.tsx
6472
6434
  /**
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:屏幕 / 画布录制。
7178
- *
7179
- * `AVRecorder` 接收任意 MediaStream 并输出 MP4(H.264 + AAC)。
7180
- * 这里提供两种源:
7181
- * - 程序化画布(Canvas.captureStream,无需授权);
7182
- * - 摄像头(getUserMedia,需要用户授权)。
7183
- */
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;
7223
- }
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;
7236
- }
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);
7259
- }
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`);
7276
- }
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());
7368
- }
7369
- };
7370
- }
7371
- // src/features/lab/components/CustomClipLab.tsx
7372
- /**
7373
- * Lab 8:自定义 IClip。
7374
- *
7375
- * `FrameCounterClip` 是本项目实现的 IClip:不依赖任何媒体文件,
7376
- * 逐帧用 OffscreenCanvas 画出帧号 / 进度条 / 色相变化。
7377
- *
7378
- * 它证明 IClip 接口(tick / ready / meta / clone / split / destroy)可以被任意扩展,
7379
- * 且能直接被 Combinator 编码成 MP4。
7380
- */
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]);
7413
- 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);
7430
- return () => {
7431
- if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
7432
- };
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
- });
7518
- }
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
- });
7561
- }
7562
- // src/CutWorkbench.tsx
7563
- /**
7564
- * 工作台容器:把「三栏骨架 + 顶栏 + 弹窗」组装成一个可配置组件。
6435
+ * 工作台容器:把「三栏骨架 + 顶栏 + 弹窗」组装成一个可配置组件。
7565
6436
  *
7566
6437
  * 与旧 `App` 的区别:
7567
6438
  * - 不自己创建编辑器(由外层 Provider 注入),因此支持多实例;
@@ -7570,11 +6441,22 @@ function LabPage() {
7570
6441
  * - 不强制能力阻断页(宿主可自行决定)。
7571
6442
  */
7572
6443
  /**
6444
+ * Lab 的懒加载器。
6445
+ *
6446
+ * ⚠️ 用**字面量**模块说明符做动态 import,而不是先从 `./features/lab` 拿组件:
6447
+ * 前者会被打包成 external(`import('@cut-workbench/workbench/lab')`),由宿主
6448
+ * 打包器解析;后者会把 lab 整棵子树(含 `@webav/av-recorder`)拉回本包的模块图。
6449
+ *
6450
+ * 解析到 lab 还是 stub,由宿主的 `resolve.alias` 决定 —— 这是移除 lab 的
6451
+ * 唯一可靠方式(运行时开关删不掉已产出的 chunk,见 src/lab.ts 顶部说明)。
6452
+ */
6453
+ var LabPage = lazy(() => import("@cut-workbench/workbench/lab").then((m) => ({ default: m.LabPage })));
6454
+ /**
7573
6455
  * 工作台主体。
7574
6456
  *
7575
6457
  * 必须在 `<WorkbenchProvider>` + `<CutEditorProvider>` 之内使用。
7576
6458
  */
7577
- function CutWorkbench({ slots, className, onSaveProject, onOpenProject, enforceCapability = true }) {
6459
+ function CutWorkbench({ slots, toolbar: toolbarOptions, className, onSaveProject, onOpenProject, enforceCapability = true }) {
7578
6460
  const editor = useCutEditor();
7579
6461
  const capability = useCapabilityInfo();
7580
6462
  const leftWidth = useUi((s) => s.leftWidth);
@@ -7638,10 +6520,18 @@ function CutWorkbench({ slots, className, onSaveProject, onOpenProject, enforceC
7638
6520
  onExport: handleExport,
7639
6521
  onSaveProject: onSaveProject != null ? handleSave : void 0,
7640
6522
  onOpenProject: onOpenProject != null ? handleOpen : void 0,
6523
+ brand: toolbarOptions?.brand,
6524
+ title: toolbarOptions?.title,
6525
+ status: toolbarOptions?.status,
6526
+ actions: toolbarOptions?.actions,
6527
+ showCapabilityBadge: toolbarOptions?.showCapabilityBadge,
6528
+ editableTitle: toolbarOptions?.editableTitle,
6529
+ labels: toolbarOptions?.labels,
7641
6530
  showLab: config.features.lab
7642
6531
  });
7643
6532
  }, [
7644
6533
  slots?.topBar,
6534
+ toolbarOptions,
7645
6535
  capability,
7646
6536
  handleExport,
7647
6537
  handleSave,
@@ -7668,7 +6558,17 @@ function CutWorkbench({ slots, className, onSaveProject, onOpenProject, enforceC
7668
6558
  toolbar,
7669
6559
  /* @__PURE__ */ jsx("div", {
7670
6560
  className: "min-h-0 flex-1 overflow-auto",
7671
- children: /* @__PURE__ */ jsx(LabPage, {})
6561
+ children: /* @__PURE__ */ jsx(Suspense, {
6562
+ fallback: /* @__PURE__ */ jsx("div", {
6563
+ className: "flex h-full items-center justify-center",
6564
+ children: /* @__PURE__ */ jsx(LoadingOverlay, {
6565
+ visible: true,
6566
+ title: "正在加载能力实验室…",
6567
+ fullscreen: false
6568
+ })
6569
+ }),
6570
+ children: /* @__PURE__ */ jsx(LabPage, {})
6571
+ })
7672
6572
  }),
7673
6573
  /* @__PURE__ */ jsx(ExportDialog, {}),
7674
6574
  /* @__PURE__ */ jsx(CanvasSettingsDialog, {
@@ -8746,6 +7646,7 @@ function initCutWorkbench(container, options = {}) {
8746
7646
  }, createElement(CutWorkbench, {
8747
7647
  className: options.className ?? "h-full w-full",
8748
7648
  enforceCapability: options.enforceCapability ?? true,
7649
+ toolbar: options.toolbar,
8749
7650
  onSaveProject: () => void instance.saveProject(),
8750
7651
  onOpenProject: async () => {
8751
7652
  const saved = await storage.load(namespace);