From b531c47fd5a0b44ce4985dd65a207c0364cb7ab6 Mon Sep 17 00:00:00 2001 From: dlh <2773843782@qq.com> Date: Fri, 14 Aug 2026 20:45:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E8=A7=86=E9=A2=91=E7=89=87?= =?UTF-8?q?=E6=AE=B5=20-=20=E7=B2=BE=E7=A1=AE=E8=A3=81=E5=88=87=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E5=B9=B6=E6=8F=90=E5=8F=96=E9=9F=B3=E8=BD=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: dlh <2773843782@qq.com> Original-PR: https://github.com/ddcat-ai/open-ai-canvas/pull/224 --- web/package.json | 2 +- .../lib/canvas/canvas-video-segment-args.ts | 17 +++++++++++++++++ web/src/lib/canvas/canvas-video-segment.ts | 16 +++++----------- web/test/canvas-video-segment-args.test.ts | 19 +++++++++++++++++++ 4 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 web/src/lib/canvas/canvas-video-segment-args.ts create mode 100644 web/test/canvas-video-segment-args.test.ts diff --git a/web/package.json b/web/package.json index 4c75edacb..c71039d1f 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/agent-prompt-cache.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/client-id.test.ts test/timeline-to-ffmpeg.test.ts", + "test": "bun test test/agent-prompt-cache.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/client-id.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/lib/canvas/canvas-video-segment-args.ts b/web/src/lib/canvas/canvas-video-segment-args.ts new file mode 100644 index 000000000..cfb2a4da2 --- /dev/null +++ b/web/src/lib/canvas/canvas-video-segment-args.ts @@ -0,0 +1,17 @@ +// 视频片段处理(裁切/提音轨)的 ffmpeg 参数构造,纯函数便于单测。 +// -ss 必须放在 -i 之后(输出 seek):放在 -i 之前是输入 seek,MP4/H.264 只会定位到目标时间戳 +// 之前最近的关键帧,切点会偏移最多一个 GOP(常见 0.5-2s)、片尾被 -t 截掉、音视频在切点处错位; +// 本流程已 -c:v libx264 重编码,输出 seek 帧精确,代价只是多解码。 + +export const SEGMENT_INPUT_NAME = "segment-input.mp4"; +export const SEGMENT_OUTPUT_NAME = "segment-output.mp4"; + +/** 视频片段裁切参数:输出统一编码 MP4。 */ +export function buildSegmentTrimArgs(startSec: string, durationSec: string): string[] { + return ["-i", SEGMENT_INPUT_NAME, "-ss", startSec, "-t", durationSec, "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "aac", "-movflags", "+faststart", SEGMENT_OUTPUT_NAME]; +} + +/** 从视频片段提取声音:同样使用输出 seek,保证起点与裁切路径一致。 */ +export function buildExtractAudioArgs(audioCodec: string, startSec: string, durationSec: string): string[] { + return ["-i", SEGMENT_INPUT_NAME, "-ss", startSec, "-t", durationSec, "-vn", "-c:a", audioCodec, "-q:a", "2", SEGMENT_OUTPUT_NAME]; +} diff --git a/web/src/lib/canvas/canvas-video-segment.ts b/web/src/lib/canvas/canvas-video-segment.ts index de50d76f5..0313d58f3 100644 --- a/web/src/lib/canvas/canvas-video-segment.ts +++ b/web/src/lib/canvas/canvas-video-segment.ts @@ -1,6 +1,7 @@ import { fetchFile } from "@ffmpeg/util"; import { getMediaBlob } from "@/services/file-storage"; +import { buildExtractAudioArgs, buildSegmentTrimArgs, SEGMENT_INPUT_NAME, SEGMENT_OUTPUT_NAME } from "./canvas-video-segment-args"; import { loadFFmpeg } from "./canvas-video-merge"; export type VideoSegmentRange = { @@ -18,8 +19,8 @@ export type VideoSegmentProgress = { progress: number; }; -const INPUT_NAME = "segment-input.mp4"; -const OUTPUT_NAME = "segment-output.mp4"; +const INPUT_NAME = SEGMENT_INPUT_NAME; +const OUTPUT_NAME = SEGMENT_OUTPUT_NAME; function assertValidRange(range: VideoSegmentRange, durationMs?: number) { const startMs = Math.max(0, Math.round(range.startMs)); @@ -70,14 +71,7 @@ async function runSegmentJob( /** 按片段范围截取视频,输出统一编码 MP4(复用时间线 trim 的参数模板)。 */ export async function trimVideoSegment(source: VideoSegmentSource, range: VideoSegmentRange, durationMs?: number, onProgress?: (progress: VideoSegmentProgress) => void) { - return runSegmentJob( - source, - range, - durationMs, - (startSec, durationSec) => ["-ss", startSec, "-i", INPUT_NAME, "-t", durationSec, "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "aac", "-movflags", "+faststart", OUTPUT_NAME], - onProgress, - "video/mp4", - ); + return runSegmentJob(source, range, durationMs, (startSec, durationSec) => buildSegmentTrimArgs(startSec, durationSec), onProgress, "video/mp4"); } /** 从视频片段提取声音为 MP3;优先 libmp3lame,内核不支持时回退默认 mp3 编码器。 */ @@ -91,7 +85,7 @@ export async function extractVideoAudio(source: VideoSegmentSource, range: Video const durationSec = String((range.endMs - range.startMs) / 1000); onProgress?.({ phase: "encoding", progress: 55 }); try { - const args = (audioCodec: string) => ["-ss", startSec, "-i", INPUT_NAME, "-t", durationSec, "-vn", "-c:a", audioCodec, "-q:a", "2", OUTPUT_NAME]; + const args = (audioCodec: string) => buildExtractAudioArgs(audioCodec, startSec, durationSec); let exitCode = await ffmpeg.exec(["-y", ...args("libmp3lame")]); if (exitCode !== 0) exitCode = await ffmpeg.exec(["-y", ...args("mp3")]); if (exitCode !== 0) throw new Error("音频提取失败:当前 FFmpeg 内核不支持 MP3 编码"); diff --git a/web/test/canvas-video-segment-args.test.ts b/web/test/canvas-video-segment-args.test.ts new file mode 100644 index 000000000..4cb9da273 --- /dev/null +++ b/web/test/canvas-video-segment-args.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; + +import { buildExtractAudioArgs, buildSegmentTrimArgs, SEGMENT_INPUT_NAME, SEGMENT_OUTPUT_NAME } from "../src/lib/canvas/canvas-video-segment-args"; + +describe("buildSegmentTrimArgs seek 顺序", () => { + test("-ss 必须放在 -i 之后(输出 seek):输入 seek 按关键帧对齐,切点会偏移最多一个 GOP", () => { + const args = buildSegmentTrimArgs("10.5", "4"); + expect(args.indexOf("-ss")).toBeGreaterThan(args.indexOf("-i")); + expect(args).toEqual(["-i", SEGMENT_INPUT_NAME, "-ss", "10.5", "-t", "4", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "aac", "-movflags", "+faststart", SEGMENT_OUTPUT_NAME]); + }); +}); + +describe("buildExtractAudioArgs seek 顺序", () => { + test("音频提取与裁切路径一致,同样使用输出 seek", () => { + const args = buildExtractAudioArgs("libmp3lame", "10.5", "4"); + expect(args.indexOf("-ss")).toBeGreaterThan(args.indexOf("-i")); + expect(args).toEqual(["-i", SEGMENT_INPUT_NAME, "-ss", "10.5", "-t", "4", "-vn", "-c:a", "libmp3lame", "-q:a", "2", SEGMENT_OUTPUT_NAME]); + }); +});