Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 423ee5bb48 | |||
| c8789670e9 |
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* 主动 Token 刷新模块
|
||||
*
|
||||
* 在 access_token 过期前主动刷新,避免 API 请求触发 401。
|
||||
* JWT payload 是 base64 编码的 JSON,无需第三方库即可解码。
|
||||
*/
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "./login"
|
||||
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 提前刷新的缓冲时间(秒) */
|
||||
const REFRESH_BUFFER_SECONDS = 60
|
||||
|
||||
/**
|
||||
* 解码 JWT payload(不验签,仅读取 exp 字段)
|
||||
*/
|
||||
function decodeJwtPayload(token: string): { exp?: number } | null {
|
||||
try {
|
||||
const parts = token.split(".")
|
||||
if (parts.length !== 3) return null
|
||||
// JWT 使用 base64url 编码,需要转换为标准 base64
|
||||
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
|
||||
const decoded = atob(padded)
|
||||
return JSON.parse(decoded)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消已调度的主动刷新
|
||||
*/
|
||||
export function cancelProactiveRefresh(): void {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新
|
||||
*/
|
||||
export function scheduleProactiveRefresh(): void {
|
||||
cancelProactiveRefresh()
|
||||
|
||||
const accessToken = localStorage.getItem("access_token")
|
||||
const refreshTokenValue = useAuthStore.getState().refreshToken
|
||||
|
||||
if (!accessToken || !refreshTokenValue) return
|
||||
|
||||
const payload = decodeJwtPayload(accessToken)
|
||||
if (!payload?.exp) return
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const secondsUntilExpiry = payload.exp - now
|
||||
|
||||
// 如果 token 已经过期或即将在缓冲时间内过期,立即刷新
|
||||
const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0)
|
||||
|
||||
refreshTimer = setTimeout(async () => {
|
||||
try {
|
||||
const data = await refreshAccessToken(refreshTokenValue)
|
||||
const newAccessToken = data.access_token
|
||||
const newRefreshToken = data.refresh_token ?? refreshTokenValue
|
||||
|
||||
// 更新 Zustand store + localStorage
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
|
||||
|
||||
// 递归调度下一次刷新
|
||||
scheduleProactiveRefresh()
|
||||
} catch {
|
||||
// 刷新失败 → 清除认证状态,跳转登录页
|
||||
cancelProactiveRefresh()
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/login"
|
||||
}
|
||||
}, delaySeconds * 1000)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "./auth"
|
||||
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "./auth/tokenRefresh"
|
||||
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
@@ -110,9 +109,6 @@ apiClient.interceptors.response.use(
|
||||
// 处理排队的请求
|
||||
processQueue(null, newAccessToken)
|
||||
|
||||
// 重新调度主动刷新(基于新 token 的过期时间)
|
||||
scheduleProactiveRefresh()
|
||||
|
||||
// 重试原始请求
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
|
||||
@@ -120,7 +116,6 @@ apiClient.interceptors.response.use(
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
// 刷新失败 → 登出
|
||||
cancelProactiveRefresh()
|
||||
processQueue(refreshError, null)
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import * as authApi from "@/api/auth"
|
||||
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "@/api/auth/tokenRefresh"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
|
||||
// 登录 Hook
|
||||
@@ -32,9 +31,6 @@ export const useLogin = () => {
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, data.access_token, refreshToken)
|
||||
|
||||
// 启动主动 token 刷新,避免后续请求触发 401
|
||||
scheduleProactiveRefresh()
|
||||
|
||||
// 跳转到登录前页面或仪表盘(与 Login.tsx onFinish 保持一致)
|
||||
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
|
||||
localStorage.removeItem("login_redirect")
|
||||
@@ -77,9 +73,6 @@ export const useWechatCallback = () => {
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
// 启动主动 token 刷新
|
||||
scheduleProactiveRefresh()
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
@@ -128,7 +121,6 @@ export const useLogout = () => {
|
||||
} catch (error) {
|
||||
// 即使登出失败也清除本地状态
|
||||
} finally {
|
||||
cancelProactiveRefresh()
|
||||
clearAuth()
|
||||
queryClient.clear()
|
||||
navigate("/")
|
||||
|
||||
@@ -9,13 +9,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { ConfigProvider, App as AntApp } from "antd"
|
||||
import zhCN from "antd/locale/zh_CN"
|
||||
import router from "./router"
|
||||
import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh"
|
||||
|
||||
// 应用启动时,如果用户已登录,立即调度主动 token 刷新
|
||||
// 这样可以在 token 过期前自动刷新,避免 API 请求触发 401
|
||||
if (localStorage.getItem("access_token")) {
|
||||
scheduleProactiveRefresh()
|
||||
}
|
||||
import "./index.css"
|
||||
import "./styles/global.css"
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { getAssetsByKind } from "@/api/assets/assets"
|
||||
import type { AssetItem } from "@/api/assets/types"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
@@ -71,6 +74,20 @@ const GeneratePage: React.FC = () => {
|
||||
setPreviewModalOpen,
|
||||
} = formState
|
||||
|
||||
/* ── 查询视频素材,用于 Step4 标题预览背景 ── */
|
||||
const { data: videoAssets = [] } = useQuery({
|
||||
queryKey: ["generate-video-assets"],
|
||||
queryFn: () => getAssetsByKind("video", { limit: 50 }),
|
||||
})
|
||||
|
||||
// 获取第一个选中素材的 URL
|
||||
const sourceVideoUrl = useMemo(() => {
|
||||
const firstId = selectedMaterials[0]
|
||||
if (!firstId) return undefined
|
||||
const asset = videoAssets.find((a: AssetItem) => a.id === firstId)
|
||||
return asset?.file_url
|
||||
}, [selectedMaterials, videoAssets])
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
||||
|
||||
@@ -235,7 +252,7 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ════ 右侧:预览 + 生成结果 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{/* 预览视频面板(Step4+ 显示) */}
|
||||
{/* 预览视频面板(Step4+ 显示,Step4 显示标题预览叠加,Step5+ 仅视频) */}
|
||||
{currentStep >= 4 && (
|
||||
<PreviewVideoPanel
|
||||
previewStatus={step5Preview.previewStatus}
|
||||
@@ -244,8 +261,14 @@ const GeneratePage: React.FC = () => {
|
||||
progress={step5Preview.progress}
|
||||
videoRatio={videoRatio}
|
||||
onRegenerate={step5Preview.regeneratePreview}
|
||||
titleText={titleSettings.title}
|
||||
titleSettings={titleSettings}
|
||||
showTitlePreview={currentStep === 4}
|
||||
sourceVideoUrl={sourceVideoUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 正式生成结果(Step6+ 才显示) */}
|
||||
{currentStep >= 6 && (
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
/**
|
||||
* 右侧预览视频面板
|
||||
* Step4+: 显示预览视频面板
|
||||
* Step5+: 显示后端生成的预览视频(标题已由 FFmpeg 烧录)
|
||||
* Step4 生成预览后常驻显示预览视频
|
||||
* Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
|
||||
*
|
||||
* 设计说明:
|
||||
* - Step4(标题设置页):右侧显示空状态提示,引导用户输入标题
|
||||
* - Step5(预览生成页):显示后端返回的预览视频
|
||||
* - Canvas 预览已删除(统一由后端 FFmpeg 渲染标题)
|
||||
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
|
||||
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
|
||||
*
|
||||
* Canvas 居中修复说明:
|
||||
* Canvas 的 CSS 位置和尺寸直接匹配视频实际渲染区域(通过 getBoundingClientRect),
|
||||
* 绘制坐标系基于 Canvas 自身尺寸,x = w/2 即可实现水平居中,
|
||||
* 避免容器与视频尺寸不一致时浏览器拉伸 Canvas 导致居中偏移。
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useRef, useEffect, useCallback } from "react"
|
||||
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { drawTitleOnCanvas } from "../utils/drawTitleOnCanvas"
|
||||
import TitlePreviewCanvas from "./title/TitlePreviewCanvas"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
previewStatus: PreviewStatus
|
||||
@@ -19,6 +25,14 @@ interface PreviewVideoPanelProps {
|
||||
progress: number
|
||||
videoRatio: string
|
||||
onRegenerate: () => void
|
||||
/** 标题文字 */
|
||||
titleText?: string
|
||||
/** 标题样式设置 */
|
||||
titleSettings?: TitleSettings
|
||||
/** Step4 标题预览模式 */
|
||||
showTitlePreview?: boolean
|
||||
/** 素材视频 URL(用于 Step4 标题预览背景) */
|
||||
sourceVideoUrl?: string
|
||||
}
|
||||
|
||||
/* ── 组件 ── */
|
||||
@@ -30,21 +44,167 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
progress,
|
||||
videoRatio,
|
||||
onRegenerate,
|
||||
titleText,
|
||||
titleSettings,
|
||||
showTitlePreview,
|
||||
sourceVideoUrl,
|
||||
}) => {
|
||||
const hasPreview = previewStatus === "ready" && previewResult
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
|
||||
// video 模式 refs
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
// 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染)
|
||||
const fontLoadedRef = useRef(false)
|
||||
|
||||
/** 在 video canvas 上绘制标题 */
|
||||
const drawVideoTitle = useCallback(() => {
|
||||
// 通过 ref 读取字体状态,避免 fontLoaded 进入依赖数组
|
||||
if (!fontLoadedRef.current) return
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container || !titleSettings) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
if (containerRect.width <= 0 || containerRect.height <= 0) return
|
||||
|
||||
// 使用 video 元素的 getBoundingClientRect 获取实际渲染尺寸和位置
|
||||
const videoEl = videoRef.current
|
||||
let drawW = containerRect.width
|
||||
let drawH = containerRect.height
|
||||
let offsetX = 0
|
||||
let offsetY = 0
|
||||
|
||||
if (videoEl && videoEl.clientWidth > 0 && videoEl.clientHeight > 0) {
|
||||
const videoRect = videoEl.getBoundingClientRect()
|
||||
drawW = videoRect.width
|
||||
drawH = videoRect.height
|
||||
offsetX = videoRect.left - containerRect.left
|
||||
offsetY = videoRect.top - containerRect.top
|
||||
}
|
||||
|
||||
// 更新 Canvas CSS 位置和尺寸,使其与视频实际渲染区域完全对齐
|
||||
canvas.style.left = `${offsetX}px`
|
||||
canvas.style.top = `${offsetY}px`
|
||||
canvas.style.width = `${drawW}px`
|
||||
canvas.style.height = `${drawH}px`
|
||||
|
||||
// 绘制时,坐标系基于 Canvas 自身尺寸,无需额外偏移
|
||||
drawTitleOnCanvas(
|
||||
ctx,
|
||||
drawW,
|
||||
drawH,
|
||||
titleText || "",
|
||||
titleSettings,
|
||||
40,
|
||||
titleSettings.position,
|
||||
60,
|
||||
)
|
||||
}, [titleText, titleSettings])
|
||||
|
||||
// video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
|
||||
useEffect(() => {
|
||||
if (!showTitlePreview || !hasPreview) return
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
drawVideoTitle()
|
||||
})
|
||||
observer.observe(container)
|
||||
requestAnimationFrame(drawVideoTitle)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [showTitlePreview, hasPreview, drawVideoTitle])
|
||||
|
||||
// 字体加载检测:字体变更时重新检测,确保 measureText 使用正确字体
|
||||
useEffect(() => {
|
||||
if (!showTitlePreview || !titleSettings) {
|
||||
fontLoadedRef.current = false
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
fontLoadedRef.current = false
|
||||
|
||||
const fontWeight = titleSettings.bold ? "bold" : ""
|
||||
const fontStyle = titleSettings.italic ? "italic" : ""
|
||||
const fontSpec =
|
||||
`${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim()
|
||||
|
||||
const onFontReady = () => {
|
||||
if (cancelled) return
|
||||
fontLoadedRef.current = true
|
||||
// ref 已同步更新,显式触发重绘(draw 内部通过 ref 检查字体状态)
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) {
|
||||
drawVideoTitle()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (document.fonts.check(fontSpec)) {
|
||||
onFontReady()
|
||||
return
|
||||
}
|
||||
|
||||
document.fonts
|
||||
.load(fontSpec)
|
||||
.then(() => onFontReady())
|
||||
.catch(() => {
|
||||
document.fonts.ready.then(() => onFontReady())
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [showTitlePreview, titleSettings, drawVideoTitle])
|
||||
|
||||
// video 加载完成后重绘
|
||||
const handleVideoLoaded = useCallback(() => {
|
||||
if (showTitlePreview) {
|
||||
requestAnimationFrame(drawVideoTitle)
|
||||
}
|
||||
}, [showTitlePreview, drawVideoTitle])
|
||||
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
<div className="xx-preview-header">
|
||||
<h3>预览视频</h3>
|
||||
{hasPreview && <span className="xx-preview-badge">480p 预览版</span>}
|
||||
<h3>{showTitlePreview && !hasPreview ? "标题预览" : "预览视频"}</h3>
|
||||
{hasPreview && !showTitlePreview && <span className="xx-preview-badge">480p 预览版</span>}
|
||||
</div>
|
||||
|
||||
{/* 空状态:还没生成预览 */}
|
||||
{previewStatus === "idle" && (
|
||||
{/* Step4 标题预览模式 */}
|
||||
{showTitlePreview && titleSettings && titleText && (
|
||||
<div style={{ padding: "0 16px 16px" }}>
|
||||
<TitlePreviewCanvas
|
||||
titleText={titleText}
|
||||
titleSettings={titleSettings}
|
||||
videoRatio="9:16"
|
||||
sourceVideoUrl={sourceVideoUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step4 但无标题时的空状态 */}
|
||||
{showTitlePreview && (!titleText || !titleSettings) && (
|
||||
<div className="xx-preview-empty">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 空状态:还没生成预览(非 Step4 模式) */}
|
||||
{!showTitlePreview && previewStatus === "idle" && (
|
||||
<div className="xx-preview-empty">
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 48, color: "var(--text-tertiary)", marginBottom: 12 }}
|
||||
@@ -54,8 +214,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{isLoading && (
|
||||
{/* 生成中(非 Step4 模式) */}
|
||||
{!showTitlePreview && isLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-loading-center">
|
||||
@@ -71,8 +231,8 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{isError && (
|
||||
{/* 生成失败(非 Step4 模式) */}
|
||||
{!showTitlePreview && isError && (
|
||||
<div className="xx-preview-error-panel">
|
||||
<div className="xx-preview-video xx-preview-video--error" style={videoAspectStyle}>
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}>预览生成失败</p>
|
||||
@@ -86,32 +246,54 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览成功 */}
|
||||
{/* 预览成功 + Canvas 标题叠加 */}
|
||||
{hasPreview && (
|
||||
<>
|
||||
<div ref={containerRef} style={{ position: "relative" }}>
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<video src={previewResult.videoUrl} controls preload="metadata" />
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={previewResult.videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
onLoadedMetadata={handleVideoLoaded}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>时长</span>
|
||||
<span>
|
||||
{(typeof previewResult.duration === "number" ? previewResult.duration : 0).toFixed(
|
||||
1,
|
||||
)}{" "}
|
||||
秒
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>片段数</span>
|
||||
<span>{previewResult.clipCount} 段</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>比例</span>
|
||||
<span>{videoRatio}</span>
|
||||
</div>
|
||||
{showTitlePreview && (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
zIndex: 1,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览信息(非 Step4 模式) */}
|
||||
{!showTitlePreview && hasPreview && previewResult && (
|
||||
<div className="xx-preview-info">
|
||||
<div className="xx-preview-info-row">
|
||||
<span>时长</span>
|
||||
<span>
|
||||
{(typeof previewResult.duration === "number" ? previewResult.duration : 0).toFixed(1)}{" "}
|
||||
秒
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>片段数</span>
|
||||
<span>{previewResult.clipCount} 段</span>
|
||||
</div>
|
||||
<div className="xx-preview-info-row">
|
||||
<span>比例</span>
|
||||
<span>{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 标题实时预览 Canvas 组件
|
||||
*
|
||||
* 在 Step4 标题设置面板中嵌入,让用户实时看到标题文字、字体、大小、颜色、
|
||||
* 位置、描边、阴影等样式的实际渲染效果(所见即所得)。
|
||||
*
|
||||
* 使用共享的 drawTitleOnCanvas 工具函数,与 PreviewVideoPanel 行为一致。
|
||||
*/
|
||||
import React, { useRef, useEffect } from "react"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { drawTitleOnCanvas } from "../../utils/drawTitleOnCanvas"
|
||||
|
||||
interface TitlePreviewCanvasProps {
|
||||
/** 标题文字 */
|
||||
titleText: string
|
||||
/** 标题样式设置 */
|
||||
titleSettings: TitleSettings
|
||||
/** 视频比例,默认 "9:16"(竖屏) */
|
||||
videoRatio?: string
|
||||
/** 素材视频 URL(作为背景显示) */
|
||||
sourceVideoUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 videoRatio 字符串为 aspect-ratio CSS 值
|
||||
*/
|
||||
function parseAspect(ratio: string): string {
|
||||
return (ratio || "9:16").replace(":", "/")
|
||||
}
|
||||
|
||||
const TitlePreviewCanvas: React.FC<TitlePreviewCanvasProps> = ({
|
||||
titleText,
|
||||
titleSettings,
|
||||
videoRatio = "9:16",
|
||||
sourceVideoUrl,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
// 字体加载状态
|
||||
const fontLoadedRef = useRef(false)
|
||||
|
||||
/** 在 Canvas 上绘制标题 */
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return
|
||||
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
|
||||
// 更新 Canvas CSS 尺寸匹配容器
|
||||
canvas.style.width = `${w}px`
|
||||
canvas.style.height = `${h}px`
|
||||
|
||||
drawTitleOnCanvas(ctx, w, h, titleText, titleSettings, 24, titleSettings.position, 40)
|
||||
}
|
||||
|
||||
// 字体加载:确保 measureText 使用正确字体
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fontLoadedRef.current = false
|
||||
|
||||
const fontWeight = titleSettings.bold ? "bold" : ""
|
||||
const fontStyle = titleSettings.italic ? "italic" : ""
|
||||
const fontSpec =
|
||||
`${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim()
|
||||
|
||||
const onFontReady = () => {
|
||||
if (cancelled) return
|
||||
fontLoadedRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) draw()
|
||||
})
|
||||
}
|
||||
|
||||
// 用 FontFace API 加载字体,失败则降级
|
||||
try {
|
||||
const fontFace = new FontFace(titleSettings.font, `local("${titleSettings.font}")`)
|
||||
fontFace
|
||||
.load()
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- FontFaceSet.add() exists at runtime
|
||||
;(document.fonts as any).add(fontFace)
|
||||
onFontReady()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 字体加载失败,用默认字体继续
|
||||
onFontReady()
|
||||
})
|
||||
} catch {
|
||||
// FontFace 不可用,直接绘制
|
||||
onFontReady()
|
||||
}
|
||||
|
||||
// 同时检查 document.fonts 是否已有该字体
|
||||
if (document.fonts.check(fontSpec)) {
|
||||
onFontReady()
|
||||
return
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
|
||||
}, [titleSettings.font, titleSettings.size, titleSettings.bold, titleSettings.italic])
|
||||
|
||||
// props 变化时重绘
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(draw)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
|
||||
}, [titleText, titleSettings])
|
||||
|
||||
// ResizeObserver 监听容器尺寸变化
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
requestAnimationFrame(draw)
|
||||
})
|
||||
observer.observe(container)
|
||||
|
||||
return () => observer.disconnect()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- draw uses refs, stable by design
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary, #999)",
|
||||
marginBottom: 6,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
预览效果
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: parseAspect(videoRatio),
|
||||
background: sourceVideoUrl
|
||||
? "#000"
|
||||
: "linear-gradient(135deg, #1a1a2e, #16213e, #0f3460)",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{sourceVideoUrl && (
|
||||
<video
|
||||
src={sourceVideoUrl}
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitlePreviewCanvas
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Canvas 标题绘制工具函数(共享模块)
|
||||
*
|
||||
* 供 PreviewVideoPanel(预览视频标题叠加)和 TitlePreviewCanvas(标题设置实时预览)共用。
|
||||
* 绘制行为与 ASS 字幕引擎一致:逐字换行、居中、描边/阴影。
|
||||
*/
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
/**
|
||||
* 将文本按 maxWidth 逐字换行,返回行数组。
|
||||
* 与 ASS 字幕引擎的逐字换行行为一致。
|
||||
*/
|
||||
export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
||||
const lines: string[] = []
|
||||
let currentLine = ""
|
||||
for (const char of text) {
|
||||
const testLine = currentLine + char
|
||||
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
|
||||
lines.push(currentLine)
|
||||
currentLine = char
|
||||
} else {
|
||||
currentLine = testLine
|
||||
}
|
||||
}
|
||||
if (currentLine) lines.push(currentLine)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
|
||||
*
|
||||
* @param ctx canvas 上下文
|
||||
* @param w canvas CSS 宽度
|
||||
* @param h canvas CSS 高度
|
||||
* @param text 标题文字
|
||||
* @param settings 标题样式
|
||||
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
|
||||
* @param position "top" | "center" | "bottom"
|
||||
* @param topOffset 顶部/底部偏移量
|
||||
*/
|
||||
export function drawTitleOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
text: string,
|
||||
settings: TitleSettings,
|
||||
paddingX: number,
|
||||
position: string,
|
||||
topOffset: number,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
// 设置 canvas 物理像素尺寸(高清屏适配)
|
||||
ctx.canvas.width = Math.round(w * dpr)
|
||||
ctx.canvas.height = Math.round(h * dpr)
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// 清除
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// 可用宽度 = 总宽 - 左右边距
|
||||
const availableWidth = w - paddingX * 2
|
||||
if (availableWidth <= 0) return
|
||||
|
||||
// 字体设置
|
||||
const fontSize = Math.round(Math.min(settings.size, 36))
|
||||
const fontWeight = settings.bold ? "bold" : "normal"
|
||||
const fontStyle = settings.italic ? "italic" : "normal"
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
|
||||
|
||||
// 文字属性
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillStyle = settings.color
|
||||
|
||||
const lineHeight = fontSize * 1.4
|
||||
|
||||
// 描边 & 阴影
|
||||
if (settings.stroke) {
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.6)"
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = "round"
|
||||
}
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.7)"
|
||||
ctx.shadowBlur = 4
|
||||
ctx.shadowOffsetX = 2
|
||||
ctx.shadowOffsetY = 2
|
||||
}
|
||||
|
||||
// 换行
|
||||
const displayText = text && text.trim() ? text : "请选择或输入标题"
|
||||
const lines = wrapText(ctx, displayText, availableWidth)
|
||||
|
||||
// 起始 Y:根据 position 计算
|
||||
const totalTextHeight = lines.length * lineHeight
|
||||
let startY: number
|
||||
switch (position) {
|
||||
case "top":
|
||||
startY = topOffset
|
||||
break
|
||||
case "center":
|
||||
startY = (h - totalTextHeight) / 2 + lineHeight / 2
|
||||
break
|
||||
case "bottom":
|
||||
default:
|
||||
startY = h - topOffset - totalTextHeight + lineHeight / 2
|
||||
break
|
||||
}
|
||||
|
||||
// 居中 x = w/2
|
||||
const x = w / 2
|
||||
lines.forEach((line, i) => {
|
||||
const y = startY + i * lineHeight
|
||||
if (settings.stroke) ctx.strokeText(line, x, y)
|
||||
ctx.fillText(line, x, y)
|
||||
})
|
||||
|
||||
// 重置 shadow(避免影响后续绘制)
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "transparent"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
}
|
||||
}
|
||||
@@ -31,27 +31,14 @@ vi.mock("react-router-dom", async () => {
|
||||
})
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
(selector: any) =>
|
||||
selector({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
{
|
||||
getState: () => ({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
},
|
||||
),
|
||||
useAuthStore: (selector: any) =>
|
||||
selector({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
@@ -67,9 +67,6 @@ 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 入口点
|
||||
|
||||
Reference in New Issue
Block a user