fix: make storyboard video clicks previewable

This commit is contained in:
2026-05-21 17:29:16 +08:00
parent cc12d7c6a7
commit 4efb2ce456
3 changed files with 87 additions and 63 deletions

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
import { type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import {
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, Minus,
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Download, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, Minus,
MessageSquare, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Send, Sparkles, Sun, Trash2, Upload, Wand2,
} from "lucide-react"
import { toast } from "sonner"
@@ -863,6 +863,17 @@ function videoSrc(video: GeneratedVideo) {
return apiAssetUrl(video.url)
}
function downloadMedia(url: string, filename: string) {
if (!url || typeof document === "undefined") return
const link = document.createElement("a")
link.href = url
link.download = filename
link.rel = "noreferrer"
document.body.appendChild(link)
link.click()
link.remove()
}
function audioPreview(job: Job | null) {
if (!job) return "粘贴 TK 链接或上传视频后,系统会先下载视频;下载完成后自动提取音频文案。"
const source = job.audio_script?.source_text?.trim() || job.audio_script?.source_zh?.trim()
@@ -5584,23 +5595,6 @@ function AudioStoryboardPlanPanel({
}
}
const selectVideoForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, videoId: string) => {
if (!job || !frame) return
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
try {
const legacyRowIndex = legacyRowIndexForFrame(frame.index)
const savedSceneForRow = storyboardSceneBelongsToRow(frame.storyboard, row.index, legacyRowIndex)
? frame.storyboard
: null
const scene = buildSceneForPlannedRow(plannedRow, frame, savedSceneForRow, videoId)
const updated = await updateStoryboard(job.id, frame.index, scene)
onJobUpdate?.(updated)
toast.success(`分镜 ${row.index + 1} 已选用该视频`)
} catch (e) {
toast.error("选用视频失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const clearVideosForRow = (videos: GeneratedVideo[]) => {
if (!videos.length) return
for (const video of videos) onDeleteVideo?.(video.id)
@@ -6468,7 +6462,6 @@ function AudioStoryboardPlanPanel({
job={job}
videos={rowVideos}
enabled={!!referenceFrame}
selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
busy={quickVideoBusyRow === row.index}
count={rowVideoCount}
onCountChange={(count) => patchRowVideoCount(row.index, count)}
@@ -6476,7 +6469,6 @@ function AudioStoryboardPlanPanel({
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
onClear={() => clearVideosForRow(rowVideos)}
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
onDeleteVideo={onDeleteVideo}
/>
</div>
@@ -6697,7 +6689,6 @@ function AudioStoryboardPlanPanel({
videos={rowVideos}
enabled={!!referenceFrame}
expanded={videosOpen}
selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
busy={quickVideoBusyRow === row.index}
count={rowVideoCount}
onCountChange={(count) => patchRowVideoCount(row.index, count)}
@@ -6706,7 +6697,6 @@ function AudioStoryboardPlanPanel({
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
onClear={() => clearVideosForRow(rowVideos)}
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
onDeleteVideo={onDeleteVideo}
/>
<div className="mt-1 flex items-center justify-between gap-2">
@@ -7062,7 +7052,6 @@ function StoryboardVideoSlots({
job,
videos,
enabled,
selectedVideoId = "",
busy = false,
count = 4,
onCountChange,
@@ -7070,14 +7059,12 @@ function StoryboardVideoSlots({
onReroll,
onRegenerate,
onClear,
onSelect,
onDeleteVideo,
}: {
job: Job
videos: GeneratedVideo[]
enabled: boolean
expanded?: boolean
selectedVideoId?: string
busy?: boolean
count?: number
onCountChange?: (count: number) => void
@@ -7086,12 +7073,10 @@ function StoryboardVideoSlots({
onReroll?: () => void
onRegenerate?: () => void
onClear?: () => void
onSelect?: (videoId: string) => void
onDeleteVideo?: (videoId: string) => void
}) {
const visible = videos
const runningCount = videos.filter((video) => video.status === "queued" || video.status === "in_progress").length
const selectedVideo = selectedVideoId ? videos.find((video) => video.id === selectedVideoId) : null
const targetCount = clampVideoCount(count)
const emptyCount = visible.length ? 0 : Math.max(1, targetCount)
return (
@@ -7103,7 +7088,6 @@ function StoryboardVideoSlots({
<span className="shrink-0 text-[10px] text-white/34">
{videos.length ? `${videos.length}${runningCount ? ` · ${runningCount} 生成中` : ""}` : enabled ? "待生成" : "待抽帧"}
</span>
{selectedVideo ? <span className="rounded border border-emerald-300/20 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] text-emerald-100/72"> {shortId(selectedVideo.id)}</span> : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<label className="inline-flex h-7 items-center gap-1 rounded-md border border-white/10 bg-black/36 px-1.5 text-[10px] font-semibold text-white/48">
@@ -7146,9 +7130,7 @@ function StoryboardVideoSlots({
key={video.id}
job={job}
video={video}
selected={selectedVideoId === video.id}
className="h-[168px] w-[94px]"
onSelect={onSelect ? () => onSelect(video.id) : undefined}
onRegenerate={onRegenerate}
onDelete={onDeleteVideo ? () => onDeleteVideo(video.id) : undefined}
/>
@@ -7265,40 +7247,50 @@ function StoryboardVideoPreview({
job,
video,
className = "h-20 w-12",
selected = false,
onSelect,
onRegenerate,
onDelete,
}: {
job: Job
video: GeneratedVideo
className?: string
selected?: boolean
onSelect?: () => void
onRegenerate?: () => void
onDelete?: () => void
}) {
const src = videoSrc(video)
const playableSrc = src && video.status === "completed" ? src : ""
const poster = videoPoster(job, video)
const running = video.status === "queued" || video.status === "in_progress"
return (
<MediaAssetTile
kind="video"
src={src && video.status === "completed" ? src : undefined}
src={playableSrc || undefined}
poster={poster}
href={onSelect ? undefined : src || undefined}
href={playableSrc || undefined}
alt={`片段 ${shortId(video.id)}`}
label={`${shortId(video.id)} · ${video.model}`}
meta={video.status}
className={`shrink-0 bg-black/45 ${className}`}
objectFit="cover"
selected={selected}
onClick={onSelect}
title={`${video.model} · ${video.status}`}
title={playableSrc ? "点击打开视频预览" : `${video.model} · ${video.status}`}
bottom={<span className="block truncate rounded bg-black/70 px-1 py-0.5 text-center font-mono text-[9px] text-white/62">{running ? "生成中" : video.status === "failed" ? "失败" : shortId(video.id)}</span>}
topLeft={selected ? <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-400 text-black"><Check className="h-3 w-3" /></span> : undefined}
topRight={running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : undefined}
actions={onRegenerate ? [{ key: "regen", label: "重生一个候选", icon: <RefreshCw className="h-3 w-3" />, onClick: onRegenerate, tone: "cyan" }] : []}
actions={[
...(playableSrc ? [{
key: "download",
label: "下载视频",
icon: <Download className="h-3 w-3" />,
onClick: () => downloadMedia(playableSrc, `skg-storyboard-${shortId(video.id)}.mp4`),
tone: "cyan" as const,
}] : []),
...(onRegenerate ? [{
key: "regen",
label: "重生一个候选",
icon: <RefreshCw className="h-3 w-3" />,
onClick: onRegenerate,
tone: "neutral" as const,
}] : []),
]}
actionsAlwaysVisible={!!playableSrc}
onDelete={onDelete}
deleteLabel="删除这个视频候选"
/>
@@ -8177,19 +8169,31 @@ function VideoCandidate({
const src = videoSrc(video)
const poster = videoPoster(job, video)
const running = video.status === "queued" || video.status === "in_progress"
const playableSrc = src && video.status === "completed" ? src : ""
const thumb = (
<>
{playableSrc ? (
<video src={playableSrc} poster={poster} muted playsInline className="h-full w-full object-cover" />
) : poster ? (
<img src={poster} alt={`片段 ${shortId(video.id)}`} className="h-full w-full object-cover opacity-80" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/30"><Film className="h-4 w-4" /></div>
)}
<div className="absolute right-1 top-1 rounded-full bg-black/70 p-0.5">{selected ? <Check className="h-3 w-3 text-rose-200" /> : <Circle className="h-3 w-3 text-white/55" />}</div>
</>
)
return (
<div className={`rounded-lg border p-2 transition ${selected ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
<div className="flex gap-2">
<button type="button" onClick={onToggle} className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
{src && video.status === "completed" ? (
<video src={src} poster={poster} muted playsInline className="h-full w-full object-cover" />
) : poster ? (
<img src={poster} alt={`片段 ${shortId(video.id)}`} className="h-full w-full object-cover opacity-80" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/30"><Film className="h-4 w-4" /></div>
)}
<div className="absolute right-1 top-1 rounded-full bg-black/70 p-0.5">{selected ? <Check className="h-3 w-3 text-rose-200" /> : <Circle className="h-3 w-3 text-white/55" />}</div>
</button>
{playableSrc ? (
<a href={playableSrc} target="_blank" rel="noreferrer" className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black" title="打开视频预览">
{thumb}
</a>
) : (
<div className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
{thumb}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="truncate font-mono text-[12px] text-white/80">{shortId(video.id)} · {video.model}</div>
@@ -8204,11 +8208,17 @@ function VideoCandidate({
<span>{video.progress}%</span>
</div>
{video.error && <div className="mt-1 line-clamp-2 text-[11px] text-rose-200/80">{video.error}</div>}
{src && video.status === "completed" && (
<a href={src} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-cyan-200 hover:text-cyan-100">
<Play className="h-3 w-3" />
</a>
{playableSrc && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<a href={playableSrc} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[11px] font-medium text-cyan-200 hover:text-cyan-100">
<Play className="h-3 w-3" />
</a>
<a href={playableSrc} download={`skg-storyboard-${shortId(video.id)}.mp4`} className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-200 hover:text-emerald-100">
<Download className="h-3 w-3" />
</a>
</div>
)}
</div>
</div>

View File

@@ -46,6 +46,7 @@ type MediaAssetTileProps = {
deleting?: boolean
deleteDisabled?: boolean
actions?: MediaAssetAction[]
actionsAlwaysVisible?: boolean
disablePreview?: boolean
}
@@ -106,6 +107,7 @@ export function MediaAssetTile({
deleting = false,
deleteDisabled = false,
actions = [],
actionsAlwaysVisible = false,
disablePreview = false,
}: MediaAssetTileProps) {
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null)
@@ -200,7 +202,7 @@ export function MediaAssetTile({
{topRight ? <div className="pointer-events-none absolute right-1 top-1 z-10">{topRight}</div> : null}
{bottom ? <div className="pointer-events-none absolute bottom-1 left-1 right-1 z-10">{bottom}</div> : null}
{(actions.length || onDelete) ? (
<div className="absolute right-1 top-1 z-20 flex flex-col gap-0.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100">
<div className={`absolute right-1 top-1 z-20 flex flex-col gap-0.5 transition ${actionsAlwaysVisible ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"}`}>
{actions.map((action) => (
<button
key={action.key}