auto-save 2026-05-12 20:04 (~3)

This commit is contained in:
2026-05-12 20:04:48 +08:00
parent 375494eede
commit ca0d6f1bfd
3 changed files with 77 additions and 39 deletions

View File

@@ -1,7 +1,7 @@
"use client"
import { useEffect } from "react"
import { X, ChevronLeft, ChevronRight, Check } from "lucide-react"
import { frameUrl, videoUrl, type KeyFrame } from "@/lib/api"
import { frameUrl, type KeyFrame } from "@/lib/api"
interface Props {
jobId: string
@@ -65,19 +65,12 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</button>
)}
{/* 大视频可拖时间轴播放(默认 seek 到该帧时间点) */}
{/* 大关键帧是静态素材(传给生图节点垫图用),不播放视频 */}
<div onClick={(e) => e.stopPropagation()} className="flex flex-col items-center gap-4 max-w-[92vw] max-h-[92vh]">
<video
key={`${jobId}-${f.index}`}
src={videoUrl(jobId)}
controls
autoPlay
playsInline
preload="auto"
className="max-w-[88vw] max-h-[72vh] rounded-xl shadow-2xl bg-black"
onLoadedMetadata={(e) => {
(e.target as HTMLVideoElement).currentTime = f.timestamp
}}
<img
src={frameUrl(jobId, f.index)}
alt={`frame ${f.index}`}
className="max-w-[88vw] max-h-[72vh] rounded-xl shadow-2xl object-contain"
/>
<div className="flex items-center gap-4 text-white">
<div className="font-mono text-sm tabular-nums">

View File

@@ -64,6 +64,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
const [url, setUrl] = useState("")
const [videoT, setVideoT] = useState(0)
const [addingFrame, setAddingFrame] = useState(false)
const [videoExpanded, setVideoExpanded] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const job = d.job
@@ -78,15 +79,15 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
return (
<div className="relative" style={{ width: 320 }}>
{/* 视频缩略图浮于节点上方 — hover 自动播放,点击进大 lightbox */}
{hasVideo && job && (
{/* 视频缩略图浮于节点上方 — 跟关键帧缩略图同尺寸(小),点击稍微放大可选帧 */}
{hasVideo && job && !videoExpanded && (
<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 }}
onClick={(e) => { e.stopPropagation(); setVideoExpanded(true) }}
title="点击展开 · 可拖时间轴选帧"
className="group relative rounded-md overflow-hidden border border-white/30 shadow-lg hover:-translate-y-0.5 transition"
style={{ width: 80 }}
>
<video
src={videoUrl(job.id)}
@@ -95,7 +96,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
playsInline
preload="metadata"
className="block w-full bg-black"
style={{ aspectRatio: `${job.width}/${job.height}`, maxHeight: 280 }}
style={{ aspectRatio: `${job.width}/${job.height}` }}
onMouseEnter={(e) => {
const v = e.target as HTMLVideoElement
v.play().catch(() => {})
@@ -106,16 +107,62 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
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">
<div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[9px] font-mono px-1 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>
)}
{/* 展开态 — 稍微放大360 宽),含 controls + 加帧按钮,不全屏 */}
{hasVideo && job && videoExpanded && (
<div
className="absolute left-0 right-0 flex justify-center"
style={{ bottom: "calc(100% + 12px)" }}
>
<div
onClick={(e) => e.stopPropagation()}
className="relative rounded-xl overflow-hidden border border-white/25 shadow-2xl bg-black"
style={{ width: 360, animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)" }}
>
<video
ref={videoRef}
src={videoUrl(job.id)}
controls
autoPlay
playsInline
preload="auto"
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
className="block w-full bg-black"
style={{ aspectRatio: `${job.width}/${job.height}`, maxHeight: "60vh" }}
/>
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md gap-2">
<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="flex-1 text-[11.5px] 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"
>
{addingFrame ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
+ {videoT.toFixed(1)}s
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setVideoExpanded(false) }}
className="px-2.5 py-1.5 text-[11px] rounded-md bg-white/10 hover:bg-white/20 text-white"
>
</button>
</div>
</div>
</div>
)}
<NodeShell
type="input" status={inputStatus(job)}
icon={<Link2 className="h-4 w-4" />}
@@ -300,7 +347,7 @@ export function KeyframeNode({ data, selected }: any) {
{f.timestamp.toFixed(1)}s
</div>
{/* Hover 视频片段预览 — 从该时间点静音 loop 播放 */}
{/* Hover 静态大图预览(关键帧是给下游生图垫图的素材,不需要视频) */}
<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={{
@@ -311,13 +358,9 @@ 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)" }}>
<video
src={`${process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"}/jobs/${jobId}/video.mp4#t=${f.timestamp}`}
muted
loop
autoPlay
playsInline
preload="metadata"
<img
src={frameUrl(jobId, f.index)}
alt={`preview ${f.index}`}
className="block"
style={{
width: KEYFRAME_WIDTH * 2,
@@ -326,15 +369,10 @@ 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>