auto-save 2026-05-12 19:58 (+1, ~4)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
import { X, ChevronLeft, ChevronRight, Check } from "lucide-react"
|
||||
import { frameUrl, type KeyFrame } from "@/lib/api"
|
||||
import { frameUrl, videoUrl, type KeyFrame } from "@/lib/api"
|
||||
|
||||
interface Props {
|
||||
jobId: string
|
||||
@@ -65,12 +65,19 @@ 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]">
|
||||
<img
|
||||
src={frameUrl(jobId, f.index)}
|
||||
alt={`frame ${f.index}`}
|
||||
className="max-w-[88vw] max-h-[72vh] rounded-xl shadow-2xl object-contain"
|
||||
<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
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-4 text-white">
|
||||
<div className="font-mono text-sm tabular-nums">
|
||||
|
||||
@@ -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>
|
||||
|
||||
74
web/components/video-lightbox.tsx
Normal file
74
web/components/video-lightbox.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { X, Plus, Loader2 } from "lucide-react"
|
||||
import { videoUrl } from "@/lib/api"
|
||||
|
||||
interface Props {
|
||||
jobId: string | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onAddFrame: (t: number) => Promise<void>
|
||||
}
|
||||
|
||||
export function VideoLightbox({ jobId, open, onClose, onAddFrame }: Props) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [currentT, setCurrentT] = useState(0)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose()
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open || !jobId) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] bg-black/85 backdrop-blur-sm flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose() }}
|
||||
className="absolute top-5 right-5 h-10 w-10 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()} className="flex flex-col items-center gap-4 max-w-[92vw] max-h-[92vh]">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl(jobId)}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
preload="auto"
|
||||
onTimeUpdate={(e) => setCurrentT((e.target as HTMLVideoElement).currentTime)}
|
||||
className="max-w-[88vw] max-h-[72vh] rounded-xl shadow-2xl bg-black"
|
||||
/>
|
||||
<div className="flex items-center gap-4 text-white">
|
||||
<div className="font-mono text-sm tabular-nums text-white/80">
|
||||
当前 {currentT.toFixed(2)}s
|
||||
</div>
|
||||
<button
|
||||
disabled={adding}
|
||||
onClick={async () => {
|
||||
const t = videoRef.current?.currentTime ?? currentT
|
||||
setAdding(true)
|
||||
try { await onAddFrame(t) } finally { setAdding(false) }
|
||||
}}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium inline-flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-white disabled:opacity-50 transition"
|
||||
>
|
||||
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
{adding ? "抽帧中…" : `+ 把 ${currentT.toFixed(1)}s 加为关键帧`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[11px] text-white/40 font-mono">拖动时间轴选帧 · 点 + 加为关键帧 · ESC 关闭</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user