@cut-workbench/workbench 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lab.js ADDED
@@ -0,0 +1,1177 @@
1
+ import { a as formatResolution, c as CardContent, d as CardTitle, f as Badge, l as CardDescription, n as formatDuration, p as cn, s as Card, u as CardHeader } from "./format-CV2aoZZC.js";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { formatTimecode, parseSrt } from "@cut-workbench/core";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ import { AlertTriangle, Circle, FlaskConical, Loader2, Pause, Play, Split, Square } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { AVRecorder, FrameCounterClip, burnSubtitles, concatMp4, createDemoCanvasStream, createMp4Clip, createSubtitlePreviewClip, exportCustomClip, extractKeyframes, fixRecordedDuration, formatRecorderState, isWebGL2Available, mixAudioIntoMp4, readMp4Info, renderChromakeyFrame, renderClipFrameToCanvas, renderSubtitleFrame, splitMp4Tracks } from "@cut-workbench/core/lab";
8
+ // src/features/lab/components/LabCard.tsx
9
+ /** Lab 卡片外壳:标题 + 用到的 API + (不可用时的原因) */
10
+ function LabCard({ title, description, apis, children, unavailable, className }) {
11
+ return /* @__PURE__ */ jsxs(Card, {
12
+ className: cn("flex flex-col", className),
13
+ children: [/* @__PURE__ */ jsxs(CardHeader, {
14
+ className: "gap-1",
15
+ children: [
16
+ /* @__PURE__ */ jsxs("div", {
17
+ className: "flex items-center gap-2",
18
+ children: [/* @__PURE__ */ jsx("div", {
19
+ className: "flex size-6 items-center justify-center rounded bg-primary/10 text-primary",
20
+ children: /* @__PURE__ */ jsx(FlaskConical, { className: "size-3.5" })
21
+ }), /* @__PURE__ */ jsx(CardTitle, {
22
+ className: "text-sm",
23
+ children: title
24
+ })]
25
+ }),
26
+ /* @__PURE__ */ jsx(CardDescription, {
27
+ className: "text-xs",
28
+ children: description
29
+ }),
30
+ /* @__PURE__ */ jsx("div", {
31
+ className: "flex flex-wrap gap-1 pt-1",
32
+ children: apis.map((api) => /* @__PURE__ */ jsx(Badge, {
33
+ variant: "outline",
34
+ className: "font-mono text-[10px] font-normal text-muted-foreground",
35
+ children: api
36
+ }, api))
37
+ })
38
+ ]
39
+ }), /* @__PURE__ */ jsx(CardContent, {
40
+ className: "flex flex-1 flex-col gap-2.5",
41
+ children: unavailable != null ? /* @__PURE__ */ jsx("div", {
42
+ className: "rounded-md border border-dashed border-warning/40 bg-warning/5 p-3 text-xs text-warning",
43
+ children: unavailable
44
+ }) : children
45
+ })]
46
+ });
47
+ }
48
+ // src/features/lab/hooks/use-lab-action.ts
49
+ /** 生成可下载链接(Lab 通用) */
50
+ function useDownload() {
51
+ const urlsRef = useRef([]);
52
+ return useCallback((blob, filename) => {
53
+ const url = URL.createObjectURL(blob);
54
+ urlsRef.current.push(url);
55
+ const link = document.createElement("a");
56
+ link.href = url;
57
+ link.download = filename;
58
+ document.body.appendChild(link);
59
+ link.click();
60
+ link.remove();
61
+ window.setTimeout(() => URL.revokeObjectURL(url), 15e3);
62
+ }, []);
63
+ }
64
+ /** 带 busy 状态的异步执行器:统一错误 toast,禁止静默吞异常 */
65
+ function useLabAction() {
66
+ const [busy, setBusy] = useState(null);
67
+ return {
68
+ busy,
69
+ run: useCallback(async (label, task) => {
70
+ setBusy(label);
71
+ try {
72
+ return await task();
73
+ } catch (err) {
74
+ const message = err instanceof Error ? err.message : String(err);
75
+ console.error(err);
76
+ toast.error("操作失败", { description: message });
77
+ return null;
78
+ } finally {
79
+ setBusy(null);
80
+ }
81
+ }, [])
82
+ };
83
+ }
84
+ // src/features/lab/components/shared.tsx
85
+ /**
86
+ * Lab 共享的可视组件。
87
+ *
88
+ * 只导出组件(React Fast Refresh 要求单文件只导出组件);
89
+ * 工具函数与 hook 放在 `../hooks/use-lab-action`。
90
+ */
91
+ /** 文件选择按钮 */
92
+ function FilePickButton({ label, accept, multiple, onFiles, disabled }) {
93
+ const inputRef = useRef(null);
94
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("input", {
95
+ ref: inputRef,
96
+ type: "file",
97
+ accept,
98
+ multiple,
99
+ className: "hidden",
100
+ onChange: (e) => {
101
+ const files = Array.from(e.target.files ?? []);
102
+ if (files.length > 0) onFiles(files);
103
+ e.target.value = "";
104
+ }
105
+ }), /* @__PURE__ */ jsx("button", {
106
+ type: "button",
107
+ disabled,
108
+ onClick: () => inputRef.current?.click(),
109
+ 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",
110
+ children: /* @__PURE__ */ jsx("span", {
111
+ className: "max-w-full truncate",
112
+ children: label
113
+ })
114
+ })] });
115
+ }
116
+ /** 结果预览:视频 + 下载 */
117
+ function ResultPreview({ blob, filename }) {
118
+ const download = useDownload();
119
+ const [url] = useState(() => URL.createObjectURL(blob));
120
+ useEffect(() => () => URL.revokeObjectURL(url), [url]);
121
+ return /* @__PURE__ */ jsxs("div", {
122
+ className: "space-y-1.5",
123
+ children: [/* @__PURE__ */ jsx("video", {
124
+ src: url,
125
+ controls: true,
126
+ className: "w-full rounded-md border bg-stage-bg",
127
+ style: { maxHeight: 200 }
128
+ }), /* @__PURE__ */ jsxs("div", {
129
+ className: "flex items-center justify-between gap-2",
130
+ children: [/* @__PURE__ */ jsxs("span", {
131
+ className: "min-w-0 flex-1 truncate text-[10px] text-muted-foreground",
132
+ children: [(blob.size / 1024 / 1024).toFixed(2), " MB"]
133
+ }), /* @__PURE__ */ jsx("button", {
134
+ type: "button",
135
+ onClick: () => download(blob, filename),
136
+ className: "rounded-md bg-primary px-2 py-1 text-[11px] text-primary-foreground hover:bg-primary/90",
137
+ children: "下载"
138
+ })]
139
+ })]
140
+ });
141
+ }
142
+ /** 忙碌提示 */
143
+ function Busy({ label }) {
144
+ return /* @__PURE__ */ jsxs("div", {
145
+ className: "flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground",
146
+ children: [/* @__PURE__ */ jsx(Loader2, { className: "size-3 animate-spin" }), label]
147
+ });
148
+ }
149
+ /** 简易滑杆 */
150
+ function SliderRow({ label, value, min, max, step, format, onChange }) {
151
+ return /* @__PURE__ */ jsxs("div", {
152
+ className: "space-y-1",
153
+ children: [/* @__PURE__ */ jsxs("div", {
154
+ className: "flex justify-between text-[10px] text-muted-foreground",
155
+ children: [/* @__PURE__ */ jsx("span", { children: label }), /* @__PURE__ */ jsx("span", {
156
+ className: "font-mono tabular-nums",
157
+ children: format ? format(value) : value.toFixed(2)
158
+ })]
159
+ }), /* @__PURE__ */ jsx("input", {
160
+ type: "range",
161
+ min,
162
+ max,
163
+ step,
164
+ value,
165
+ onChange: (e) => onChange(Number(e.target.value)),
166
+ className: "h-1 w-full cursor-pointer appearance-none rounded-full bg-muted accent-primary"
167
+ })]
168
+ });
169
+ }
170
+ // src/features/lab/components/ConcatenateLab.tsx
171
+ /**
172
+ * Lab 1:无损拼接。
173
+ *
174
+ * 用 `fastConcatMP4` 直接拼接多个 MP4 的样本,不重编码。
175
+ * 前提是编码 / 分辨率 / 采样率完全一致;不一致时本 Lab 会先预检并给出具体差异。
176
+ */
177
+ function ConcatenateLab() {
178
+ const [files, setFiles] = useState([]);
179
+ const [infos, setInfos] = useState([]);
180
+ const [result, setResult] = useState(null);
181
+ const { busy, run } = useLabAction();
182
+ const handleFiles = async (picked) => {
183
+ setFiles(picked);
184
+ setResult(null);
185
+ setInfos([]);
186
+ const detected = await run("正在读取文件信息…", async () => {
187
+ const list = [];
188
+ for (const file of picked) list.push(await readMp4Info(file));
189
+ return list;
190
+ });
191
+ if (detected != null) setInfos(detected);
192
+ };
193
+ const handleConcat = async () => {
194
+ const output = await run("正在无损拼接…", () => concatMp4(files));
195
+ if (output != null) {
196
+ setResult(output.blob);
197
+ setInfos(output.infos);
198
+ }
199
+ };
200
+ const first = infos[0];
201
+ const mismatch = first != null && infos.some((info) => info.width !== first.width || info.height !== first.height || info.codec !== first.codec || info.audioSampleRate !== first.audioSampleRate);
202
+ return /* @__PURE__ */ jsxs(LabCard, {
203
+ title: "无损拼接",
204
+ description: "直接把多个 MP4 的样本拼在一起,不重编码,速度极快。要求规格完全一致。",
205
+ apis: ["fastConcatMP4", "MP4Clip.getFileHeaderBinData"],
206
+ children: [
207
+ /* @__PURE__ */ jsx(FilePickButton, {
208
+ label: files.length > 0 ? `已选 ${files.length} 个文件(重新选择)` : "选择 2 个以上 MP4",
209
+ accept: "video/mp4",
210
+ multiple: true,
211
+ onFiles: (picked) => void handleFiles(picked),
212
+ disabled: busy != null
213
+ }),
214
+ files.length > 0 && /* @__PURE__ */ jsx("ul", {
215
+ className: "space-y-1 text-[10px] text-muted-foreground",
216
+ children: files.map((file, index) => {
217
+ const info = infos[index];
218
+ return /* @__PURE__ */ jsxs("li", {
219
+ className: "flex justify-between gap-2",
220
+ children: [/* @__PURE__ */ jsx("span", {
221
+ className: "truncate",
222
+ children: file.name
223
+ }), /* @__PURE__ */ jsx("span", {
224
+ className: "shrink-0 font-mono",
225
+ children: info != null ? `${formatResolution(info.width, info.height)} · ${formatDuration(info.durationUs)} · ${info.codec}` : "检测中…"
226
+ })]
227
+ }, `${file.name}-${index}`);
228
+ })
229
+ }),
230
+ mismatch && /* @__PURE__ */ jsxs("div", {
231
+ className: "flex items-start gap-1.5 rounded-md bg-warning/10 p-2 text-[10px] text-warning",
232
+ children: [/* @__PURE__ */ jsx(AlertTriangle, { className: "mt-0.5 size-3 shrink-0" }), "检测到规格不一致,拼接会失败并给出具体原因(fastConcatMP4 的硬性要求)。"]
233
+ }),
234
+ /* @__PURE__ */ jsx("button", {
235
+ type: "button",
236
+ disabled: files.length < 2 || busy != null,
237
+ onClick: () => void handleConcat(),
238
+ className: "rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50",
239
+ children: "开始拼接"
240
+ }),
241
+ busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
242
+ result != null && /* @__PURE__ */ jsx(ResultPreview, {
243
+ blob: result,
244
+ filename: "concat.mp4"
245
+ })
246
+ ]
247
+ });
248
+ }
249
+ // src/features/lab/components/RemuxLab.tsx
250
+ /**
251
+ * Lab 2:配音。
252
+ *
253
+ * `mixinMP4AndAudio` 只重编码音频轨,视频轨原样复制 —— 因此速度快、画质无损。
254
+ */
255
+ function RemuxLab() {
256
+ const [video, setVideo] = useState(null);
257
+ const [audio, setAudio] = useState(null);
258
+ const [volume, setVolume] = useState(1);
259
+ const [loop, setLoop] = useState(true);
260
+ const [result, setResult] = useState(null);
261
+ const { busy, run } = useLabAction();
262
+ const handleMix = async () => {
263
+ if (video == null || audio == null) return;
264
+ const output = await run("正在混合音频…", () => mixAudioIntoMp4(video, audio, volume, loop));
265
+ if (output != null) setResult(output);
266
+ };
267
+ return /* @__PURE__ */ jsxs(LabCard, {
268
+ title: "视频配音",
269
+ description: "把音轨混入 MP4:只重编码音频,视频轨不动,画质零损失。",
270
+ apis: ["mixinMP4AndAudio"],
271
+ children: [
272
+ /* @__PURE__ */ jsx(FilePickButton, {
273
+ label: video == null ? "选择视频(MP4)" : video.name,
274
+ accept: "video/mp4",
275
+ onFiles: (files) => {
276
+ setVideo(files[0] ?? null);
277
+ setResult(null);
278
+ },
279
+ disabled: busy != null
280
+ }),
281
+ /* @__PURE__ */ jsx(FilePickButton, {
282
+ label: audio == null ? "选择音频(MP3/WAV/M4A)" : audio.name,
283
+ accept: "audio/*",
284
+ onFiles: (files) => {
285
+ setAudio(files[0] ?? null);
286
+ setResult(null);
287
+ },
288
+ disabled: busy != null
289
+ }),
290
+ /* @__PURE__ */ jsx(SliderRow, {
291
+ label: "音量",
292
+ value: volume,
293
+ min: 0,
294
+ max: 2,
295
+ step: .05,
296
+ format: (v) => `${Math.round(v * 100)}%`,
297
+ onChange: setVolume
298
+ }),
299
+ /* @__PURE__ */ jsxs("label", {
300
+ className: "flex items-center gap-2 text-[11px] text-muted-foreground",
301
+ children: [/* @__PURE__ */ jsx("input", {
302
+ type: "checkbox",
303
+ checked: loop,
304
+ onChange: (e) => setLoop(e.target.checked),
305
+ className: "accent-primary"
306
+ }), "音频短于视频时循环填充"]
307
+ }),
308
+ /* @__PURE__ */ jsx("button", {
309
+ type: "button",
310
+ disabled: video == null || audio == null || busy != null,
311
+ onClick: () => void handleMix(),
312
+ className: "rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50",
313
+ children: "开始混合"
314
+ }),
315
+ busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
316
+ result != null && /* @__PURE__ */ jsx(ResultPreview, {
317
+ blob: result,
318
+ filename: "mixin.mp4"
319
+ })
320
+ ]
321
+ });
322
+ }
323
+ // src/features/lab/components/ChromakeyLab.tsx
324
+ /**
325
+ * Lab 3:绿幕抠像。
326
+ *
327
+ * `createChromakey` 返回一个函数,把 VideoFrame 转成去背后的 ImageBitmap。
328
+ * 配合 `MP4Clip.tickInterceptor` 即可对整段视频生效(导出时同样有效)。
329
+ *
330
+ * ⚠️ 依赖 WebGL2;不支持时本卡片隐藏操作、仅显示原因。
331
+ */
332
+ /**
333
+ * 抠像结果的兜底底色:读取主题令牌,而不是写死 `#101018`。
334
+ *
335
+ * 抠像后的 VideoFrame 带透明通道,需要一层底色才能看清;用 `--cw-stage-bg`
336
+ * 让 Lab 预览区与工作台舞台保持一致,宿主换主题时一起变。
337
+ * 取不到时退回深色(纯逻辑环境 / 未包 `.cw-root`)。
338
+ */
339
+ function readStageBackground() {
340
+ if (typeof document === "undefined") return "#101018";
341
+ const root = document.querySelector(".cw-root");
342
+ if (root == null) return "#101018";
343
+ const value = getComputedStyle(root).getPropertyValue("--cw-stage-bg").trim();
344
+ return value.length > 0 ? value : "#101018";
345
+ }
346
+ function ChromakeyLab() {
347
+ const [webgl2] = useState(isWebGL2Available);
348
+ const [file, setFile] = useState(null);
349
+ const [similarity, setSimilarity] = useState(.4);
350
+ const [smoothness, setSmoothness] = useState(.1);
351
+ const [spill, setSpill] = useState(.1);
352
+ const [timeUs, setTimeUs] = useState(0);
353
+ const [durationUs, setDurationUs] = useState(0);
354
+ const canvasRef = useRef(null);
355
+ const clipRef = useRef(null);
356
+ const { busy, run } = useLabAction();
357
+ useEffect(() => {
358
+ return () => {
359
+ clipRef.current?.destroy();
360
+ clipRef.current = null;
361
+ };
362
+ }, []);
363
+ const render = useCallback(async () => {
364
+ const clip = clipRef.current;
365
+ const canvas = canvasRef.current;
366
+ if (clip == null || canvas == null) return;
367
+ await renderChromakeyFrame(clip, timeUs, {
368
+ similarity,
369
+ smoothness,
370
+ spill,
371
+ keyColor: [
372
+ 0,
373
+ 255,
374
+ 0
375
+ ]
376
+ }, canvas, readStageBackground());
377
+ }, [
378
+ similarity,
379
+ smoothness,
380
+ spill,
381
+ timeUs
382
+ ]);
383
+ const handleFile = async (files) => {
384
+ const picked = files[0];
385
+ if (picked == null) return;
386
+ const result = await run("正在解析视频…", async () => {
387
+ clipRef.current?.destroy();
388
+ return await createMp4Clip(picked);
389
+ });
390
+ if (result == null) return;
391
+ clipRef.current = result.clip;
392
+ setFile(picked);
393
+ setDurationUs(result.meta.duration);
394
+ setTimeUs(Math.min(result.meta.duration / 2, 1e6));
395
+ toast.success("已载入视频,拖动参数查看抠像效果");
396
+ };
397
+ useEffect(() => {
398
+ if (clipRef.current == null || busy != null) return;
399
+ render();
400
+ }, [busy, render]);
401
+ return /* @__PURE__ */ jsxs(LabCard, {
402
+ title: "绿幕抠像",
403
+ description: "用 WebGL2 实时扣除背景色,通过 tickInterceptor 作用到整段视频。",
404
+ apis: ["createChromakey", "MP4Clip.tickInterceptor"],
405
+ unavailable: webgl2 ? null : "当前设备不支持 WebGL2,createChromakey 无法运行。",
406
+ children: [
407
+ /* @__PURE__ */ jsx(FilePickButton, {
408
+ label: file?.name ?? "选择绿幕视频(MP4)",
409
+ accept: "video/mp4",
410
+ onFiles: (files) => void handleFile(files),
411
+ disabled: busy != null
412
+ }),
413
+ /* @__PURE__ */ jsxs("div", {
414
+ className: "relative overflow-hidden rounded-md border bg-stage-bg",
415
+ children: [/* @__PURE__ */ jsx("canvas", {
416
+ ref: canvasRef,
417
+ className: "block w-full",
418
+ style: { maxHeight: 180 }
419
+ }), file == null && /* @__PURE__ */ jsx("div", {
420
+ className: "pointer-events-none absolute inset-0 flex h-24 items-center justify-center text-[10px] text-muted-foreground",
421
+ children: "选择视频后显示抠像结果"
422
+ })]
423
+ }),
424
+ /* @__PURE__ */ jsx(SliderRow, {
425
+ label: "相似度 similarity",
426
+ value: similarity,
427
+ min: 0,
428
+ max: 1,
429
+ step: .01,
430
+ format: (v) => v.toFixed(2),
431
+ onChange: setSimilarity
432
+ }),
433
+ /* @__PURE__ */ jsx(SliderRow, {
434
+ label: "平滑度 smoothness",
435
+ value: smoothness,
436
+ min: 0,
437
+ max: 1,
438
+ step: .01,
439
+ format: (v) => v.toFixed(2),
440
+ onChange: setSmoothness
441
+ }),
442
+ /* @__PURE__ */ jsx(SliderRow, {
443
+ label: "饱和度 spill",
444
+ value: spill,
445
+ min: 0,
446
+ max: 1,
447
+ step: .01,
448
+ format: (v) => v.toFixed(2),
449
+ onChange: setSpill
450
+ }),
451
+ durationUs > 0 && /* @__PURE__ */ jsx(SliderRow, {
452
+ label: "取帧时间",
453
+ value: timeUs,
454
+ min: 0,
455
+ max: Math.max(1, durationUs - 1),
456
+ step: 1e5,
457
+ format: (v) => `${(v / 1e6).toFixed(2)}s`,
458
+ onChange: setTimeUs
459
+ }),
460
+ busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
461
+ /* @__PURE__ */ jsx("p", {
462
+ className: "text-[10px] leading-relaxed text-muted-foreground",
463
+ children: "keyColor 固定为纯绿 (0,255,0)。实际项目中可读取首帧像素自动取色。"
464
+ })
465
+ ]
466
+ });
467
+ }
468
+ // src/features/lab/components/SubtitleLab.tsx
469
+ var SAMPLE_SRT = `1
470
+ 00:00:00,000 --> 00:00:02,000
471
+ 这是第一句字幕
472
+
473
+ 2
474
+ 00:00:02,000 --> 00:00:04,000
475
+ 第二句会带描边
476
+
477
+ 3
478
+ 00:00:04,000 --> 00:00:06,000
479
+ EmbedSubtitlesClip 烧录进画面
480
+ `;
481
+ /**
482
+ * Lab 4:字幕烧录。
483
+ *
484
+ * `EmbedSubtitlesClip` 本身就是一个 IClip:tick 返回带字幕的 VideoFrame。
485
+ * 因此既能放进 Combinator 导出,也能直接逐帧预览。
486
+ */
487
+ function SubtitleLab() {
488
+ const [srt, setSrt] = useState(SAMPLE_SRT);
489
+ const [timeUs, setTimeUs] = useState(0);
490
+ const [playing, setPlaying] = useState(false);
491
+ const [result, setResult] = useState(null);
492
+ const { busy, run } = useLabAction();
493
+ const canvasRef = useRef(null);
494
+ const clipRef = useRef(null);
495
+ const rafRef = useRef(null);
496
+ const cues = parseSrt(srt);
497
+ const durationUs = cues.length > 0 ? cues[cues.length - 1].endMs * 1e3 : 0;
498
+ useEffect(() => {
499
+ clipRef.current?.destroy();
500
+ if (cues.length === 0) {
501
+ clipRef.current = null;
502
+ return;
503
+ }
504
+ try {
505
+ clipRef.current = createSubtitlePreviewClip(srt, 640, 360);
506
+ } catch (err) {
507
+ console.warn("[SubtitleLab] create failed", err);
508
+ clipRef.current = null;
509
+ }
510
+ return () => {
511
+ clipRef.current?.destroy();
512
+ clipRef.current = null;
513
+ };
514
+ }, [cues.length, srt]);
515
+ const draw = useCallback(async () => {
516
+ const clip = clipRef.current;
517
+ const canvas = canvasRef.current;
518
+ if (clip == null || canvas == null) return;
519
+ await renderSubtitleFrame(clip, timeUs, canvas);
520
+ }, [timeUs]);
521
+ useEffect(() => {
522
+ draw();
523
+ }, [draw]);
524
+ useEffect(() => {
525
+ if (!playing) return;
526
+ let last = performance.now();
527
+ let current = timeUs;
528
+ const tick = () => {
529
+ const now = performance.now();
530
+ current += (now - last) * 1e3;
531
+ last = now;
532
+ if (durationUs > 0 && current >= durationUs) {
533
+ setTimeUs(durationUs);
534
+ setPlaying(false);
535
+ return;
536
+ }
537
+ setTimeUs(current);
538
+ rafRef.current = requestAnimationFrame(tick);
539
+ };
540
+ rafRef.current = requestAnimationFrame(tick);
541
+ return () => {
542
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
543
+ };
544
+ }, [playing, durationUs]);
545
+ const handleBurn = async () => {
546
+ const width = 1280;
547
+ const height = 720;
548
+ const output = await run("正在烧录字幕…", () => burnSubtitles(srt, width, height, durationUs));
549
+ if (output != null) setResult(output);
550
+ };
551
+ return /* @__PURE__ */ jsxs(LabCard, {
552
+ title: "字幕烧录",
553
+ description: "SRT 解析后逐帧绘制到画面,最终编码进视频流(硬字幕)。",
554
+ apis: ["EmbedSubtitlesClip", "Combinator"],
555
+ children: [
556
+ /* @__PURE__ */ jsx("textarea", {
557
+ value: srt,
558
+ onChange: (e) => setSrt(e.target.value),
559
+ rows: 5,
560
+ className: "w-full rounded-md border bg-transparent p-2 font-mono text-[10px] outline-none focus-visible:border-ring",
561
+ placeholder: "粘贴 SRT 内容"
562
+ }),
563
+ /* @__PURE__ */ jsx("div", {
564
+ className: "overflow-hidden rounded-md border bg-stage-bg",
565
+ children: /* @__PURE__ */ jsx("canvas", {
566
+ ref: canvasRef,
567
+ width: 640,
568
+ height: 360,
569
+ className: "block w-full",
570
+ style: { maxHeight: 180 }
571
+ })
572
+ }),
573
+ /* @__PURE__ */ jsxs("div", {
574
+ className: "flex items-center gap-2",
575
+ children: [/* @__PURE__ */ jsxs("button", {
576
+ type: "button",
577
+ onClick: () => setPlaying((p) => !p),
578
+ disabled: cues.length === 0,
579
+ className: "flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-[11px] disabled:opacity-50",
580
+ children: [playing ? /* @__PURE__ */ jsx(Square, { className: "size-3" }) : /* @__PURE__ */ jsx(Play, { className: "size-3" }), playing ? "停止" : "预览"]
581
+ }), /* @__PURE__ */ jsxs("span", {
582
+ className: "text-[10px] text-muted-foreground",
583
+ children: [
584
+ cues.length,
585
+ " 条 · ",
586
+ (timeUs / 1e6).toFixed(2),
587
+ "s / ",
588
+ (durationUs / 1e6).toFixed(2),
589
+ "s"
590
+ ]
591
+ })]
592
+ }),
593
+ /* @__PURE__ */ jsx(SliderRow, {
594
+ label: "时间",
595
+ value: timeUs,
596
+ min: 0,
597
+ max: Math.max(1, durationUs),
598
+ step: 1e4,
599
+ format: (v) => `${(v / 1e6).toFixed(2)}s`,
600
+ onChange: (v) => {
601
+ setPlaying(false);
602
+ setTimeUs(v);
603
+ }
604
+ }),
605
+ /* @__PURE__ */ jsx("button", {
606
+ type: "button",
607
+ disabled: cues.length === 0 || busy != null,
608
+ onClick: () => void handleBurn(),
609
+ className: "rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50",
610
+ children: "烧录并导出 MP4"
611
+ }),
612
+ busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
613
+ result != null && /* @__PURE__ */ jsx(ResultPreview, {
614
+ blob: result,
615
+ filename: "subtitles.mp4"
616
+ })
617
+ ]
618
+ });
619
+ }
620
+ // src/features/lab/components/TrackSplitLab.tsx
621
+ /**
622
+ * Lab 5:轨道拆分。
623
+ *
624
+ * `splitTrack()` 从一个 MP4Clip 派生出「纯视频」与「纯音频」两个 clip,
625
+ * 它们与源共享样本表,因此内存开销很小。
626
+ */
627
+ function TrackSplitLab() {
628
+ const [file, setFile] = useState(null);
629
+ const [result, setResult] = useState(null);
630
+ const { busy, run } = useLabAction();
631
+ const cleanupRef = useRef(null);
632
+ useEffect(() => {
633
+ return () => cleanupRef.current?.();
634
+ }, []);
635
+ const handleSplit = async () => {
636
+ if (file == null) return;
637
+ cleanupRef.current?.();
638
+ const output = await run("正在拆分轨道…", () => splitMp4Tracks(file));
639
+ setResult(output);
640
+ cleanupRef.current = () => {
641
+ output?.videoClip?.destroy();
642
+ output?.audioClip?.destroy();
643
+ };
644
+ };
645
+ return /* @__PURE__ */ jsxs(LabCard, {
646
+ title: "轨道拆分",
647
+ description: "把一个 MP4 拆成纯视频轨与纯音频轨两个独立 IClip,用于分别编辑。",
648
+ apis: ["MP4Clip.splitTrack"],
649
+ children: [
650
+ /* @__PURE__ */ jsx(FilePickButton, {
651
+ label: file?.name ?? "选择 MP4",
652
+ accept: "video/mp4",
653
+ onFiles: (files) => {
654
+ setFile(files[0] ?? null);
655
+ setResult(null);
656
+ },
657
+ disabled: busy != null
658
+ }),
659
+ /* @__PURE__ */ jsxs("button", {
660
+ type: "button",
661
+ disabled: file == null || busy != null,
662
+ onClick: () => void handleSplit(),
663
+ 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",
664
+ children: [/* @__PURE__ */ jsx(Split, { className: "size-3" }), "拆分轨道"]
665
+ }),
666
+ busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
667
+ result != null && /* @__PURE__ */ jsxs("div", {
668
+ className: "grid gap-1.5 text-[10px]",
669
+ children: [
670
+ /* @__PURE__ */ jsx(TrackResult, {
671
+ label: "视频轨",
672
+ present: result.videoMeta != null,
673
+ detail: result.videoMeta != null ? `${result.videoMeta.width}×${result.videoMeta.height} · ${formatDuration(result.videoMeta.duration)}` : "源文件无视频轨"
674
+ }),
675
+ /* @__PURE__ */ jsx(TrackResult, {
676
+ label: "音频轨",
677
+ present: result.audioMeta != null,
678
+ detail: result.audioMeta != null ? `48kHz 双声道 · ${formatDuration(result.audioMeta.duration)}` : "源文件无音频轨"
679
+ }),
680
+ /* @__PURE__ */ jsx("p", {
681
+ className: "pt-1 leading-relaxed text-muted-foreground",
682
+ children: "拆分出的 clip 与源共享样本表,可分别创建 Sprite 放到不同轨道;两者在时间轴上对齐即等于原片。"
683
+ })
684
+ ]
685
+ })
686
+ ]
687
+ });
688
+ }
689
+ function TrackResult({ label, present, detail }) {
690
+ return /* @__PURE__ */ jsxs("div", {
691
+ className: "flex items-center justify-between gap-2 rounded-md border px-2 py-1.5",
692
+ children: [/* @__PURE__ */ jsx("span", {
693
+ className: present ? "text-foreground" : "text-muted-foreground",
694
+ children: label
695
+ }), /* @__PURE__ */ jsx("span", {
696
+ className: present ? "font-mono text-success" : "text-muted-foreground",
697
+ children: present ? detail : detail
698
+ })]
699
+ });
700
+ }
701
+ // src/features/lab/components/ThumbnailLab.tsx
702
+ /**
703
+ * Lab 6:缩略图抽帧。
704
+ *
705
+ * `MP4Clip.thumbnails()` 默认每个关键帧生成一张;也可以传 step 按固定间隔取。
706
+ * 这里展示全部关键帧与时间戳。
707
+ */
708
+ function ThumbnailLab() {
709
+ const [file, setFile] = useState(null);
710
+ const [frames, setFrames] = useState([]);
711
+ const [width, setWidth] = useState(120);
712
+ const { busy, run } = useLabAction();
713
+ useEffect(() => {
714
+ return () => {
715
+ for (const frame of frames) URL.revokeObjectURL(frame.url);
716
+ };
717
+ }, [frames]);
718
+ const handleExtract = async () => {
719
+ if (file == null) return;
720
+ const output = await run("正在抽取关键帧…", () => extractKeyframes(file, width));
721
+ if (output != null) setFrames(output);
722
+ };
723
+ return /* @__PURE__ */ jsxs(LabCard, {
724
+ title: "缩略图抽帧",
725
+ description: "按关键帧抽取缩略图,可用于时间轴缩略图带或场景切分预览。",
726
+ apis: ["MP4Clip.thumbnails"],
727
+ children: [
728
+ /* @__PURE__ */ jsx(FilePickButton, {
729
+ label: file?.name ?? "选择 MP4",
730
+ accept: "video/mp4",
731
+ onFiles: (files) => {
732
+ setFile(files[0] ?? null);
733
+ setFrames([]);
734
+ },
735
+ disabled: busy != null
736
+ }),
737
+ /* @__PURE__ */ jsxs("div", {
738
+ className: "flex items-center gap-2",
739
+ children: [
740
+ /* @__PURE__ */ jsx("label", {
741
+ className: "text-[10px] text-muted-foreground",
742
+ children: "宽度"
743
+ }),
744
+ /* @__PURE__ */ jsx("input", {
745
+ type: "number",
746
+ min: 40,
747
+ max: 480,
748
+ step: 20,
749
+ value: width,
750
+ onChange: (e) => setWidth(Number(e.target.value)),
751
+ className: "h-6 w-16 rounded border bg-transparent px-1.5 text-[11px]"
752
+ }),
753
+ /* @__PURE__ */ jsx("button", {
754
+ type: "button",
755
+ disabled: file == null || busy != null,
756
+ onClick: () => void handleExtract(),
757
+ className: "ml-auto rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50",
758
+ children: "抽取"
759
+ })
760
+ ]
761
+ }),
762
+ busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
763
+ frames.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("p", {
764
+ className: "text-[10px] text-muted-foreground",
765
+ children: [
766
+ "共 ",
767
+ frames.length,
768
+ " 张关键帧"
769
+ ]
770
+ }), /* @__PURE__ */ jsx("div", {
771
+ className: "thin-scrollbar grid max-h-52 grid-cols-3 gap-1.5 overflow-auto",
772
+ children: frames.map((frame, index) => /* @__PURE__ */ jsxs("figure", {
773
+ className: "space-y-0.5",
774
+ children: [/* @__PURE__ */ jsx("img", {
775
+ src: frame.url,
776
+ alt: "",
777
+ className: "w-full rounded border object-cover",
778
+ loading: "lazy"
779
+ }), /* @__PURE__ */ jsx("figcaption", {
780
+ className: "font-mono text-[9px] text-muted-foreground tabular-nums",
781
+ children: formatTimecode(frame.ts)
782
+ })]
783
+ }, `${frame.ts}-${index}`))
784
+ })] })
785
+ ]
786
+ });
787
+ }
788
+ // src/features/lab/components/RecorderLab.tsx
789
+ /**
790
+ * Lab 7:屏幕 / 画布录制。
791
+ *
792
+ * `AVRecorder` 接收任意 MediaStream 并输出 MP4(H.264 + AAC)。
793
+ * 这里提供两种源:
794
+ * - 程序化画布(Canvas.captureStream,无需授权);
795
+ * - 摄像头(getUserMedia,需要用户授权)。
796
+ */
797
+ function RecorderLab() {
798
+ const [mode, setMode] = useState("demo");
799
+ const [recording, setRecording] = useState(false);
800
+ const [paused, setPaused] = useState(false);
801
+ const [bytes, setBytes] = useState(0);
802
+ const [state, setState] = useState("inactive");
803
+ const [result, setResult] = useState(null);
804
+ const [previewStream, setPreviewStream] = useState(null);
805
+ const videoRef = useRef(null);
806
+ const recorderRef = useRef(null);
807
+ const demoRef = useRef(null);
808
+ const cameraRef = useRef(null);
809
+ const { run } = useLabAction();
810
+ useEffect(() => {
811
+ const video = videoRef.current;
812
+ if (video == null) return;
813
+ video.srcObject = previewStream;
814
+ if (previewStream != null) video.play().catch(() => void 0);
815
+ return () => {
816
+ if (video.srcObject === previewStream) video.srcObject = null;
817
+ };
818
+ }, [previewStream]);
819
+ const cleanup = useCallback(() => {
820
+ demoRef.current?.stop();
821
+ demoRef.current = null;
822
+ cameraRef.current?.getTracks().forEach((t) => t.stop());
823
+ cameraRef.current = null;
824
+ recorderRef.current?.dispose();
825
+ recorderRef.current = null;
826
+ setPreviewStream(null);
827
+ }, []);
828
+ useEffect(() => cleanup, [cleanup]);
829
+ const acquireStream = async () => {
830
+ cleanup();
831
+ if (mode === "demo") {
832
+ const demo = createDemoCanvasStream(1280, 720);
833
+ demoRef.current = demo;
834
+ setPreviewStream(demo.stream);
835
+ return demo.stream;
836
+ }
837
+ try {
838
+ const stream = await navigator.mediaDevices.getUserMedia({
839
+ video: true,
840
+ audio: true
841
+ });
842
+ cameraRef.current = stream;
843
+ setPreviewStream(stream);
844
+ return stream;
845
+ } catch (err) {
846
+ const message = err instanceof Error ? err.message : String(err);
847
+ toast.error("无法访问摄像头", { description: message });
848
+ return null;
849
+ }
850
+ };
851
+ const handleStart = async () => {
852
+ const stream = await acquireStream();
853
+ if (stream == null) return;
854
+ setBytes(0);
855
+ setResult(null);
856
+ const recorder = createRecorderAdapter(stream, (total) => setBytes(total));
857
+ recorderRef.current = recorder;
858
+ recorder.start();
859
+ setRecording(true);
860
+ setPaused(false);
861
+ setState(recorder.state());
862
+ };
863
+ const handlePauseToggle = () => {
864
+ const recorder = recorderRef.current;
865
+ if (recorder == null) return;
866
+ if (paused) {
867
+ recorder.resume();
868
+ setPaused(false);
869
+ } else {
870
+ recorder.pause();
871
+ setPaused(true);
872
+ }
873
+ setState(recorder.state());
874
+ };
875
+ const handleStop = async () => {
876
+ const recorder = recorderRef.current;
877
+ if (recorder == null) return;
878
+ const output = await run("正在封装 MP4…", async () => {
879
+ const raw = await recorder.stop();
880
+ return fixRecordedDuration(raw);
881
+ });
882
+ setRecording(false);
883
+ setPaused(false);
884
+ setState(recorder.state());
885
+ recorderRef.current = null;
886
+ if (output != null) {
887
+ setResult(output);
888
+ toast.success(`录制完成 · ${(output.size / 1024 / 1024).toFixed(2)} MB`);
889
+ }
890
+ };
891
+ return /* @__PURE__ */ jsxs(LabCard, {
892
+ title: "屏幕 / 画布录制",
893
+ description: "用 AVRecorder 把 MediaStream 实时编码为 MP4。可录制程序化画布或摄像头。",
894
+ apis: ["AVCanvas.captureStream", "AVRecorder.start/pause/resume/stop"],
895
+ children: [
896
+ /* @__PURE__ */ jsx("div", {
897
+ className: "flex gap-1.5",
898
+ children: ["demo", "camera"].map((item) => /* @__PURE__ */ jsx("button", {
899
+ type: "button",
900
+ disabled: recording,
901
+ onClick: () => setMode(item),
902
+ className: `flex-1 rounded-md border px-2 py-1 text-[11px] disabled:opacity-50 ${mode === item ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground"}`,
903
+ children: item === "demo" ? "动态画布" : "摄像头"
904
+ }, item))
905
+ }),
906
+ /* @__PURE__ */ jsx("div", {
907
+ className: "overflow-hidden rounded-md border bg-stage-bg",
908
+ children: /* @__PURE__ */ jsx("video", {
909
+ ref: videoRef,
910
+ muted: true,
911
+ playsInline: true,
912
+ className: "block w-full",
913
+ style: { maxHeight: 170 }
914
+ })
915
+ }),
916
+ /* @__PURE__ */ jsxs("div", {
917
+ className: "flex flex-wrap items-center gap-1.5",
918
+ children: [!recording ? /* @__PURE__ */ jsxs("button", {
919
+ type: "button",
920
+ onClick: () => void handleStart(),
921
+ className: "flex items-center gap-1 rounded-md bg-destructive px-2.5 py-1 text-[11px] text-destructive-foreground",
922
+ children: [/* @__PURE__ */ jsx(Circle, { className: "size-3 fill-current" }), "开始录制"]
923
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
924
+ type: "button",
925
+ onClick: handlePauseToggle,
926
+ className: "flex items-center gap-1 rounded-md bg-secondary px-2.5 py-1 text-[11px]",
927
+ children: [paused ? /* @__PURE__ */ jsx(Play, { className: "size-3" }) : /* @__PURE__ */ jsx(Pause, { className: "size-3" }), paused ? "继续" : "暂停"]
928
+ }), /* @__PURE__ */ jsxs("button", {
929
+ type: "button",
930
+ onClick: () => void handleStop(),
931
+ className: "flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-[11px] text-primary-foreground",
932
+ children: [/* @__PURE__ */ jsx(Square, { className: "size-3" }), "停止"]
933
+ })] }), /* @__PURE__ */ jsxs("span", {
934
+ className: "ml-auto font-mono text-[10px] text-muted-foreground tabular-nums",
935
+ children: [
936
+ (bytes / 1024 / 1024).toFixed(2),
937
+ " MB · ",
938
+ formatRecorderState(state)
939
+ ]
940
+ })]
941
+ }),
942
+ result != null && /* @__PURE__ */ jsx(ResultPreview, {
943
+ blob: result,
944
+ filename: "recording.mp4"
945
+ })
946
+ ]
947
+ });
948
+ }
949
+ /** 适配 RecorderHandle(隔离 AVRecorder 细节) */
950
+ function createRecorderAdapter(stream, onChunk) {
951
+ const recorder = new AVRecorder(stream, { expectFPS: 30 });
952
+ const chunks = [];
953
+ let total = 0;
954
+ return {
955
+ start: () => {
956
+ const readable = recorder.start(300);
957
+ (async () => {
958
+ const reader = readable.getReader();
959
+ for (;;) try {
960
+ const { done, value } = await reader.read();
961
+ if (done) break;
962
+ if (value != null) {
963
+ chunks.push(new Uint8Array(value));
964
+ total += value.byteLength;
965
+ onChunk(total);
966
+ }
967
+ } catch {
968
+ break;
969
+ }
970
+ })();
971
+ },
972
+ pause: () => recorder.pause(),
973
+ resume: () => recorder.resume(),
974
+ stop: async () => {
975
+ await recorder.stop();
976
+ return new Blob(chunks, { type: "video/mp4" });
977
+ },
978
+ state: () => recorder.state,
979
+ dispose: () => {
980
+ stream.getTracks().forEach((t) => t.stop());
981
+ }
982
+ };
983
+ }
984
+ // src/features/lab/components/CustomClipLab.tsx
985
+ /**
986
+ * Lab 8:自定义 IClip。
987
+ *
988
+ * `FrameCounterClip` 是本项目实现的 IClip:不依赖任何媒体文件,
989
+ * 逐帧用 OffscreenCanvas 画出帧号 / 进度条 / 色相变化。
990
+ *
991
+ * 它证明 IClip 接口(tick / ready / meta / clone / split / destroy)可以被任意扩展,
992
+ * 且能直接被 Combinator 编码成 MP4。
993
+ */
994
+ function CustomClipLab() {
995
+ const [durationSec, setDurationSec] = useState(5);
996
+ const [timeUs, setTimeUs] = useState(0);
997
+ const [playing, setPlaying] = useState(false);
998
+ const [result, setResult] = useState(null);
999
+ const canvasRef = useRef(null);
1000
+ const clipRef = useRef(null);
1001
+ const rafRef = useRef(null);
1002
+ const { busy, run } = useLabAction();
1003
+ const durationUs = Math.round(durationSec * 1e6);
1004
+ useEffect(() => {
1005
+ clipRef.current?.destroy();
1006
+ try {
1007
+ clipRef.current = new FrameCounterClip(640, 360, durationUs, 30);
1008
+ } catch (err) {
1009
+ console.warn("[CustomClipLab] create failed", err);
1010
+ clipRef.current = null;
1011
+ }
1012
+ return () => {
1013
+ clipRef.current?.destroy();
1014
+ clipRef.current = null;
1015
+ };
1016
+ }, [durationUs]);
1017
+ const draw = useCallback(async () => {
1018
+ const clip = clipRef.current;
1019
+ const canvas = canvasRef.current;
1020
+ if (clip == null || canvas == null) return;
1021
+ await renderClipFrameToCanvas(clip, timeUs, canvas);
1022
+ }, [timeUs]);
1023
+ useEffect(() => {
1024
+ draw();
1025
+ }, [draw]);
1026
+ useEffect(() => {
1027
+ if (!playing) return;
1028
+ let last = performance.now();
1029
+ let current = timeUs;
1030
+ const tick = () => {
1031
+ const now = performance.now();
1032
+ current += (now - last) * 1e3;
1033
+ last = now;
1034
+ if (current >= durationUs) {
1035
+ setTimeUs(durationUs - 1);
1036
+ setPlaying(false);
1037
+ return;
1038
+ }
1039
+ setTimeUs(current);
1040
+ rafRef.current = requestAnimationFrame(tick);
1041
+ };
1042
+ rafRef.current = requestAnimationFrame(tick);
1043
+ return () => {
1044
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
1045
+ };
1046
+ }, [playing, durationUs]);
1047
+ const handleExport = async () => {
1048
+ const clip = clipRef.current;
1049
+ if (clip == null) return;
1050
+ const output = await run("正在编码自定义 Clip…", () => exportCustomClip(clip, durationUs));
1051
+ if (output != null) setResult(output);
1052
+ };
1053
+ return /* @__PURE__ */ jsxs(LabCard, {
1054
+ title: "自定义 IClip",
1055
+ description: "纯程序化渲染的 Clip(帧计数器),不依赖任何媒体文件,证明接口可扩展。",
1056
+ apis: [
1057
+ "IClip.tick",
1058
+ "IClip.clone",
1059
+ "IClip.split",
1060
+ "Combinator"
1061
+ ],
1062
+ children: [
1063
+ /* @__PURE__ */ jsx("div", {
1064
+ className: "overflow-hidden rounded-md border bg-stage-bg",
1065
+ children: /* @__PURE__ */ jsx("canvas", {
1066
+ ref: canvasRef,
1067
+ width: 640,
1068
+ height: 360,
1069
+ className: "block w-full",
1070
+ style: { maxHeight: 180 }
1071
+ })
1072
+ }),
1073
+ /* @__PURE__ */ jsxs("div", {
1074
+ className: "flex items-center gap-2",
1075
+ children: [/* @__PURE__ */ jsxs("button", {
1076
+ type: "button",
1077
+ onClick: () => {
1078
+ if (!playing && timeUs >= durationUs - 1) setTimeUs(0);
1079
+ setPlaying((p) => !p);
1080
+ },
1081
+ className: "flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-[11px]",
1082
+ children: [playing ? /* @__PURE__ */ jsx(Square, { className: "size-3" }) : /* @__PURE__ */ jsx(Play, { className: "size-3" }), playing ? "停止" : "预览"]
1083
+ }), /* @__PURE__ */ jsxs("span", {
1084
+ className: "font-mono text-[10px] text-muted-foreground tabular-nums",
1085
+ children: [
1086
+ formatTimecode(timeUs),
1087
+ " / ",
1088
+ formatTimecode(durationUs)
1089
+ ]
1090
+ })]
1091
+ }),
1092
+ /* @__PURE__ */ jsx(SliderRow, {
1093
+ label: "时间",
1094
+ value: timeUs,
1095
+ min: 0,
1096
+ max: Math.max(1, durationUs - 1),
1097
+ step: 1e4,
1098
+ format: (v) => `${(v / 1e6).toFixed(2)}s`,
1099
+ onChange: (v) => {
1100
+ setPlaying(false);
1101
+ setTimeUs(v);
1102
+ }
1103
+ }),
1104
+ /* @__PURE__ */ jsx(SliderRow, {
1105
+ label: "时长",
1106
+ value: durationSec,
1107
+ min: 1,
1108
+ max: 20,
1109
+ step: .5,
1110
+ format: (v) => `${v.toFixed(1)}s`,
1111
+ onChange: (v) => {
1112
+ setPlaying(false);
1113
+ setDurationSec(v);
1114
+ setTimeUs(0);
1115
+ }
1116
+ }),
1117
+ /* @__PURE__ */ jsx("button", {
1118
+ type: "button",
1119
+ disabled: busy != null,
1120
+ onClick: () => void handleExport(),
1121
+ className: "rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground disabled:opacity-50",
1122
+ children: "编码为 MP4"
1123
+ }),
1124
+ busy != null && /* @__PURE__ */ jsx(Busy, { label: busy }),
1125
+ result != null && /* @__PURE__ */ jsx(ResultPreview, {
1126
+ blob: result,
1127
+ filename: "custom-clip.mp4"
1128
+ })
1129
+ ]
1130
+ });
1131
+ }
1132
+ // src/features/lab/components/LabPage.tsx
1133
+ /**
1134
+ * 能力实验室:每个 WebAV 高级 API 一个可独立运行的卡片。
1135
+ *
1136
+ * 与主编辑器共享同一套能力探测结果,因此这里不再重复检测。
1137
+ */
1138
+ function LabPage() {
1139
+ return /* @__PURE__ */ jsxs("div", {
1140
+ className: "mx-auto max-w-7xl space-y-4 p-4",
1141
+ children: [
1142
+ /* @__PURE__ */ jsxs("header", {
1143
+ className: "space-y-1",
1144
+ children: [/* @__PURE__ */ jsxs("div", {
1145
+ className: "flex items-center gap-2",
1146
+ children: [/* @__PURE__ */ jsx(FlaskConical, { className: "size-4 text-primary" }), /* @__PURE__ */ jsx("h1", {
1147
+ className: "text-sm font-semibold",
1148
+ children: "能力实验室"
1149
+ })]
1150
+ }), /* @__PURE__ */ jsx("p", {
1151
+ className: "text-xs text-muted-foreground",
1152
+ children: "覆盖 av-cliper / av-canvas / av-recorder 的进阶 API。每个卡片可独立运行,失败时给出可读原因。"
1153
+ })]
1154
+ }),
1155
+ /* @__PURE__ */ jsxs("div", {
1156
+ className: "grid gap-3 md:grid-cols-2 xl:grid-cols-3",
1157
+ children: [
1158
+ /* @__PURE__ */ jsx(ConcatenateLab, {}),
1159
+ /* @__PURE__ */ jsx(RemuxLab, {}),
1160
+ /* @__PURE__ */ jsx(ChromakeyLab, {}),
1161
+ /* @__PURE__ */ jsx(SubtitleLab, {}),
1162
+ /* @__PURE__ */ jsx(TrackSplitLab, {}),
1163
+ /* @__PURE__ */ jsx(ThumbnailLab, {}),
1164
+ /* @__PURE__ */ jsx(RecorderLab, {}),
1165
+ /* @__PURE__ */ jsx(CustomClipLab, {})
1166
+ ]
1167
+ }),
1168
+ /* @__PURE__ */ jsx("footer", {
1169
+ className: "border-t pt-3 text-[10px] leading-relaxed text-muted-foreground",
1170
+ children: "说明:所有输出均为 MP4(H.264 + AAC 48kHz 双声道)。WebAV 生成的是 fMP4 分片, 部分播放器需「修复时长」;浏览器本地处理,文件不会上传。"
1171
+ })
1172
+ ]
1173
+ });
1174
+ }
1175
+ export { LabPage };
1176
+
1177
+ //# sourceMappingURL=lab.js.map