@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/README.md +153 -9
- package/dist/CutWorkbench.d.ts +26 -1
- package/dist/auto.cjs +4 -0
- package/dist/auto.d.ts +19 -0
- package/dist/auto.js +8 -0
- package/dist/components/common/UnsupportedBrowser.d.ts +6 -1
- package/dist/components/layout/AppShell.d.ts +17 -3
- package/dist/components/layout/WorkbenchToolbar.d.ts +39 -2
- package/dist/cut-workbench.css +47 -6
- package/dist/features/lab/index.d.ts +5 -1
- package/dist/features/media-library/components/DropZone.d.ts +6 -0
- package/dist/features/media-library/lib/pick-files.d.ts +24 -0
- package/dist/hooks/use-toast-safe.d.ts +9 -1
- package/dist/index.cjs +937 -1969
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +825 -1836
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +3 -0
- package/dist/lab-disabled.cjs +6 -0
- package/dist/lab-disabled.cjs.map +1 -0
- package/dist/lab-disabled.d.ts +13 -0
- package/dist/lab-disabled.js +5 -0
- package/dist/lab-disabled.js.map +1 -0
- package/dist/lab.cjs +1194 -0
- package/dist/lab.cjs.map +1 -0
- package/dist/lab.d.ts +32 -0
- package/dist/lab.js +1193 -0
- package/dist/lab.js.map +1 -0
- package/dist/lib/notify.d.ts +40 -0
- package/dist/notify-CEwFQgjC.js +233 -0
- package/dist/notify-CEwFQgjC.js.map +1 -0
- package/dist/notify-CMNwDFGw.cjs +380 -0
- package/dist/notify-CMNwDFGw.cjs.map +1 -0
- package/dist/umd/cut-workbench.umd.js +1 -1
- package/package.json +84 -67
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,
|
|
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,
|
|
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
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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: [
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
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__ */
|
|
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__ */
|
|
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
|
-
|
|
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/
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
"
|
|
992
|
-
"
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
/**
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
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
|
-
/**
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
1054
|
-
noKeyboard:
|
|
1179
|
+
noClick: true,
|
|
1180
|
+
noKeyboard: true,
|
|
1055
1181
|
multiple: true,
|
|
1056
1182
|
disabled,
|
|
1057
|
-
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
|
-
|
|
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/
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
|
1319
|
-
const
|
|
1320
|
-
const
|
|
1321
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
2798
|
+
notify(bridge, {
|
|
2799
|
+
level: "error",
|
|
2800
|
+
title: "截图失败:画布尚未就绪"
|
|
2801
|
+
});
|
|
2696
2802
|
return;
|
|
2697
2803
|
}
|
|
2698
2804
|
downloadDataUrl(dataUrl, `frame-${Date.now()}.png`);
|
|
2699
|
-
|
|
2700
|
-
|
|
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
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
children: /* @__PURE__ */ jsx(
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
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: "
|
|
2855
|
-
onClick:
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
children: /* @__PURE__ */ jsx(ZoomIn, { className: "size-3.5" })
|
|
2977
|
+
size: "xs",
|
|
2978
|
+
onClick: onZoomToFit,
|
|
2979
|
+
className: "text-[10px]",
|
|
2980
|
+
children: "适应"
|
|
2859
2981
|
})
|
|
2860
|
-
|
|
2861
|
-
|
|
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:
|
|
2865
|
-
className: "
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
7180
|
-
*
|
|
7181
|
-
* - 程序化画布(Canvas.captureStream,无需授权);
|
|
7182
|
-
* - 摄像头(getUserMedia,需要用户授权)。
|
|
6361
|
+
* 设计:编辑类操作直接调 store;播放类操作通过 `cut-test:*` 自定义事件通知预览面板,
|
|
6362
|
+
* 避免 store → features 的反向依赖。
|
|
7183
6363
|
*/
|
|
7184
|
-
function
|
|
7185
|
-
const
|
|
7186
|
-
const
|
|
7187
|
-
const
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
7194
|
-
|
|
7195
|
-
|
|
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
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
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
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7241
|
-
|
|
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
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
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
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
|
|
7299
|
-
|
|
7300
|
-
|
|
7301
|
-
|
|
7302
|
-
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7306
|
-
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
|
|
7328
|
-
}
|
|
7329
|
-
|
|
7330
|
-
|
|
7331
|
-
|
|
7332
|
-
|
|
7333
|
-
|
|
7334
|
-
|
|
7335
|
-
}
|
|
7336
|
-
|
|
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/
|
|
6462
|
+
// src/hooks/use-capability.ts
|
|
6463
|
+
var cached = null;
|
|
7372
6464
|
/**
|
|
7373
|
-
*
|
|
7374
|
-
*
|
|
7375
|
-
* `FrameCounterClip` 是本项目实现的 IClip:不依赖任何媒体文件,
|
|
7376
|
-
* 逐帧用 OffscreenCanvas 画出帧号 / 进度条 / 色相变化。
|
|
6465
|
+
* 启动时探测一次浏览器能力(结果在会话内缓存)。
|
|
7377
6466
|
*
|
|
7378
|
-
*
|
|
7379
|
-
* 且能直接被 Combinator 编码成 MP4。
|
|
6467
|
+
* 必须在渲染任何 WebAV 组件前完成,否则 opfs-tools 的模块副作用可能直接抛错。
|
|
7380
6468
|
*/
|
|
7381
|
-
function
|
|
7382
|
-
const [
|
|
7383
|
-
|
|
7384
|
-
|
|
7385
|
-
|
|
7386
|
-
|
|
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 (
|
|
7415
|
-
let
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7424
|
-
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
|
|
7429
|
-
|
|
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
|
-
|
|
6493
|
+
cancelled = true;
|
|
7432
6494
|
};
|
|
7433
|
-
}, [
|
|
7434
|
-
|
|
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/
|
|
7520
|
-
|
|
7521
|
-
|
|
7522
|
-
|
|
7523
|
-
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
7529
|
-
|
|
7530
|
-
|
|
7531
|
-
|
|
7532
|
-
|
|
7533
|
-
|
|
7534
|
-
|
|
7535
|
-
|
|
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(
|
|
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);
|