auto-save 2026-05-12 19:58 (+1, ~4)

This commit is contained in:
2026-05-12 19:59:15 +08:00
parent c481da407d
commit 375494eede
5 changed files with 163 additions and 41 deletions

View File

@@ -19,6 +19,7 @@ export interface NodeData {
onToggleFrame: (idx: number) => void
onExpandFrame: (idx: number) => void
onAddManualFrame: (t: number) => void
onOpenVideoLightbox: () => void
}
/* ---- 状态映射工具 ---- */
@@ -76,6 +77,45 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
const inputLocked = isDownloading || d.submitting
return (
<div className="relative" style={{ width: 320 }}>
{/* 视频缩略图浮于节点上方 — hover 自动播放,点击进大 lightbox */}
{hasVideo && job && (
<div className="absolute left-0 right-0 flex justify-center" style={{ bottom: "calc(100% + 12px)" }}>
<button
type="button"
onClick={(e) => { e.stopPropagation(); d.onOpenVideoLightbox() }}
title="点击放大滑动播放"
className="group relative rounded-lg overflow-hidden border border-white/25 shadow-2xl hover:-translate-y-0.5 transition"
style={{ width: 220 }}
>
<video
src={videoUrl(job.id)}
muted
loop
playsInline
preload="metadata"
className="block w-full bg-black"
style={{ aspectRatio: `${job.width}/${job.height}`, maxHeight: 280 }}
onMouseEnter={(e) => {
const v = e.target as HTMLVideoElement
v.play().catch(() => {})
}}
onMouseLeave={(e) => {
const v = e.target as HTMLVideoElement
v.pause()
v.currentTime = 0
}}
/>
<div className="absolute bottom-1.5 right-1.5 bg-black/70 text-white text-[10px] font-mono px-1.5 py-0.5 rounded">
{job.duration.toFixed(1)}s
</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 bg-black/30 transition">
<span className="text-white text-[11px] bg-black/70 px-2 py-1 rounded"></span>
</div>
</button>
</div>
)}
<NodeShell
type="input" status={inputStatus(job)}
icon={<Link2 className="h-4 w-4" />}
@@ -128,37 +168,10 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
</>
)}
{/* 已下载:视频播放器 + 加帧 + 元数据 + 解析按钮 */}
{/* 已下载:仅元数据(视频缩略图浮在节点上方,点击进 lightbox */}
{hasVideo && job && (
<>
{/* 视频播放器(拖时间轴选帧) */}
<video
ref={videoRef}
src={videoUrl(job.id)}
controls
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
className="block w-full rounded-md bg-black border border-black/10 dark:border-white/10"
/>
{/* 加帧按钮(已抽过帧才出现) */}
{hasFrames && (
<button
type="button"
disabled={addingFrame}
onClick={async (e) => {
e.stopPropagation()
const t = videoRef.current?.currentTime ?? videoT
setAddingFrame(true)
try { await d.onAddManualFrame(t) } finally { setAddingFrame(false) }
}}
className="mt-2 w-full text-[12px] py-1.5 rounded-md bg-emerald-500 hover:bg-emerald-400 text-white disabled:opacity-50 inline-flex items-center justify-center gap-1.5 font-medium"
title="把视频当前时间点抽为新关键帧"
>
{addingFrame ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
{addingFrame ? "抽帧中…" : `+ 把 ${videoT.toFixed(1)}s 加为关键帧`}
</button>
)}
{/* 元数据 */}
<div className="mt-2 rounded-md bg-black/30 border border-black/10 dark:border-white/10 px-3 py-2 flex items-center justify-between text-[10.5px] font-mono">
<div className="rounded-md bg-black/30 border border-black/10 dark:border-white/10 px-3 py-2 flex items-center justify-between text-[10.5px] font-mono">
<span className="text-[var(--text-strong)]">{job.width}×{job.height} · {job.duration.toFixed(1)}s</span>
<span className="text-[var(--text-faint)]">{job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接"}</span>
</div>
@@ -182,6 +195,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
</>
)}
</NodeShell>
</div>
)
}
@@ -286,7 +300,7 @@ export function KeyframeNode({ data, selected }: any) {
{f.timestamp.toFixed(1)}s
</div>
{/* Hover 大图预览 — 大尺寸(跟整排缩略图同宽 + 高至屏幕顶) */}
{/* Hover 视频片段预览 — 从该时间点静音 loop 播放 */}
<div
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-200 z-[60]"
style={{
@@ -297,9 +311,13 @@ export function KeyframeNode({ data, selected }: any) {
}}
>
<div className="rounded-2xl overflow-hidden border border-white/25 bg-black" style={{ boxShadow: "0 40px 100px -20px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06)" }}>
<img
src={frameUrl(jobId, f.index)}
alt={`preview ${f.index}`}
<video
src={`${process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"}/jobs/${jobId}/video.mp4#t=${f.timestamp}`}
muted
loop
autoPlay
playsInline
preload="metadata"
className="block"
style={{
width: KEYFRAME_WIDTH * 2,
@@ -308,10 +326,15 @@ export function KeyframeNode({ data, selected }: any) {
maxHeight: "82vh",
objectFit: "contain",
}}
onLoadedMetadata={(e) => {
const v = e.target as HTMLVideoElement
v.currentTime = f.timestamp
v.play().catch(() => {})
}}
/>
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md">
<span className="text-white text-[12.5px] font-medium"> {f.index + 1}</span>
<span className="text-white/60 text-[11px] font-mono">{f.timestamp.toFixed(2)}s</span>
<span className="text-white text-[12.5px] font-medium"> {f.index + 1} · </span>
<span className="text-white/60 text-[11px] font-mono"> {f.timestamp.toFixed(2)}s</span>
</div>
</div>
</div>