Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-video-frame-dialog.tsx

221 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { App, Button, InputNumber, Modal } from "antd";
import { Check, Image as ImageIcon, SkipBack, SkipForward, Trash2 } from "lucide-react";
import { nanoid } from "nanoid";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatVideoFrameTime, normalizeVideoFrameTimes } from "@/lib/canvas/canvas-video-frame";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { resolveMediaUrl } from "@/services/file-storage";
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
type SelectedVideoFrame = {
id: string;
timeMs: number;
};
export type CanvasVideoFrameParams = {
timesMs: number[];
};
type CanvasVideoFrameDialogProps = {
node: CanvasNodeData;
open: boolean;
onClose: () => void;
onConfirm: (params: CanvasVideoFrameParams) => void;
};
const MAX_SELECTED_FRAMES = 30;
export function CanvasVideoFrameDialog({ node, open, onClose, onConfirm }: CanvasVideoFrameDialogProps) {
const { message } = App.useApp();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const videoRef = useRef<HTMLVideoElement>(null);
const [videoUrl, setVideoUrl] = useState("");
const [videoError, setVideoError] = useState(false);
const [durationMs, setDurationMs] = useState(0);
const [currentTimeMs, setCurrentTimeMs] = useState(0);
const [frames, setFrames] = useState<SelectedVideoFrame[]>([]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setVideoUrl("");
setVideoError(false);
setDurationMs(0);
setCurrentTimeMs(0);
setFrames([]);
const storageKey = node.metadata?.storageKey || "";
const fallback = node.metadata?.content || "";
const applyUrl = (url: string) => {
if (!cancelled) setVideoUrl(url);
};
if (resourceIdFromStorageKey(storageKey)) {
void cacheResourceObjectUrl(storageKey)
.then((cached) => {
if (cancelled) return;
if (cached) setVideoUrl(cached);
else void resolveMediaUrl(storageKey, fallback).then(applyUrl);
})
.catch(() => {
if (!cancelled) void resolveMediaUrl(storageKey, fallback).then(applyUrl);
});
} else {
void resolveMediaUrl(storageKey, fallback).then(applyUrl);
}
return () => {
cancelled = true;
};
}, [node.id, node.metadata?.content, node.metadata?.storageKey, open]);
const seekTo = (timeMs: number) => {
const normalized = normalizeVideoFrameTimes([timeMs], durationMs)[0];
if (normalized === undefined) return;
setCurrentTimeMs(normalized);
if (videoRef.current) videoRef.current.currentTime = normalized / 1000;
};
const addFrame = (timeMs: number) => {
const normalized = normalizeVideoFrameTimes([timeMs], durationMs)[0];
if (normalized === undefined) {
message.warning("视频时长未就绪,请稍候再试");
return;
}
if (frames.some((frame) => frame.timeMs === normalized)) {
message.info("该时间点已经添加");
return;
}
if (frames.length >= MAX_SELECTED_FRAMES) {
message.warning(`单次最多提取 ${MAX_SELECTED_FRAMES} 帧,请先完成当前批次`);
return;
}
setFrames((current) => [...current, { id: nanoid(), timeMs: normalized }].sort((left, right) => left.timeMs - right.timeMs));
};
const removeFrame = (id: string) => setFrames((current) => current.filter((frame) => frame.id !== id));
const lastFrameMs = Math.max(0, durationMs - 1);
const title = (
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-lg" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
<ImageIcon className="size-4" />
</span>
<div className="min-w-0">
<div className="truncate font-semibold leading-6">提取视频画面</div>
<div className="truncate text-xs opacity-45">{node.title || "视频节点"}</div>
</div>
</div>
);
return (
<Modal title={title} open={open} onCancel={onClose} footer={null} width={760} centered destroyOnHidden>
<div className="space-y-4">
<div className="flex min-h-0 items-center justify-center overflow-hidden rounded-xl bg-black">
{videoUrl ? (
<video
ref={videoRef}
src={videoUrl}
controls
playsInline
preload="metadata"
className="block max-h-[var(--video-segment-preview-max-height)] w-full"
onLoadedMetadata={(event) => {
const video = event.currentTarget;
if (!Number.isFinite(video.duration) || video.duration <= 0) return;
setDurationMs(Math.round(video.duration * 1000));
setCurrentTimeMs(Math.round(video.currentTime * 1000));
}}
onTimeUpdate={(event) => setCurrentTimeMs(Math.round(event.currentTarget.currentTime * 1000))}
onSeeked={(event) => setCurrentTimeMs(Math.round(event.currentTarget.currentTime * 1000))}
onError={() => setVideoError(true)}
/>
) : (
<div className="grid h-44 w-full place-items-center text-xs opacity-60">{videoError ? "视频预览加载失败,请检查素材是否仍然可用" : "正在加载视频…"}</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 rounded-xl px-3 py-2.5" style={{ background: theme.toolbar.itemHover }}>
<span className="text-xs font-medium opacity-60">当前时间</span>
<InputNumber
size="small"
min={0}
max={Math.max(0, lastFrameMs / 1000)}
step={0.001}
precision={3}
value={currentTimeMs / 1000}
className="w-28"
aria-label="当前取帧时间(秒)"
onChange={(value) => seekTo(Math.round((value || 0) * 1000))}
/>
<span className="mr-auto text-xs opacity-45">/ {durationMs ? formatVideoFrameTime(durationMs) : "--:--.---"}</span>
<Button
size="small"
icon={<SkipBack className="size-3.5" />}
disabled={!durationMs}
onClick={() => {
seekTo(0);
addFrame(0);
}}
>
首帧
</Button>
<Button size="small" type="primary" icon={<ImageIcon className="size-3.5" />} disabled={!durationMs} onClick={() => addFrame(currentTimeMs)}>
添加当前画面
</Button>
<Button
size="small"
icon={<SkipForward className="size-3.5" />}
disabled={!durationMs}
onClick={() => {
seekTo(lastFrameMs);
addFrame(lastFrameMs);
}}
>
尾帧
</Button>
</div>
<section aria-labelledby="selected-video-frames-title">
<div className="mb-2 flex items-center justify-between gap-3">
<div id="selected-video-frames-title" className="text-sm font-medium">
已选画面
</div>
<div className="text-xs opacity-45">
{frames.length}/{MAX_SELECTED_FRAMES}
</div>
</div>
{frames.length ? (
<div className="thin-scrollbar grid max-h-52 grid-cols-2 gap-2 overflow-y-auto pr-1 sm:grid-cols-3">
{frames.map((frame, index) => (
<div key={frame.id} className="flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-2" style={{ background: theme.toolbar.itemHover }}>
<button type="button" className="min-w-0 flex-1 text-left outline-none focus-visible:ring-2" style={{ "--tw-ring-color": theme.node.activeStroke } as CSSProperties} onClick={() => seekTo(frame.timeMs)}>
<span className="block text-[var(--fs-micro)] opacity-45">画面 {index + 1}</span>
<span className="block truncate font-mono text-xs font-medium">{formatVideoFrameTime(frame.timeMs)}</span>
</button>
<Button type="text" size="small" danger icon={<Trash2 className="size-3.5" />} aria-label={`删除画面 ${index + 1}`} onClick={() => removeFrame(frame.id)} />
</div>
))}
</div>
) : (
<div className="rounded-lg px-3 py-5 text-center text-xs opacity-50" style={{ background: theme.toolbar.itemHover }}>
播放或拖动到需要的位置,然后添加当前画面。可以连续添加多个时间点。
</div>
)}
</section>
<div className="flex items-center justify-between gap-3">
<div className="text-xs opacity-45">提取后只创建图片节点,不会自动发起生成任务。</div>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={onClose}>取消</Button>
<Button type="primary" icon={<Check className="size-4" />} disabled={!frames.length} onClick={() => onConfirm({ timesMs: frames.map((frame) => frame.timeMs) })}>
{frames.length ? `提取 ${frames.length} 帧` : "提取画面"}
</Button>
</div>
</div>
</div>
</Modal>
);
}