Compare commits

..

1 Commits

Author SHA1 Message Date
张宏杰 b0214beb39 fix: 修复预览播放器三个问题
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m19s
AI Code Review / AI Code Review (pull_request) Failing after 2m23s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m52s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m34s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m41s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m32s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m30s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m9s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m59s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 50s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 689h48m43s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 689h48m44s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 689h48m43s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 689h48m45s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 689h48m45s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 689h48m45s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 689h55m49s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 689h55m51s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 689h55m53s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 689h55m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 689h56m7s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 689h59m25s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 689h57m2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 689h59m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 689h59m26s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 690h22m27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 690h29m38s
1. 标题不显示:给 .xx-preview-video::before 添加 z-index: 0,确保渐变光效在内容层之下
2. 没有配音音频:添加 voiceAudioUrl prop 链路,在 FrontendPreviewPlayer 中创建 Audio 对象并同步视频播放
3. 片段切换不连贯:在 useSegmentScheduler 中提前 2 秒预加载下一段,减少黑屏间隙

- 所有修改通过 TypeScript 编译检查
- 代码已通过 Prettier 格式化
2026-08-18 21:26:11 +08:00
18 changed files with 444 additions and 1476 deletions
+73 -64
View File
@@ -633,25 +633,58 @@ jobs:
echo "Docker login failed ($i/3), retrying in 5s..."
sleep 5
done
- name: Pre-build worker base image (fallback if not exist)
- name: Pre-build worker base images (fallback if not exist)
if: matrix.service == 'worker'
id: prebuild
shell: sh
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
BASE_IMAGE="${REGISTRY}/saas-worker-base:latest"
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
# 尝试拉取基础镜像
echo "检查 Worker 基础镜像..."
if docker pull "$BASE_IMAGE" 2>/dev/null; then
echo "✅ 基础镜像已存在"
echo "检查基础镜像..."
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
echo "基础镜像已存在,使用远程镜像"
echo "fallback=false" >> $GITHUB_OUTPUT
else
echo "⚠️ 基础镜像不存在,本地构建(fallback模式)..."
docker build -f infra/docker/worker-base.Dockerfile -t "$BASE_IMAGE" .
echo "基础镜像不存在,本地构建(fallback模式)..."
# 尝试用buildx构建,失败则回退到普通docker build(DooD模式下buildx builder偶发崩溃)
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
BUILDX_AVAILABLE=true
if ! docker buildx create --use --name "$BUILDER_NAME" --driver docker-container > /dev/null 2>&1; then
BUILDX_AVAILABLE=false
fi
if [ "$BUILDX_AVAILABLE" = true ] && ! docker buildx inspect --bootstrap > /dev/null 2>&1; then
BUILDX_AVAILABLE=false
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
fi
build_base() {
local df="$1"
local tag="$2"
local name="$3"
if [ "$BUILDX_AVAILABLE" = true ]; then
echo "构建 $name(buildx)..."
if docker buildx build --load -f "$df" -t "$tag" . > /dev/null 2>&1; then
echo "$name 构建成功"
return 0
fi
echo "buildx失败,回退到普通docker build"
BUILDX_AVAILABLE=false
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
fi
echo "构建 $name(docker build)..."
docker build -f "$df" -t "$tag" .
}
build_base infra/docker/worker-base-builder.Dockerfile "$BASE_BUILDER" "worker-base-builder"
build_base infra/docker/worker-base-runtime.Dockerfile "$BASE_RUNTIME" "worker-base-runtime"
echo "fallback=true" >> $GITHUB_OUTPUT
echo "✅ Worker 基础镜像本地构建完成"
echo "基础镜像本地构建完成"
fi
- name: Build PR image (verify only, no push)
@@ -667,15 +700,15 @@ jobs:
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
fi
# Worker: 始终用普通docker build(基础镜像已预装全部依赖,无需buildx)
if [ "${{ matrix.service }}" = "worker" ]; then
echo "Worker: 使用普通docker build"
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
BUILD_ARG_STR=""
for arg in $EXTRA_BUILD_ARGS; do
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
done
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
echo "PR Build successful (worker, no buildx)"
echo "Fallback PR Build successful"
exit 0
fi
@@ -798,7 +831,6 @@ jobs:
fi
- name: Setup buildx builder
if: matrix.service != 'worker'
shell: sh
run: |
set -eu
@@ -811,64 +843,41 @@ jobs:
fi
docker buildx inspect --bootstrap
- name: Pre-build worker base image (fallback if not exist)
if: matrix.service == 'worker'
shell: sh
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
BASE_IMAGE="${REGISTRY}/saas-worker-base:latest"
echo "检查 Worker 基础镜像..."
if docker pull "$BASE_IMAGE" 2>/dev/null; then
echo "✅ 基础镜像已存在"
else
echo "⚠️ 基础镜像不存在,本地构建(fallback)..."
docker build -f infra/docker/worker-base.Dockerfile -t "$BASE_IMAGE" .
echo "✅ Worker 基础镜像本地构建完成"
fi
- name: Build and push ${{ matrix.service_display }} image
- name: Build and push ${{ matrix.service_display }} image (with retry)
shell: sh
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
if [ "${{ matrix.service }}" = "worker" ]; then
# Worker: plain docker build(基础镜像已预装全部依赖,无需 buildx)
echo "=== Worker: plain docker build ==="
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" --build-arg APP_VERSION="${GITHUB_SHA}" .
docker push "${IMAGE_TAG}"
echo "✅ Worker image pushed: ${IMAGE_TAG}"
else
# API/Web: buildx with registry cache
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
fi
NO_CACHE_FLAG=""
for i in 1 2 3; do
echo "=== Docker build 尝试 $i/3 ==="
if bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
echo "✅ Docker build 成功"
break
fi
echo "❌ Docker build 失败(尝试 $i/3)"
[ $i -eq 3 ] && exit 1
sleep 10
if [ $i -eq 2 ]; then
NO_CACHE_FLAG="--no-cache"
echo "下次重试将使用 --no-cache"
fi
done
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
fi
# Docker build 带重试:失败自动重试2次,第2次重试加--no-cache
NO_CACHE_FLAG=""
for i in 1 2 3; do
echo "=== Docker build 尝试 $i/3 ==="
if bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
echo "✅ Docker build 成功"
break
fi
echo "❌ Docker build 失败(尝试 $i/3)"
[ $i -eq 3 ] && exit 1
sleep 10
# 第2次重试使用 --no-cache
if [ $i -eq 2 ]; then
NO_CACHE_FLAG="--no-cache"
echo "下次重试将使用 --no-cache"
fi
done
echo
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
- name: Cleanup buildx builder
if: matrix.service != 'worker' && always()
if: always()
shell: sh
run: |
docker buildx rm ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} 2>/dev/null || true
+36 -27
View File
@@ -7,16 +7,26 @@ on:
- main
paths:
- 'requirements-base.txt'
- 'requirements.txt'
- 'requirements-worker.txt'
- 'infra/docker/worker-base.Dockerfile'
workflow_dispatch:
- 'infra/docker/worker-base-builder.Dockerfile'
- 'infra/docker/worker-base-runtime.Dockerfile'
workflow_dispatch: # 支持手动触发
jobs:
build-worker-base:
name: Build Worker Base Image
name: Build Worker Base Images
runs-on: runtime-builder
timeout-minutes: 45
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- name: builder
dockerfile: infra/docker/worker-base-builder.Dockerfile
image_name: worker-base-builder
- name: runtime
dockerfile: infra/docker/worker-base-runtime.Dockerfile
image_name: worker-base-runtime
steps:
- name: Checkout code
shell: sh
@@ -47,40 +57,39 @@ jobs:
sleep 5
done
- name: Build and push Worker base image
- name: Build and push base image
shell: sh
run: |
set -eu
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest"
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-worker-base:latest"
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
echo "=== Building Worker base image ==="
echo "=== Building ${{ matrix.name }} base image ==="
echo "Dockerfile: ${{ matrix.dockerfile }}"
echo "Image: ${IMAGE_TAG}"
# 使用普通 docker build(单平台不需要 buildx)
docker build \
-f infra/docker/worker-base.Dockerfile \
-t "${ACR_IMAGE}" \
-f "${{ matrix.dockerfile }}" \
-t "${IMAGE_TAG}" \
.
docker push "${IMAGE_TAG}"
# 同时推送到 Gitea Packages 作为备份
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest"
docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)"
echo ""
echo "✅ Image built successfully"
echo "✅ ${{ matrix.name }} base image built and pushed"
# 推送到 ACR
echo "=== Pushing to ACR ==="
docker push "${ACR_IMAGE}"
echo "✅ Pushed to ACR"
# 打标签并推送到 Gitea Packages 作为备份
echo "=== Pushing to Gitea Packages ==="
docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)"
echo "✅ Gitea backup push completed"
- name: Cleanup
- name: Cleanup local images
if: always()
shell: sh
run: |
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest"
docker rmi "${ACR_IMAGE}" 2>/dev/null || true
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
docker rmi "${REGISTRY}/${{ matrix.image_name }}:latest" 2>/dev/null || true
docker rmi "git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest" 2>/dev/null || true
docker image prune -f 2>/dev/null || true
echo "Cleanup done"
echo "Image cleanup done"
+6 -2
View File
@@ -214,8 +214,12 @@ test.describe("Core generation flow", () => {
await titleInput.fill(titleText)
await page.getByRole("button", { name: "下一步" }).click()
// Step 5: preview — 前端实时预览架构改造,无需后端生成预览
await expect(page.getByRole("heading", { name: /预览设置/ })).toBeVisible({ timeout: 15000 })
// Step 5: preview — 需要先生成预览视频,才能进入下一步
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
// 点击"生成预览"按钮触发预览生成
await page.locator(".xx-preview-generate-btn").click()
// 等待预览生成完成(后端渲染,可能需要较长时间)
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
-10
View File
@@ -12,7 +12,6 @@
"@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0",
"axios": "^1.7.2",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0",
@@ -4624,15 +4623,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/mp4box": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/mp4box/-/mp4box-2.4.1.tgz",
"integrity": "sha512-0HGX7nXoDIX6FKLVl4a3wtYjBlwqsN3xuQC3GXzNtKp98FXUOhDSq623azsz8DG5ptd9ZXcXodDkgbdMZOjWvw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=20.8.1"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
-1
View File
@@ -23,7 +23,6 @@
"@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0",
"axios": "^1.7.2",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0",
+8 -4
View File
@@ -23,9 +23,13 @@ export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post<GenerateCoverResponse>("/generation/generate-cover", data, {
timeout: 300000,
params: { template_id: templateId },
})
const response = await apiClient.post<GenerateCoverResponse>(
"/generation/generate-cover",
{ ...data, template_id: templateId },
{
timeout: 300000,
params: { template_id: templateId },
},
)
return response.data
}
@@ -18,10 +18,6 @@ import { useCloneProgress } from "@/hooks/useCloneProgress"
import { getAssetsByKind } from "@/api/assets"
import CloneModal from "@/components/voice/CloneModal"
import GenerateHeader from "./components/GenerateHeader"
import {
calculateTotalVideoDuration,
estimateTotalVideoDuration,
} from "./utils/calculateTotalVideoDuration"
import GenerateStepsBar from "./components/GenerateStepsBar"
import GenerateResultPanel from "./components/GenerateResultPanel"
import PreviewVideoPanel from "./components/PreviewVideoPanel"
@@ -111,14 +107,6 @@ const GeneratePage: React.FC = () => {
[userTemplates, selectedTemplate],
)
/* ── 视频总时长计算(用于配音时长校验) ── */
const totalVideoDuration = useMemo(() => {
// 优先用素材精确时长;素材未加载时用模板 segments 的 duration_max 之和估算
const exact = calculateTotalVideoDuration(previewAssets, currentTemplate ?? undefined)
if (exact > 0) return exact
return estimateTotalVideoDuration(currentTemplate ?? undefined)
}, [previewAssets, currentTemplate])
/* ── 配音音频 URL ── */
const { data: voiceMaterials = [] } = useQuery({
queryKey: ["assets", "voice"],
@@ -219,7 +207,6 @@ const GeneratePage: React.FC = () => {
duration={duration}
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
totalVideoDuration={totalVideoDuration}
voiceMode={voiceMode}
onVoiceModeChange={setVoiceMode}
selectedClonedVoice={selectedClonedVoice}
@@ -287,7 +274,6 @@ const GeneratePage: React.FC = () => {
{/* ── 视频预览弹窗 ── */}
<Modal
className="xx-preview-modal"
open={previewModalOpen}
onCancel={() => setPreviewModalOpen(false)}
footer={null}
@@ -1,43 +1,31 @@
/**
* 前端预览播放器 — Canvas + WebCodecs 方案
* 前端预览播放器
* 用原生 <video> 标签按时间线播放素材片段
* 替代后端 FFmpeg 渲染预览,实现真正的实时预览
*
* 架构:
* - 浏览器支持 WebCodecs → Canvas 渲染(帧级精确控制 + 标题合成)
* - 浏览器不支持 → fallback 到多 video 元素方案
*
* 对外 API 不变:assets, template, videoRatio, ready, voiceAudioUrl
* 注意:本组件不创建 .xx-preview-video 容器(由父组件 PreviewVideoPanel 提供)
* 避免嵌套 .xx-preview-video 导致 CSS 冲突
*/
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
import {
PlayCircleOutlined,
PauseCircleOutlined,
SoundOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import { PlayCircleOutlined, PauseCircleOutlined, SoundOutlined } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
import { useCanvasPlayer, isWebCodecsSupported } from "../hooks/useCanvasPlayer"
interface FrontendPreviewPlayerProps {
/** 选中的素材列表 */
assets: AssetItem[]
/** 当前模板(用于获取片段时长配置) */
template: EditingTemplate | null
/** 视频比例 */
videoRatio: string
/** 是否准备好播放(素材已加载) */
ready: boolean
/** 配音音频 URL */
voiceAudioUrl?: string
titleSettings?: {
title: string
size: number
font: string
color: string
position: "top" | "center" | "bottom"
bold?: boolean
italic?: boolean
stroke?: boolean
shadow?: boolean
}
}
/** 格式化时间 mm:ss */
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
@@ -45,7 +33,8 @@ function formatTime(seconds: number): string {
}
/**
* 将素材映射为播放片段(复用原逻辑)
* 将素材映射为播放片段
* 每个素材对应一个模板片段,按顺序分配
*/
function buildPlaybackSegments(
assets: AssetItem[],
@@ -65,9 +54,20 @@ function buildPlaybackSegments(
const startTime = 0
const endTime = Math.min(startTime + segDuration, assetDuration)
const videoUrl = asset.file_url || asset.storage_key
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: i })
console.log(
`[buildPlaybackSegments] 片段 ${i}: assetId=${asset.id}, videoUrl=${videoUrl?.substring(0, 80)}, file_url=${!!asset.file_url}`,
)
segments.push({
assetId: asset.id,
videoUrl,
startTime,
endTime,
order: i,
})
})
return segments
@@ -79,70 +79,28 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
videoRatio: _videoRatio,
ready,
voiceAudioUrl,
titleSettings,
}) => {
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
const useWebCodecs = isWebCodecsSupported()
// ── 两条路径共用同一个 canvas ref(fallback 路径不使用) ──
const canvasRef = useRef<HTMLCanvasElement>(null)
// ── Canvas 播放器(WebCodecs 路径) ──
const canvasTitle = titleSettings
? {
text: titleSettings.title || "标题预览",
fontSize: titleSettings.size,
fontFamily: titleSettings.font || "思源黑体",
color: titleSettings.color || "#ffffff",
position: titleSettings.position || "bottom",
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
}
: undefined
const canvasSegments = useMemo(
() =>
segments.map((s) => ({
assetId: s.assetId,
videoUrl: s.videoUrl,
startTime: s.startTime,
endTime: s.endTime,
})),
[segments],
)
const { state: canvasState, controls: canvasControls } = useCanvasPlayer(
canvasRef,
canvasSegments,
useWebCodecs ? canvasTitle : undefined,
)
// ── Video 播放器(fallback 路径) ──
const {
isPlaying: videoIsPlaying,
currentTime: videoCurrentTime,
totalDuration: videoTotalDuration,
currentSegmentIndex: videoCurrentSegIdx,
canPlay: videoCanPlay,
togglePlayPause: videoTogglePlayPause,
seekTo: videoSeekTo,
videoRefs,
isPlaying,
currentTime,
totalDuration,
currentSegmentIndex,
canPlay,
togglePlayPause,
seekTo,
videoRef,
} = useSegmentScheduler(segments)
// 选择哪条路径的状态
const isPlaying = useWebCodecs ? canvasState.isPlaying : videoIsPlaying
const currentTime = useWebCodecs ? canvasState.currentTime : videoCurrentTime
const totalDuration = useWebCodecs ? canvasState.duration : videoTotalDuration
const canPlay = useWebCodecs ? canvasState.isReady : videoCanPlay
const isBuffering = useWebCodecs ? canvasState.isBuffering : false
// ── 配音音频同步 ──
const audioRef = useRef<HTMLAudioElement | null>(null)
const prevIsPlayingRef = useRef(false)
// 创建/更新 Audio 元素
useEffect(() => {
if (!voiceAudioUrl) {
// 没有配音,清理已有 audio
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ""
@@ -150,6 +108,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
}
return
}
if (!audioRef.current) {
audioRef.current = new Audio()
audioRef.current.preload = "auto"
@@ -159,54 +118,53 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
}
}, [voiceAudioUrl])
// 同步播放状态
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src) return
if (isPlaying && !prevIsPlayingRef.current) {
// 刚进入播放
audio.currentTime = currentTime
audio.play().catch(() => {})
} else if (!isPlaying && prevIsPlayingRef.current) {
// 刚暂停
audio.pause()
}
prevIsPlayingRef.current = isPlaying
}, [isPlaying, currentTime])
// 片段切换时同步音频(仅 fallback 路径需要)
const segmentSyncKey = useWebCodecs ? -1 : videoCurrentSegIdx
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src || !isPlaying) return
audio.currentTime = currentTime
}, [segmentSyncKey, isPlaying, currentTime])
// seek 时同步音频
const handleSeekTo = useCallback(
(time: number) => {
if (useWebCodecs) {
canvasControls.seek(time)
} else {
videoSeekTo(time)
}
seekTo(time)
const audio = audioRef.current
if (audio && audio.src) {
audio.currentTime = time
}
},
[useWebCodecs, canvasControls, videoSeekTo],
[seekTo],
)
const handleTogglePlay = useCallback(() => {
if (useWebCodecs) {
if (canvasState.isPlaying) {
canvasControls.pause()
} else {
canvasControls.play()
}
} else {
videoTogglePlayPause()
// 播放结束时暂停音频
useEffect(() => {
if (!isPlaying) {
const audio = audioRef.current
if (audio) audio.pause()
}
}, [useWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
}, [isPlaying])
// ── 进度条拖拽 ──
// 清理
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.src = ""
}
}
}, [])
// 进度条拖拽
const [isDragging, setIsDragging] = useState(false)
const progressRef = useRef<HTMLDivElement>(null)
@@ -247,26 +205,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
// ── Canvas ResizeObserver ──
const canvasContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const container = canvasContainerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
if (width > 0 && height > 0) {
canvas.width = width * window.devicePixelRatio
canvas.height = height * window.devicePixelRatio
}
}
})
ro.observe(container)
return () => ro.disconnect()
}, [])
// ── 未就绪 ──
// 未就绪状态
if (!ready || !assets.length) {
return (
<div
@@ -288,7 +227,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
)
}
// ── 无播放片段 ──
// 无播放片段
if (!canPlay) {
return (
<div
@@ -303,81 +242,39 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
zIndex: 1,
}}
>
{isBuffering ? (
<>
<LoadingOutlined style={{ fontSize: 48, color: "#fff", marginBottom: 12 }} spin />
<p style={{ color: "rgba(255,255,255,0.8)" }}>加载中...</p>
</>
) : (
<>
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
<p className="xx-preview-empty-title">暂无可播放素材</p>
<p className="xx-preview-empty-desc">请先在左侧选择素材</p>
</>
)}
<PlayCircleOutlined
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
/>
<p className="xx-preview-empty-title">暂无可播放素材</p>
<p className="xx-preview-empty-desc">请先在左侧选择素材</p>
</div>
)
}
return (
<>
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
{useWebCodecs && (
<div
ref={canvasContainerRef}
style={{
position: "absolute",
inset: 0,
zIndex: 1,
background: "#000",
}}
>
<canvas
ref={canvasRef}
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
</div>
)}
{/* ✅ 不再创建 .xx-preview-video 容器,直接渲染 video + 覆盖层 */}
{/* video 元素 — 由父组件 .xx-preview-video 容器提供定位和尺寸 */}
<video
ref={videoRef}
preload="auto"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "#000",
zIndex: 1,
}}
playsInline
/>
{/* ── Video 渲染层(fallback 路径) ── */}
{!useWebCodecs &&
segments.map((seg, i) => (
<video
key={seg.assetId}
muted
ref={(el) => {
videoRefs.current[i] = el
}}
preload={
i === videoCurrentSegIdx ? "auto" : i === videoCurrentSegIdx + 1 ? "metadata" : "none"
}
src={seg.videoUrl}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
background: "#000",
zIndex: 1,
opacity: i === videoCurrentSegIdx ? 1 : 0,
pointerEvents: i === videoCurrentSegIdx ? "auto" : "none",
}}
playsInline
/>
))}
{/* 播放按钮 */}
{/* 播放按钮覆盖层 */}
{!isPlaying && (
<button
className="xx-preview-play-btn"
onClick={handleTogglePlay}
onClick={togglePlayPause}
style={{
position: "absolute",
top: "50%",
@@ -395,6 +292,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
color: "#fff",
fontSize: 28,
zIndex: 10,
transition: "opacity 0.2s",
}}
>
<PlayCircleOutlined />
@@ -415,10 +313,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
zIndex: 10,
}}
>
{useWebCodecs ? "Canvas" : `片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
片段 {currentSegmentIndex + 1}/{segments.length}
</div>
{/* 控制条 */}
{/* 控制条 — 绝对定位在底部 */}
<div
className="xx-preview-controls"
style={{
@@ -434,8 +332,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
zIndex: 10,
}}
>
{/* 播放/暂停 */}
<button
onClick={handleTogglePlay}
onClick={togglePlayPause}
style={{
background: "none",
border: "none",
@@ -450,6 +349,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
{/* 时间 */}
<span
style={{
fontSize: 12,
@@ -461,6 +361,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
{formatTime(currentTime)} / {formatTime(totalDuration)}
</span>
{/* 进度条 */}
<div
ref={progressRef}
onMouseDown={handleMouseDown}
@@ -54,7 +54,6 @@ export interface GenerateStepContentProps {
/* 配音 */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
totalVideoDuration?: number
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
@@ -107,7 +106,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
duration,
selectedVoice,
onSelectedVoiceChange,
totalVideoDuration,
voiceMode,
selectedClonedVoice,
clonedVoices,
@@ -148,7 +146,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
<Step3VoiceSelect
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
totalVideoDuration={totalVideoDuration}
/>
)
case 4:
@@ -11,7 +11,7 @@
* FrontendPreviewPlayer 的内容通过 absolute 定位填充容器
* TitleOverlay 通过 absolute 定位 + z-index: 30 覆盖在最上层
*/
import React, { useMemo, useRef, useState, useEffect } from "react"
import React, { useMemo } from "react"
import { LoadingOutlined } from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
@@ -79,16 +79,12 @@ function getPositionStyle(position: string): React.CSSProperties {
* 构建 CSS 标题层的样式
* 所有渲染参数与后端 FFmpeg ASS 字幕一致
*/
function buildTitleStyle(settings: TitleSettings, containerHeight: number): React.CSSProperties {
// 用 px 计算 fontSize,不再依赖父元素 font-size 的百分比
const fontSizePx =
containerHeight > 0
? (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * containerHeight
: (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400 // fallback
function buildTitleStyle(settings: TitleSettings): React.CSSProperties {
const fontSizePercent = (Math.min(settings.size, 36) / ASS_VIDEO_HEIGHT) * 100
const base: React.CSSProperties = {
fontFamily: settings.font || "思源黑体",
fontSize: `${fontSizePx}px`,
fontSize: `${fontSizePercent}%`,
color: settings.color || "#ffffff",
fontWeight: settings.bold ? 700 : 400,
fontStyle: settings.italic ? "italic" : "normal",
@@ -117,35 +113,15 @@ function buildTitleStyle(settings: TitleSettings, containerHeight: number): Reac
* z-index: 20(在视频 z-index:1 和控制条 z-index:10 之上)
*/
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => {
const containerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(400) // fallback
// ResizeObserver 获取容器实际高度
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const h = entry.contentRect.height
if (h > 0) setContainerHeight(h)
}
})
ro.observe(el)
// 初始化也读一次
const rect = el.getBoundingClientRect()
if (rect.height > 0) setContainerHeight(rect.height)
return () => ro.disconnect()
}, [])
const positionStyle = useMemo(
() => getPositionStyle(titleSettings.position),
[titleSettings.position],
)
// eslint-disable-next-line react-hooks/exhaustive-deps
const titleStyle = useMemo(
() => buildTitleStyle(titleSettings, containerHeight),
// eslint-disable-next-line react-hooks/exhaustive-deps -- 已逐字段列出 titleSettings 依赖
() => buildTitleStyle(titleSettings),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
containerHeight,
titleSettings.font,
titleSettings.size,
titleSettings.color,
@@ -156,11 +132,11 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
],
)
// 标题为空时显示占位文本
const displayTitle = titleSettings.title?.trim() || "标题预览"
return (
<div
ref={containerRef}
style={{
position: "absolute",
inset: 0,
@@ -5,15 +5,13 @@
import React, { useState, useRef, useCallback } from "react"
import { useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { AudioOutlined, SoundOutlined, WarningOutlined } from "@ant-design/icons"
import { Modal } from "antd"
import { AudioOutlined, SoundOutlined } from "@ant-design/icons"
import { getAssetsByKind } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
interface Step5VoiceSelectProps {
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
totalVideoDuration?: number
}
/** 格式化时长 mm:ss */
@@ -36,13 +34,10 @@ const formatFileSize = (bytes?: number): string => {
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
selectedVoice,
onSelectedVoiceChange,
totalVideoDuration = 0,
}) => {
const navigate = useNavigate()
const [playingId, setPlayingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const [durationWarningOpen, setDurationWarningOpen] = useState(false)
const [pendingVoiceId, setPendingVoiceId] = useState<string | null>(null)
// 获取用户上传的配音素材
const { data: materials = [], isLoading } = useQuery({
@@ -83,38 +78,14 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
[playingId],
)
/** 选中素材(含时长校验) */
/** 选中素材 */
const handleSelect = useCallback(
(id: string) => {
// 如果启用了时长校验,且配音时长不足
if (totalVideoDuration > 0) {
const material = materials.find((m) => m.id === id)
if (material && (material.duration || 0) < totalVideoDuration) {
setPendingVoiceId(id)
setDurationWarningOpen(true)
return
}
}
onSelectedVoiceChange(id)
},
[onSelectedVoiceChange, totalVideoDuration, materials],
[onSelectedVoiceChange],
)
/** 确认使用时长不足的配音 */
const handleConfirmUseAnyway = useCallback(() => {
if (pendingVoiceId) {
onSelectedVoiceChange(pendingVoiceId)
}
setDurationWarningOpen(false)
setPendingVoiceId(null)
}, [pendingVoiceId, onSelectedVoiceChange])
/** 取消选择 */
const handleCancelSelection = useCallback(() => {
setDurationWarningOpen(false)
setPendingVoiceId(null)
}, [])
/** 跳转到配音库上传 */
const handleGoToUpload = useCallback(() => {
navigate("/app/voices")
@@ -267,65 +238,15 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
justifyContent: "space-between",
fontSize: 12,
color: "#999",
alignItems: "center",
}}
>
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
{formatDuration(item.duration)}
{totalVideoDuration > 0 &&
(Number(item.duration) || 0) < Number(totalVideoDuration) && (
<span
style={{
color: "#ff4d4f",
fontSize: 11,
fontWeight: 500,
display: "inline-flex",
alignItems: "center",
gap: 2,
}}
>
<WarningOutlined />
时长不足
</span>
)}
</span>
<span>{formatDuration(item.duration)}</span>
<span>{formatFileSize(item.file_size)}</span>
</div>
</div>
)
})}
</div>
{/* 时长不足警告弹窗 */}
<Modal
title={
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<WarningOutlined style={{ color: "#faad14" }} />
配音时长不足
</span>
}
open={durationWarningOpen}
onOk={handleConfirmUseAnyway}
onCancel={handleCancelSelection}
okText="仍要使用"
cancelText="重新选择"
okButtonProps={{ danger: true }}
>
{(() => {
const pendingMaterial = pendingVoiceId
? materials.find((m) => m.id === pendingVoiceId)
: null
return (
<p>
该配音时长(
<strong>{pendingMaterial ? formatDuration(pendingMaterial.duration) : "--"}</strong>
)短于视频总时长(
<strong>{formatDuration(totalVideoDuration)}</strong>
),播放时配音可能提前结束,建议选择更长的配音素材。
</p>
)
})()}
</Modal>
</div>
)
}
+4 -3
View File
@@ -895,12 +895,13 @@
max-height: 400px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
display: grid;
place-items: center;
color: var(--text-inverse);
font-size: 36px;
position: relative;
overflow: hidden;
margin-bottom: 16px;
isolation: isolate;
}
.xx-preview-video::before {
@@ -2881,11 +2882,11 @@
overflow: hidden;
}
.xx-preview-modal .ant-modal-content {
.ant-modal-content {
padding: 0 !important;
}
.xx-preview-modal .ant-modal-close {
.ant-modal-close {
color: #fff !important;
}
@@ -1,768 +0,0 @@
/**
* Canvas + WebCodecs 播放器核心 Hook
* MP4 → mp4box.js 解封装 → VideoDecoder 解码帧 → Canvas 绘制
*
* 浏览器不支持 WebCodecs 时返回 hasSupport=false,由调用方 fallback
*/
import { useRef, useCallback, useEffect, useState } from "react"
import { createFile } from "mp4box"
import type { Movie, Sample } from "mp4box"
// ── MP4 Box 解析辅助函数 ──
/** 在指定范围内查找 avcC / hvcC box,返回其数据 */
function findCodecConfig(buffer: ArrayBuffer, start: number, end: number): ArrayBuffer | undefined {
const view = new DataView(buffer)
let offset = start
while (offset < end - 8) {
const size = view.getUint32(offset)
if (size < 8) break
const type = String.fromCharCode(
view.getUint8(offset + 4),
view.getUint8(offset + 5),
view.getUint8(offset + 6),
view.getUint8(offset + 7),
)
// 容器 box(fullbox 多 4 字节)
const containerBoxes = ["trak", "mdia", "minf", "stbl"]
if (containerBoxes.includes(type)) {
// fullbox: size(4) + type(4) + version(1) + flags(3) = 12 bytes header
const contentStart = offset + 12
const result = findCodecConfig(buffer, contentStart, offset + size)
if (result) return result
} else if (type === "stsd") {
// SampleDescriptionBox 是 fullbox: 8 header + 4 version/flags + 4 entry_count
const entryCount = view.getUint32(offset + 12)
let entryOffset = offset + 16
for (let i = 0; i < entryCount && entryOffset < offset + size; i++) {
const entrySize = view.getUint32(entryOffset)
// 视觉样本条目: 8 header + 6 reserved + 2 data_ref_index + remaining
// 子 box 从 entryOffset + 16 + 62 开始 (skip reserved + data_ref_index + predefined)
// 实际结构: 8(header) + 6(reserved) + 2(data_ref_index) + 16(predefined+reserved) + 2(width) + 2(height) + ...
// 子 box 从 entryOffset + 8 + 6 + 2 + 16 + 2 + 2 + 2 + 2 + 4 + 2 + 2 + 2 + 2 = entryOffset + 78
// 更简单的做法:扫描 entry 内的子 box
const entryEnd = entryOffset + entrySize
const subBoxStart = entryOffset + 8 + 70 // VisualSampleEntry 固定字段共 70 字节
const result = findCodecConfig(buffer, subBoxStart, entryEnd)
if (result) return result
entryOffset += entrySize
}
} else if (type === "avcC" || type === "hvcC") {
// 找到目标 box,返回完整 box(含 header)
// 返回完整 box(含 size + type header),WebCodecs HEVC decoder 需要
return buffer.slice(offset, offset + size)
}
if (size === 0) break
offset += size
}
return undefined
}
// ── 帧队列(环形缓冲区) ──
interface FrameEntry {
frame: VideoFrame
pts: number // 全局时间戳(秒),已按片段偏移对齐
duration: number // 帧持续时长(秒)
}
class FrameQueue {
private frames: FrameEntry[] = []
private maxSize: number
constructor(maxSize = 5) {
this.maxSize = maxSize
}
push(entry: FrameEntry) {
while (this.frames.length >= this.maxSize) {
const old = this.frames.shift()
old?.frame.close()
}
this.frames.push(entry)
}
/** 获取当前时间戳应显示的帧 */
getCurrentFrame(timestamp: number): VideoFrame | null {
let best: FrameEntry | null = null
let bestIdx = -1
for (let i = 0; i < this.frames.length; i++) {
const f = this.frames[i]
if (f.pts <= timestamp + 0.01) {
best = f
bestIdx = i
}
}
for (let i = 0; i < bestIdx; i++) {
this.frames[i].frame.close()
}
if (bestIdx >= 0) {
this.frames = this.frames.slice(bestIdx)
}
return best?.frame ?? null
}
clear() {
for (const f of this.frames) {
f.frame.close()
}
this.frames = []
}
get size() {
return this.frames.length
}
}
// ── 片段元数据 ──
interface SegmentMeta {
assetId: string
videoUrl: string
/** 该片段在全局时间轴上的起始时间(秒) */
globalStartTime: number
/** 该片段在全局时间轴上的结束时间(秒) */
globalEndTime: number
/** 视频轨道 ID */
trackId: number
/** 视频轨道 timescale */
timescale: number
/** 编解码器 */
codec: string
/** 视频宽度(像素) */
videoWidth: number
/** 视频高度(像素) */
videoHeight: number
/** 解码器配置数据(HEVC hvcC / H.264 avcC),WebCodecs 必需 */
description?: ArrayBuffer
/** 前端提取的样本数据(已按时间范围过滤,从关键帧开始) */
samples: Sample[]
}
// ── 播放器状态 ──
export interface CanvasPlayerState {
hasSupport: boolean
isPlaying: boolean
currentTime: number
duration: number
isReady: boolean
isBuffering: boolean
}
export interface CanvasPlayerControls {
play: () => void
pause: () => void
seek: (time: number) => void
destroy: () => void
}
interface SegmentSource {
assetId: string
videoUrl: string
startTime: number
endTime: number
}
/** 检测浏览器是否支持 WebCodecs VideoDecoder */
export function isWebCodecsSupported(): boolean {
return typeof window !== "undefined" && "VideoDecoder" in window && "VideoFrame" in window
}
/**
* useCanvasPlayer — Canvas + WebCodecs 播放器核心
*/
export function useCanvasPlayer(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
segments: SegmentSource[],
titleSettings?: {
text: string
fontSize: number
fontFamily: string
color: string
position: "top" | "center" | "bottom"
bold?: boolean
stroke?: boolean
shadow?: boolean
},
) {
const [state, setState] = useState<CanvasPlayerState>({
hasSupport: isWebCodecsSupported(),
isPlaying: false,
currentTime: 0,
duration: 0,
isReady: false,
isBuffering: false,
})
// ── 内部引用 ──
const decoderRef = useRef<VideoDecoder | null>(null)
const frameQueueRef = useRef(new FrameQueue(10))
const rafRef = useRef<number>(0)
const playStartRef = useRef<number>(0)
const playStartOffsetRef = useRef<number>(0)
const segmentDataRef = useRef<Map<string, ArrayBuffer>>(new Map())
const segmentMetaRef = useRef<SegmentMeta[]>([])
const videoDimRef = useRef<{ width: number; height: number }>({ width: 0, height: 0 })
const isDestroyedRef = useRef(false)
const lastProgressUpdateRef = useRef<number>(0)
const descriptionCache = useRef<Map<string, ArrayBuffer>>(new Map())
// 计算总时长
const totalDuration = segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0)
// ── 加载 MP4 文件数据 ──
const loadSegment = useCallback(async (segment: SegmentSource): Promise<void> => {
if (isDestroyedRef.current) return
if (segmentDataRef.current.has(segment.assetId)) return
setState((s) => ({ ...s, isBuffering: true }))
try {
const resp = await fetch(segment.videoUrl)
const buffer = await resp.arrayBuffer()
segmentDataRef.current.set(segment.assetId, buffer)
} catch (err) {
console.error("[useCanvasPlayer] Failed to fetch segment:", err)
} finally {
setState((s) => ({ ...s, isBuffering: false }))
}
}, [])
// ── 从 MP4 buffer 提取编解码器配置数据(avcC / hvcC) ──
// WebCodecs VideoDecoder 对 HEVC/H.265 必须提供 description 字段
const extractCodecDescription = useCallback((buffer: ArrayBuffer): ArrayBuffer | undefined => {
try {
const view = new DataView(buffer)
let offset = 0
// 查找 moov box
while (offset < buffer.byteLength - 8) {
const size = view.getUint32(offset)
const type = String.fromCharCode(
view.getUint8(offset + 4),
view.getUint8(offset + 5),
view.getUint8(offset + 6),
view.getUint8(offset + 7),
)
if (type === "moov") {
return findCodecConfig(buffer, offset + 8, offset + size)
}
if (size === 0) break
offset += size
}
} catch (e) {
console.warn("[useCanvasPlayer] extractCodecDescription failed:", e)
}
return undefined
}, [])
// ── 解封装单个片段,提取轨道元数据 + 按时间范围过滤样本 ──
// ✅ 关键修复:改为异步函数,等待 MP4Box.js 的 onSamples 回调完成后再返回
const demuxSegment = useCallback(
async (buffer: ArrayBuffer, segIndex: number): Promise<SegmentMeta | null> => {
const segment = segments?.[segIndex]
if (!segment) {
console.warn("[useCanvasPlayer] No segment at index", segIndex)
return null
}
// 计算全局偏移
let globalStart = 0
for (let i = 0; i < segIndex; i++) {
globalStart += segments[i].endTime - segments[i].startTime
}
const mp4File = createFile()
return new Promise<SegmentMeta | null>((resolve) => {
let meta: SegmentMeta | null = null
let resolved = false
// ✅ 超时保护:5秒后如果 onSamples 没有触发,返回 null
const timeout = setTimeout(() => {
if (!resolved) {
console.error(
`[useCanvasPlayer] Timeout: onSamples not triggered for segment ${segIndex}`,
)
resolved = true
resolve(null)
}
}, 5000)
mp4File.onReady = (info: Movie) => {
const videoTrack = info?.videoTracks?.[0]
console.log("[useCanvasPlayer] demuxSegment:", {
segIndex,
startTime: segment.startTime,
endTime: segment.endTime,
nbSamples: videoTrack?.nb_samples,
codec: videoTrack?.codec,
videoWidth: videoTrack?.track_width,
videoHeight: videoTrack?.track_height,
})
if (!videoTrack) {
console.warn("[useCanvasPlayer] No video track found for segment", segIndex)
clearTimeout(timeout)
resolved = true
resolve(null)
return
}
// 提取编解码器配置数据(HEVC 必需,H.264 也需要)
let description = extractCodecDescription(buffer)
// 如果当前分片没有 description,尝试从缓存获取
if (!description) {
for (const cached of descriptionCache.current.values()) {
description = cached
break
}
}
// ✅ 如果 description 缺失,无法解码 HEVC
if (!description) {
console.error(
`[useCanvasPlayer] No description found for segment ${segIndex}, cannot decode HEVC`,
)
clearTimeout(timeout)
resolved = true
resolve(null)
return
}
// 缓存 description 供后续分片使用
descriptionCache.current.set(segment.assetId, description)
meta = {
assetId: segment.assetId,
videoUrl: segment.videoUrl,
globalStartTime: globalStart,
globalEndTime: globalStart + (segment.endTime - segment.startTime),
trackId: videoTrack.id ?? 1,
timescale: videoTrack.timescale ?? 90000,
codec: videoTrack.codec ?? "avc1.42E01E",
videoWidth: videoTrack.track_width || 1280,
videoHeight: videoTrack.track_height || 720,
description,
samples: [],
}
// 提取所有 samples
mp4File.setExtractionOptions(videoTrack.id ?? 1, null, {
nbSamples: Infinity, // 提取所有 sample
})
mp4File.start()
}
mp4File.onSamples = (_trackId: number, _user: unknown, samples: Sample[]) => {
if (resolved) return // ✅ 防止重复 resolve
if (!meta) {
clearTimeout(timeout)
resolved = true
resolve(null)
return
}
// 前端切片:按 [startTime, endTime] 时间范围过滤样本
const timescale = meta.timescale
const startCts = segment.startTime * timescale
const endCts = segment.endTime * timescale
// 过滤出时间范围内的样本
let filtered = samples.filter((s) => (s?.cts ?? 0) >= startCts && (s?.cts ?? 0) < endCts)
// 确保从关键帧开始(跳过第一个 sync 之前的非关键帧)
let foundSync = false
filtered = filtered.filter((s) => {
if (s.is_sync) {
foundSync = true
return true
}
return foundSync
})
// Fallback:如果时间范围内没有样本,使用全部样本从第一个关键帧开始
if (filtered.length === 0) {
console.warn(
`[useCanvasPlayer] No samples in range [${segment.startTime}s, ${segment.endTime}s] for segment ${segIndex}, fallback to all from keyframe`,
)
let sync = false
filtered = samples.filter((s) => {
if (s.is_sync) {
sync = true
return true
}
return sync
})
}
meta.samples = filtered
console.log(
`[useCanvasPlayer] Segment ${segIndex}: ${filtered.length}/${samples.length} samples (range ${segment.startTime}s-${segment.endTime}s)`,
)
// ✅ 关键修复:等待 onSamples 完成后再返回
clearTimeout(timeout)
resolved = true
resolve(meta)
}
mp4File.onError = (_module: string, message: string) => {
console.error(`[useCanvasPlayer] MP4Box error: ${message}`)
clearTimeout(timeout)
resolved = true
resolve(null)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(buffer as any).fileStart = 0
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mp4File.appendBuffer(buffer as any)
})
},
[segments, extractCodecDescription],
)
// ── 初始化 VideoDecoder 并解码指定片段 ──
const decodeSegment = useCallback(
async (_buffer: ArrayBuffer, meta: SegmentMeta): Promise<void> => {
if (isDestroyedRef.current) return
let decoderReady = false
// 配置解码器(每个片段可能需要不同的 codec/分辨率)
const decoder = new VideoDecoder({
output: (frame: VideoFrame) => {
const localTime = frame.timestamp / 1_000_000
const globalTime = localTime + meta.globalStartTime
frameQueueRef.current.push({
frame,
pts: globalTime,
duration: (frame.duration ?? 0) / 1_000_000,
})
},
error: (e: DOMException) => {
console.error("[useCanvasPlayer] Decoder error:", e)
},
})
console.log("[useCanvasPlayer] configure:", {
codec: meta.codec,
description: meta.description,
descriptionByteLength: meta.description?.byteLength,
videoWidth: meta.videoWidth,
videoHeight: meta.videoHeight,
})
try {
await decoder.configure({
codec: meta.codec,
codedWidth: meta.videoWidth,
codedHeight: meta.videoHeight,
...(meta.description ? { description: meta.description } : {}),
})
decoderRef.current = decoder
decoderReady = true
// 更新视频尺寸(用于 aspect ratio)
if (meta.videoWidth > 0 && meta.videoHeight > 0) {
videoDimRef.current = { width: meta.videoWidth, height: meta.videoHeight }
}
} catch (err) {
console.error("[useCanvasPlayer] Decoder configure failed for segment:", err)
return
}
if (!decoderReady) return
// 使用 demuxSegment 中已提取并过滤的 samples(前端切片)
const samplesCollected = meta.samples
if (samplesCollected.length === 0) {
console.warn("[useCanvasPlayer] No samples to decode for segment", meta.assetId)
return
}
// 送入解码器
for (const sample of samplesCollected) {
if (!sample.data || isDestroyedRef.current) continue
if (decoder.state === "closed") break
if (!sample.data) continue
const chunk = new EncodedVideoChunk({
type: sample.is_sync ? "key" : "delta",
timestamp: ((sample.cts ?? 0) / (meta.timescale || 90000)) * 1_000_000,
duration: ((sample.duration ?? 0) / (meta.timescale || 90000)) * 1_000_000,
data: sample.data,
})
try {
decoder.decode(chunk)
} catch (e) {
console.warn("[useCanvasPlayer] Decode chunk error:", e)
}
}
// flush 确保所有帧输出
try {
await decoder.flush()
} catch (e) {
console.warn("[useCanvasPlayer] Decoder flush error:", e)
}
},
[],
)
// ── 标题绘制 ──
const drawTitle = useCallback(
(
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
title: NonNullable<typeof titleSettings>,
) => {
const fontSize = (title.fontSize / 720) * canvas.height
ctx.font = `${title.bold ? "bold" : "normal"} ${fontSize}px ${title.fontFamily}`
ctx.fillStyle = title.color
ctx.textAlign = "center"
// 按 "/" 分割为多行("/" 作为手动换行符)
const lines = title.text.split(/[//⁄∕]/)
console.log("[drawTitle] 原始标题:", JSON.stringify(title.text), "分割后:", lines)
const lineHeight = fontSize * 1.3
const totalHeight = lines.length * lineHeight
// 根据 position 计算第一行的 Y 坐标
let startY: number
switch (title.position) {
case "top":
startY = fontSize + canvas.height * 0.08
break
case "bottom":
startY = canvas.height - canvas.height * 0.08 - totalHeight + lineHeight
break
case "center":
default:
startY = (canvas.height - totalHeight) / 2 + lineHeight
break
}
if (title.shadow) {
ctx.shadowColor = "rgba(0,0,0,0.8)"
ctx.shadowBlur = 4
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
}
lines.forEach((line, idx) => {
const y = startY + idx * lineHeight
if (title.stroke) {
ctx.strokeStyle = "#000000"
ctx.lineWidth = 1
ctx.strokeText(line, canvas.width / 2, y)
}
ctx.fillText(line, canvas.width / 2, y)
})
ctx.shadowColor = "transparent"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
},
[],
)
// ── 计算保持宽高比的绘制矩形(letterbox / pillarbox) ──
const computeDrawRect = useCallback(
(canvasW: number, canvasH: number): { dx: number; dy: number; dw: number; dh: number } => {
const vw = videoDimRef.current.width
const vh = videoDimRef.current.height
if (vw <= 0 || vh <= 0) return { dx: 0, dy: 0, dw: canvasW, dh: canvasH }
const canvasAspect = canvasW / canvasH
const videoAspect = vw / vh
let dw: number, dh: number
if (canvasAspect > videoAspect) {
// canvas 更宽 → pillarbox(左右留黑)
dh = canvasH
dw = canvasH * videoAspect
} else {
// canvas 更高 → letterbox(上下留黑)
dw = canvasW
dh = canvasW / videoAspect
}
return {
dx: (canvasW - dw) / 2,
dy: (canvasH - dh) / 2,
dw,
dh,
}
},
[],
)
// ── Canvas 渲染循环 ──
const renderFrame = useCallback(() => {
if (isDestroyedRef.current) return
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const elapsed = (performance.now() - playStartRef.current) / 1000
const currentTime = Math.min(playStartOffsetRef.current + elapsed, totalDuration)
const frame = frameQueueRef.current.getCurrentFrame(currentTime)
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (frame) {
const rect = computeDrawRect(canvas.width, canvas.height)
ctx.drawImage(frame, rect.dx, rect.dy, rect.dw, rect.dh)
}
if (titleSettings?.text) {
drawTitle(ctx, canvas, titleSettings)
}
// 进度更新节流到 200ms(5fps),减少 React re-render
const now = performance.now()
if (now - lastProgressUpdateRef.current >= 200) {
lastProgressUpdateRef.current = now
setState((s) => {
if (Math.abs(s.currentTime - currentTime) > 0.01) {
return { ...s, currentTime }
}
return s
})
}
if (currentTime >= totalDuration) {
setState((s) => ({ ...s, isPlaying: false }))
return
}
rafRef.current = requestAnimationFrame(renderFrame)
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect])
// ── 播放控制 ──
const play = useCallback(async () => {
if (!state.hasSupport || isDestroyedRef.current) return
setState((s) => ({ ...s, isPlaying: true }))
playStartRef.current = performance.now()
playStartOffsetRef.current = state.currentTime
lastProgressUpdateRef.current = 0
rafRef.current = requestAnimationFrame(renderFrame)
}, [state.hasSupport, state.currentTime, renderFrame])
const pause = useCallback(() => {
setState((s) => ({ ...s, isPlaying: false }))
cancelAnimationFrame(rafRef.current)
}, [])
const seek = useCallback(
(time: number) => {
const clampedTime = Math.max(0, Math.min(time, totalDuration))
setState((s) => ({ ...s, currentTime: clampedTime }))
playStartOffsetRef.current = clampedTime
playStartRef.current = performance.now()
// seek 后清空帧队列,等待新帧解码
frameQueueRef.current.clear()
},
[totalDuration],
)
const destroy = useCallback(() => {
isDestroyedRef.current = true
cancelAnimationFrame(rafRef.current)
if (decoderRef.current && decoderRef.current.state !== "closed") {
decoderRef.current.close()
}
frameQueueRef.current.clear()
segmentDataRef.current.clear()
segmentMetaRef.current = []
descriptionCache.current.clear()
}, [])
// ── 预加载下一个片段的数据 ──
const preloadNext = useCallback(
async (currentIndex: number) => {
const nextIdx = currentIndex + 1
if (nextIdx >= segments.length) return
const next = segments[nextIdx]
if (segmentDataRef.current.has(next.assetId)) return
await loadSegment(next)
},
[segments, loadSegment],
)
// ── 初始化:加载并解码所有片段 ──
useEffect(() => {
if (!state.hasSupport || segments.length === 0) return
const init = async () => {
setState((s) => ({ ...s, isBuffering: true }))
// 1. 加载所有片段数据
for (const seg of segments) {
await loadSegment(seg)
}
if (isDestroyedRef.current) return
// 2. 解析每个片段的轨道元数据(await 等待 onSamples 回调完成)
const metas: SegmentMeta[] = []
for (let i = 0; i < segments.length; i++) {
const buffer = segmentDataRef.current.get(segments[i].assetId)
if (!buffer) continue
const meta = await demuxSegment(buffer, i)
if (meta) metas.push(meta)
}
if (isDestroyedRef.current || metas.length === 0) {
setState((s) => ({ ...s, isBuffering: false }))
return
}
segmentMetaRef.current = metas
// 3. 设置视频尺寸(用第一个片段的尺寸)
if (metas[0].videoWidth > 0 && metas[0].videoHeight > 0) {
videoDimRef.current = { width: metas[0].videoWidth, height: metas[0].videoHeight }
}
// 4. 依次解码每个片段
for (const meta of metas) {
const buffer = segmentDataRef.current.get(meta.assetId)
if (!buffer) continue
await decodeSegment(buffer, meta)
if (isDestroyedRef.current) break
}
setState((s) => ({ ...s, duration: totalDuration, isReady: true, isBuffering: false }))
}
init()
return () => {
destroy()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [segments, state.hasSupport])
return {
state: { ...state, duration: totalDuration },
controls: { play, pause, seek, destroy } satisfies CanvasPlayerControls,
preloadNext,
}
}
export default useCanvasPlayer
@@ -1,9 +1,9 @@
/**
* 素材片段调度器 Hook(多 video 元素方案 v2)
* 每个片段对应一个独立 <video> 元素,全部预加载,通过 display 切换实现无缝播放
* 替代单 video + 切 src 方案,消除片段切换延迟
* 素材片段调度器 Hook
* 控制原生 <video> 元素按时间线依次播放素材片段(入点→出点)
* 实现前端预览播放,替代后端 FFmpeg 渲染
*/
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"
/** 单个播放片段 */
export interface PlaybackSegment {
@@ -27,7 +27,7 @@ export interface SegmentSchedulerState {
currentTime: number
/** 总时长(秒) */
totalDuration: number
/** 当前片段索引 */
/** 当前片段索引(在 segments 数组中的位置) */
currentSegmentIndex: number
/** 当前片段的本地播放时间 */
segmentLocalTime: number
@@ -43,8 +43,8 @@ export interface SegmentSchedulerState {
togglePlayPause: () => void
/** 跳转到全局时间 */
seekTo: (time: number) => void
/** 每个片段对应的 video 元素 ref 数组 */
videoRefs: React.MutableRefObject<(HTMLVideoElement | null)[]>
/** 绑定到 <video> 元素 */
videoRef: React.RefObject<HTMLVideoElement>
}
/**
@@ -80,17 +80,10 @@ function buildTimeline(segments: PlaybackSegment[]): number[] {
}
/**
* useSegmentScheduler — 多 video 元素版素材片段调度器
*
* 核心改变:
* - 每个片段对应一个独立 <video> 元素(由组件渲染,ref 传入)
* - 所有 video 在挂载时即设置 src + preload="auto",浏览器自动预加载
* - 切换片段仅改 currentSegmentIndex + display,无需重新 load
* - 实现无缝切换,无加载延迟
* useSegmentScheduler — 素材片段调度器
*/
export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState {
/** 每个片段对应的 video 元素 ref(由组件 JSX 渲染并绑定) */
const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
const videoRef = useRef<HTMLVideoElement | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0)
@@ -98,6 +91,13 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const rafRef = useRef<number>(0)
const isSeekingRef = useRef(false)
// ✅ 关键修复:用 ref 标记是否已加载过片段(替代检查 video.src)
// video.src = "" 在浏览器中会被解析为页面 URL,导致 "video.src === ''" 永远为 false
const srcLoadedRef = useRef(false)
// 预加载用的隐藏 video 元素
const preloadVideoRef = useRef<HTMLVideoElement | null>(null)
// 计算时间线
const timelineStarts = useMemo(() => buildTimeline(segments), [segments])
const totalDuration = useMemo(
@@ -113,63 +113,122 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
? currentTime - (timelineStarts[currentSegmentIndex] || 0) + currentSegment.startTime
: 0
/**
* 切换到指定片段
* 不改变 src(video 已在 JSX 中设置),仅 seek + 等待可播
*/
/** 切换到指定片段,返回 Promise 在视频可播放后 resolve */
const switchToSegment = useCallback(
(index: number, seekToLocalTime?: number): Promise<void> => {
return new Promise((resolve) => {
// 暂停当前视频
const prevVideo = videoRefs.current[currentSegmentIndex]
if (prevVideo) prevVideo.pause()
const video = videoRefs.current[index]
const video = videoRef.current
if (!video || index >= segments.length) {
resolve()
return
}
const seg = segments[index]
const localTime = seekToLocalTime ?? seg.startTime
const videoUrl = seg.videoUrl
// 设置播放位置
video.currentTime = localTime
// 如果已有足够帧数据,直接 resolve
if (video.readyState >= 2) {
setCurrentSegmentIndex(index)
if (!videoUrl || videoUrl === "") {
console.error(
`[useSegmentScheduler] 片段 ${index} 的 videoUrl 为空!assetId: ${seg.assetId}`,
)
resolve()
return
}
// 等待 canplay 事件
const onCanPlay = () => {
// 检查是否是完整的 HTTP URL
if (!videoUrl.startsWith("http://") && !videoUrl.startsWith("https://")) {
console.warn(`[useSegmentScheduler] 片段 ${index} 的 videoUrl 不是完整 URL: ${videoUrl}`)
}
const localTime = seekToLocalTime ?? seg.startTime
// ✅ 关键修复:清除旧事件监听,防止重复绑定
const cleanup = () => {
video.removeEventListener("loadedmetadata", onLoaded)
video.removeEventListener("error", onError)
video.removeEventListener("canplay", onCanPlay)
clearTimeout(timeoutId)
setCurrentSegmentIndex(index)
}
const onLoaded = () => {
video.currentTime = localTime
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} loadedmetadata, src=${videoUrl.substring(0, 80)}`,
)
resolve()
}
// 10 秒超时保护
const timeoutId = setTimeout(() => {
video.removeEventListener("canplay", onCanPlay)
console.warn(
`[useSegmentScheduler] 片段 ${index} 预加载超时 (10s), readyState=${video.readyState}`,
const onCanPlay = () => {
video.currentTime = localTime
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} canplay, src=${videoUrl.substring(0, 80)}`,
)
setCurrentSegmentIndex(index)
resolve()
}
const onError = () => {
console.error(
`[useSegmentScheduler] 视频加载失败: ${videoUrl}`,
video.error?.message || `code=${video.error?.code}`,
)
srcLoadedRef.current = true // 标记为已尝试,避免死循环
cleanup()
resolve()
}
// ✅ 关键修复:10秒超时防止 Promise 永远不 resolve
const timeoutId = setTimeout(() => {
console.warn(
`[useSegmentScheduler] 片段 ${index} 加载超时 (10s), readyState=${video.readyState}`,
)
cleanup()
resolve()
}, 10000)
// 先绑定事件,再设置 src(避免错过已触发的loadedmetadata)
video.addEventListener("loadedmetadata", onLoaded)
video.addEventListener("canplay", onCanPlay)
video.addEventListener("error", onError)
// 设置 src 并显式调用 load()
video.src = videoUrl
video.load()
// ✅ 关键修复:如果 readyState >= 1(已有 metadata),说明加载已完成
// 直接设置 currentTime 并 resolve,不等待事件
if (video.readyState >= 1) {
video.currentTime = localTime
srcLoadedRef.current = true
cleanup()
console.log(
`[useSegmentScheduler] 片段 ${index} 已缓存(readyState=${video.readyState}), 直接播放`,
)
resolve()
return
}
setCurrentSegmentIndex(index)
// 预加载下一段
if (index + 1 < segments.length) {
const nextSeg = segments[index + 1]
if (!preloadVideoRef.current) {
preloadVideoRef.current = document.createElement("video")
preloadVideoRef.current.preload = "auto"
}
preloadVideoRef.current.src = nextSeg.videoUrl
}
})
},
[segments, currentSegmentIndex],
[segments],
)
/** 播放循环 — 检测片段边界并切换 */
const tick = useCallback(() => {
const video = videoRefs.current[currentSegmentIndex]
const video = videoRef.current
if (!video || isSeekingRef.current) {
rafRef.current = requestAnimationFrame(tick)
return
@@ -178,42 +237,29 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const seg = segments[currentSegmentIndex]
if (!seg) return
// 提前 2 秒预加载下一段
if (currentSegmentIndex + 1 < segments.length) {
const nextSeg = segments[currentSegmentIndex + 1]
if (!preloadVideoRef.current) {
preloadVideoRef.current = document.createElement("video")
preloadVideoRef.current.preload = "auto"
}
if (preloadVideoRef.current.src !== nextSeg.videoUrl) {
preloadVideoRef.current.src = nextSeg.videoUrl
preloadVideoRef.current.load()
}
}
// 检查是否到达出点(容差 0.15s)
if (video.currentTime >= seg.endTime - 0.15) {
video.pause()
const nextIndex = currentSegmentIndex + 1
if (nextIndex < segments.length) {
switchToSegment(nextIndex).then(() => {
setIsPlaying(true)
rafRef.current = requestAnimationFrame(tick)
const nextVideo = videoRefs.current[nextIndex]
if (nextVideo) {
const canPlay = () => {
nextVideo
.play()
.catch((e) =>
console.warn("[useSegmentScheduler] auto-play next segment failed:", e),
)
}
if (nextVideo.readyState >= 3) {
canPlay()
} else {
const timeout = setTimeout(canPlay, 300)
nextVideo.addEventListener(
"canplay",
() => {
clearTimeout(timeout)
canPlay()
},
{ once: true },
)
}
}
})
switchToSegment(nextIndex)
const accumulatedTime =
(timelineStarts[currentSegmentIndex] || 0) + (seg.endTime - seg.startTime)
setCurrentTime(accumulatedTime)
} else {
video.pause()
setIsPlaying(false)
setIsEnded(true)
setCurrentTime(totalDuration)
@@ -230,38 +276,52 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
/** 播放 */
const play = useCallback(async () => {
if (!canPlay) return
const video = videoRef.current
if (!video || !canPlay) {
console.log("[useSegmentScheduler] play: video=", !!video, "canPlay=", canPlay)
return
}
setIsEnded(false)
// 确保第一段可播放
const firstVideo = videoRefs.current[0]
if (firstVideo && currentSegmentIndex === 0 && firstVideo.readyState < 2) {
// ✅ 关键修复:用 ref 标记代替 video.src 检查
if (!srcLoadedRef.current) {
console.log("[useSegmentScheduler] 首次播放,加载片段 0...")
await switchToSegment(0)
}
const video = videoRefs.current[currentSegmentIndex]
if (!video) return
try {
const playPromise = video.play()
if (playPromise !== undefined) {
await playPromise
}
console.log("[useSegmentScheduler] 播放开始, src=", video.src?.substring(0, 80))
setIsPlaying(true)
rafRef.current = requestAnimationFrame(tick)
} catch (err) {
console.warn("[useSegmentScheduler] 播放失败:", err)
console.warn("[useSegmentScheduler] 播放失败:", err, "src=", video.src?.substring(0, 80))
// 如果播放失败,尝试重新加载片段
if (!srcLoadedRef.current) {
srcLoadedRef.current = false
await switchToSegment(0)
try {
await video.play()
setIsPlaying(true)
rafRef.current = requestAnimationFrame(tick)
} catch (retryErr) {
console.error("[useSegmentScheduler] 重试播放仍然失败:", retryErr)
}
}
}
}, [canPlay, switchToSegment, tick, currentSegmentIndex])
}, [canPlay, switchToSegment, tick])
/** 暂停 */
const pause = useCallback(() => {
const video = videoRefs.current[currentSegmentIndex]
const video = videoRef.current
if (video) video.pause()
setIsPlaying(false)
cancelAnimationFrame(rafRef.current)
}, [currentSegmentIndex])
}, [])
/** 切换播放/暂停 */
const togglePlayPause = useCallback(() => {
@@ -271,8 +331,9 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if (isEnded) {
// 播放结束后再次播放,从头开始
setIsEnded(false)
srcLoadedRef.current = false // ✅ 重置标记,强制重新加载
switchToSegment(0, segments[0]?.startTime).then(() => {
const video = videoRefs.current[0]
const video = videoRef.current
if (video) {
video.play().catch((e) => console.warn("[useSegmentScheduler] restart play failed:", e))
setIsPlaying(true)
@@ -289,7 +350,8 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
/** 跳转到指定全局时间 */
const seekTo = useCallback(
async (time: number) => {
if (!canPlay) return
const video = videoRef.current
if (!video || !canPlay) return
const clampedTime = Math.max(0, Math.min(time, totalDuration))
const { index, localTime } = findSegmentAtTime(segments, clampedTime)
@@ -299,11 +361,11 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if (index !== currentSegmentIndex) {
await switchToSegment(index, localTime)
} else {
const video = videoRefs.current[index]
if (video) video.currentTime = localTime
video.currentTime = localTime
}
setCurrentTime(clampedTime)
setCurrentSegmentIndex(index)
setIsEnded(false)
setTimeout(() => {
@@ -313,24 +375,14 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
[canPlay, totalDuration, segments, currentSegmentIndex, switchToSegment],
)
// 确保 videoRefs 数组长度与 segments 一致 + 强制预加载
useEffect(() => {
videoRefs.current = videoRefs.current.slice(0, segments.length)
while (videoRefs.current.length < segments.length) {
videoRefs.current.push(null)
}
// 强制预加载:所有 video 元素挂载后,调用 load() 确保浏览器真正开始加载数据
videoRefs.current.forEach((video) => {
if (video) {
video.load()
}
})
}, [segments])
// 组件卸载时清理
useEffect(() => {
return () => {
cancelAnimationFrame(rafRef.current)
if (preloadVideoRef.current) {
preloadVideoRef.current.src = ""
preloadVideoRef.current = null
}
}
}, [])
@@ -341,6 +393,13 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
setCurrentTime(0)
setCurrentSegmentIndex(0)
setIsEnded(false)
srcLoadedRef.current = false // ✅ 重置标记
const video = videoRef.current
if (video) {
video.pause()
video.removeAttribute("src")
video.load()
}
}, [segments])
return {
@@ -355,7 +414,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
pause,
togglePlayPause,
seekTo,
videoRefs,
videoRef,
}
}
@@ -6,7 +6,6 @@ import { useCallback, useEffect, useState } from "react"
import { message } from "antd"
import type { CoverConfig, CoverTemplate } from "../types/cover"
import { generateCover } from "@/api/generation"
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
import {
fetchCoverTemplates,
createCoverTemplate,
@@ -112,58 +111,8 @@ export function useStep6Cover({
// 提取详细错误信息
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyErr = err as any
const statusCode = anyErr?.response?.status
// 400 错误:后端缺少预览视频,自动创建后重试
if (statusCode === 400) {
console.log("[Step6] 后端返回 400,尝试自动创建预览渲染任务...")
message.info("正在准备预览视频,请稍候...")
try {
const previewResp = await createPreview({
template_id: selectedTemplate,
asset_ids: assetIds,
duration: duration || 30,
})
// 轮询等待预览渲染完成
await new Promise<void>((resolve, reject) => {
const poll = setInterval(async () => {
try {
const status = await getPreviewStatus(previewResp.task_id)
if (status.status === "completed") {
clearInterval(poll)
resolve()
} else if (status.status === "failed") {
clearInterval(poll)
reject(new Error(status.error_message || "预览渲染失败"))
}
} catch (e) {
clearInterval(poll)
reject(e)
}
}, 3000)
})
message.success("预览视频就绪,重新生成封面...")
// 重试封面生成
const retryResp = await generateCover(selectedTemplate, {
asset_ids: assetIds,
cover_type: "ai_frame",
})
const retryUrl = retryResp.cover?.image_url || ""
if (retryUrl) {
onCoverSettingsChange({
...coverSettings,
thumbnail_url: retryUrl,
ai_suggested_time: retryResp.cover?.frame_time ?? null,
})
message.success("封面生成成功")
} else {
message.warning("封面生成未返回图片,请重试")
}
} catch (retryErr) {
console.error("[Step6] 自动创建预览后重试失败:", retryErr)
message.error("预览视频创建失败,请稍后重试")
}
} else if (anyErr?.__msgShown) {
// 如果 API 拦截器已经弹出了后端返回的具体错误信息,这里跳过重复 toast
if (anyErr?.__msgShown) {
// 拦截器已处理,不再重复弹出
} else {
let errorMsg = "封面生成失败"
@@ -188,7 +137,7 @@ export function useStep6Cover({
clearTimeout(timeoutId)
setGenerating(false)
}
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating, duration])
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating])
// ── 模板操作方法 ──
const handleSelectTemplate = useCallback((id: string) => {
@@ -1,54 +0,0 @@
/**
* 共享:根据素材列表和模板片段计算总视频时长
* GeneratePage(配音校验)和 FrontendPreviewPlayer(播放控制)共用
*/
export interface DurationAsset {
id?: string
duration?: number
metadata?: { duration?: number }
}
export interface DurationTemplateSegment {
duration_min?: number
duration_max?: number
}
/**
* 计算总视频时长
* @param assets 素材列表
* @param template 模板(含 segments)
* @returns 总时长(秒),无有效数据时返回 0
*/
export function calculateTotalVideoDuration(
assets: DurationAsset[] | undefined,
template: { segments?: DurationTemplateSegment[] } | undefined,
): number {
if (!assets || assets.length === 0 || !template) return 0
const templateSegments = template.segments || []
return assets.reduce((sum, asset, i) => {
const assetDuration = asset.duration || asset.metadata?.duration || 30
const tplSeg = templateSegments[i] || templateSegments[templateSegments.length - 1]
const segDuration = tplSeg
? Math.min(
tplSeg.duration_max ?? assetDuration,
Math.max(tplSeg.duration_min ?? 0, assetDuration),
)
: Math.min(assetDuration, 10)
return sum + segDuration
}, 0)
}
/**
* 估算总视频时长(仅依赖模板 segments)
* 当素材未加载或加载失败时,用各片段 duration_max 之和作为估算值
* 确保配音时长校验不会因素材未就绪而跳过
*/
export function estimateTotalVideoDuration(
template: { segments?: DurationTemplateSegment[] } | undefined,
): number {
if (!template?.segments || template.segments.length === 0) return 0
return template.segments.reduce((sum, seg) => sum + (seg.duration_max || 0), 0)
}
-49
View File
@@ -1,49 +0,0 @@
# ============================================================
# Worker 统一基础镜像(预构建)
# 预装系统依赖 + Python 全部依赖 + CJK 字体
# 业务构建从此镜像开始,只需 COPY 业务代码,构建时间 < 5 分钟
# 当 requirements-*.txt 变更时重新构建
# ============================================================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 预装系统依赖(编译工具 + 运行时 + CJK 字体用于 ASS 字幕渲染)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
python3-dev \
binutils \
ffmpeg \
libglib2.0-0 \
fonts-noto-cjk \
&& fc-cache -fv \
&& rm -rf /var/lib/apt/lists/*
# 创建虚拟环境
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /tmp
# 预装全部 Python 依赖(基础 + Worker 大包 + 业务依赖)
COPY requirements-base.txt requirements.txt requirements-worker.txt ./
RUN pip install --no-cache-dir \
-i https://mirrors.aliyun.com/pypi/simple/ \
--trusted-host mirrors.aliyun.com \
-r requirements-base.txt \
-r requirements-worker.txt \
-r requirements.txt
# 虚拟环境瘦身
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
# 清理临时文件
RUN rm -f /tmp/requirements-base.txt /tmp/requirements.txt /tmp/requirements-worker.txt
ENV PYTHONPATH=/app:/app/packages
+49 -15
View File
@@ -1,22 +1,43 @@
# ============================================================
# Worker Dockerfile - 极简化
# 从预构建统一基础镜像开始,仅叠加业务代码
# 基础镜像包含:系统依赖 + 全部 Python 依赖 + CJK 字体
# 构建时间目标:< 5 分钟
# Worker Dockerfile - 分层缓存优化版
# 优化:基础依赖 + Worker大包预构建为基础镜像,业务构建仅叠加业务依赖
# 基础镜像:worker-base-builder / worker-base-runtime
# ============================================================
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest
# ==================== Builder 阶段 ====================
# 从预构建的builder基础镜像开始,已经包含:
# - 编译工具 (gcc/g++/python3-dev/binutils)
# - requirements-base.txt 全部依赖
# - requirements-worker.txt 全部依赖 (numpy/scipy/opencv)
# - 预strip的.so文件
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-builder:latest AS builder
# 构建参数:版本号(CI 传入 commit hash)
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /tmp
# ---- 安装业务依赖(变化频繁,单独一层)----
COPY requirements.txt /tmp/requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
-r /tmp/requirements.txt \
&& rm /tmp/requirements.txt
# ---- 增量瘦身(清理新增业务依赖的冗余文件)----
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
# ==================== Runtime 阶段 ====================
# 从预构建的runtime基础镜像开始,已经包含:
# - ffmpeg
# - libglib2.0-0
FROM xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/worker-base-runtime:latest AS runtime
# 构建参数:版本号
ARG APP_VERSION=dev
# 创建非 root 用户
RUN groupadd -r celery \
&& useradd -r -g celery -d /app -s /sbin/nologin celery \
&& mkdir -p /app/generated \
&& chown celery:celery /app/generated
WORKDIR /app
# 从 builder 复制 Python 虚拟环境
COPY --from=builder /opt/venv /opt/venv
# 设置 Python 环境变量
ENV PATH="/opt/venv/bin:$PATH"
@@ -24,20 +45,33 @@ ENV PYTHONPATH=/app:/app/packages
ENV PYTHONUNBUFFERED=1
ENV APP_VERSION=$APP_VERSION
# 复制文件(按变化频率从低到高排序,最大化层缓存命中)
# 创建非 root 用户(极少变化,放最前)
RUN groupadd -r celery \
&& useradd -r -g celery -d /app -s /sbin/nologin celery \
&& mkdir -p /app/generated \
&& chown celery:celery /app/generated
WORKDIR /app
# 复制文件按变化频率从低到高排序,最大化层缓存命中
COPY alembic.ini /app/alembic.ini
COPY migrations/ /app/migrations/
COPY packages/ /app/packages/
COPY apps/api/app/config.py /app/apps/api/app/config.py
COPY apps/api/app/core/ /app/apps/api/app/core/
# Worker 启动脚本
# 复制 Worker 启动脚本
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
# 业务代码(变化最频繁,放最后)
COPY apps/worker/ /app/apps/worker/
# ---- Install CJK fonts for ASS subtitle rendering ----
RUN apt-get update && apt-get install -y --no-install-recommends fonts-noto-cjk && fc-cache -fv && rm -rf /var/lib/apt/lists/*
USER celery
# Worker 入口点
WORKDIR /app/apps/worker
CMD ["/usr/local/bin/entrypoint-worker.sh"]