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

This commit is contained in:
2026-05-12 20:10:22 +08:00
parent ca0d6f1bfd
commit 138d68d876
3 changed files with 118 additions and 44 deletions

View File

@@ -293,6 +293,13 @@
"message": "auto-save 2026-05-12 19:58 (+1, ~4)", "message": "auto-save 2026-05-12 19:58 (+1, ~4)",
"hash": "375494e", "hash": "375494e",
"files_changed": 5 "files_changed": 5
},
{
"ts": "2026-05-12T20:04:48+08:00",
"type": "commit",
"message": "auto-save 2026-05-12 20:04 (~3)",
"hash": "ca0d6f1",
"files_changed": 3
} }
] ]
} }

View File

@@ -57,12 +57,42 @@ const EDGES_RAW: Array<[string, string]> = [
export default function Home() { export default function Home() {
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const [job, setJob] = useState<Job | null>(null) const [jobs, setJobs] = useState<Job[]>([])
const [activeJobId, setActiveJobId] = useState<string | null>(null)
const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId])
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [analyzing, setAnalyzing] = useState(false) const [analyzing, setAnalyzing] = useState(false)
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set()) const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
const [expandedFrame, setExpandedFrame] = useState<number | null>(null) const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
const [videoLightboxOpen, setVideoLightboxOpen] = useState(false) const [videoLightboxOpen, setVideoLightboxOpen] = useState(false)
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
const setJob = useCallback((updater: Job | ((prev: Job | null) => Job | null) | null) => {
setJobs((prev) => {
const current = prev.find((j) => j.id === activeJobId) ?? null
const next = typeof updater === "function" ? (updater as (p: Job | null) => Job | null)(current) : updater
if (!next) return prev
const idx = prev.findIndex((j) => j.id === next.id)
if (idx < 0) {
setActiveJobId(next.id)
return [...prev, next]
}
const arr = [...prev]
arr[idx] = next
return arr
})
}, [activeJobId])
// 新增 job + 设为 active
const addJob = useCallback((j: Job) => {
setJobs((prev) => [...prev.filter((x) => x.id !== j.id), j])
setActiveJobId(j.id)
}, [])
const handleSwitchJob = useCallback((id: string) => {
setActiveJobId(id)
setSelectedFrames(new Set())
}, [])
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null) const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const handleSubmit = useCallback(async (url: string) => { const handleSubmit = useCallback(async (url: string) => {
@@ -70,14 +100,14 @@ export default function Home() {
setSelectedFrames(new Set()) setSelectedFrames(new Set())
try { try {
const created = await createJob(url) const created = await createJob(url)
setJob(created) addJob(created)
toast.success(`已创建任务 ${created.id.slice(0, 8)}`) toast.success(`已创建任务 ${created.id.slice(0, 8)}`)
} catch (e) { } catch (e) {
toast.error("提交失败:" + (e instanceof Error ? e.message : String(e))) toast.error("提交失败:" + (e instanceof Error ? e.message : String(e)))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
}, []) }, [addJob])
const handleUpload = useCallback(async (file: File) => { const handleUpload = useCallback(async (file: File) => {
setSubmitting(true) setSubmitting(true)
@@ -85,14 +115,14 @@ export default function Home() {
try { try {
toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`) toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`)
const created = await uploadJob(file) const created = await uploadJob(file)
setJob(created) addJob(created)
toast.success(`已上传 ${created.id.slice(0, 8)}`) toast.success(`已上传 ${created.id.slice(0, 8)}`)
} catch (e) { } catch (e) {
toast.error("上传失败:" + (e instanceof Error ? e.message : String(e))) toast.error("上传失败:" + (e instanceof Error ? e.message : String(e)))
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
}, []) }, [addJob])
const handleAnalyze = useCallback(async () => { const handleAnalyze = useCallback(async () => {
if (!job) return if (!job) return
@@ -130,23 +160,29 @@ export default function Home() {
}) })
}, []) }, [])
// URL ?job=xxx 自动恢复 job state // URL ?job=xxx,yyy 自动恢复多个 job
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const id = params.get("job") const idsStr = params.get("job") ?? ""
if (id && !job) { const ids = idsStr.split(",").filter(Boolean)
getJob(id).then(setJob).catch(() => toast.error(`找不到 job ${id}`)) if (ids.length === 0) return
} Promise.all(ids.map((id) => getJob(id).catch(() => null))).then((results) => {
const valid = results.filter((j): j is Job => !!j)
if (valid.length > 0) {
setJobs(valid)
setActiveJobId(valid[valid.length - 1].id)
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// 写回 URL不刷新页面 // 写回 URL所有 jobs id 用 , 分隔
useEffect(() => { useEffect(() => {
if (!job) return if (jobs.length === 0) return
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.searchParams.set("job", job.id) url.searchParams.set("job", jobs.map((j) => j.id).join(","))
window.history.replaceState({}, "", url.toString()) window.history.replaceState({}, "", url.toString())
}, [job?.id]) }, [jobs.length])
// 轮询 Jobdownloaded / transcribed / failed 三态停止) // 轮询 Jobdownloaded / transcribed / failed 三态停止)
const prevStatusRef = useRef<string | null>(null) const prevStatusRef = useRef<string | null>(null)
@@ -174,6 +210,8 @@ export default function Home() {
const nodeData: NodeData = useMemo(() => ({ const nodeData: NodeData = useMemo(() => ({
job, job,
jobs,
activeJobId,
submitting, submitting,
analyzing, analyzing,
selectedFrames, selectedFrames,
@@ -184,7 +222,8 @@ export default function Home() {
onExpandFrame: setExpandedFrame, onExpandFrame: setExpandedFrame,
onAddManualFrame: handleAddManualFrame, onAddManualFrame: handleAddManualFrame,
onOpenVideoLightbox: () => setVideoLightboxOpen(true), onOpenVideoLightbox: () => setVideoLightboxOpen(true),
}), [job, submitting, analyzing, selectedFrames, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame]) onSwitchJob: handleSwitchJob,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame, handleSwitchJob])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>( const [nodes, setNodes, onNodesChange] = useNodesState<Node>(

View File

@@ -9,7 +9,9 @@ import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import { type Job, frameUrl, videoUrl } from "@/lib/api" import { type Job, frameUrl, videoUrl } from "@/lib/api"
export interface NodeData { export interface NodeData {
job: Job | null job: Job | null // 当前 active job
jobs: Job[] // 所有 job 列表
activeJobId: string | null
submitting: boolean submitting: boolean
analyzing: boolean analyzing: boolean
selectedFrames: Set<number> selectedFrames: Set<number>
@@ -20,6 +22,7 @@ export interface NodeData {
onExpandFrame: (idx: number) => void onExpandFrame: (idx: number) => void
onAddManualFrame: (t: number) => void onAddManualFrame: (t: number) => void
onOpenVideoLightbox: () => void onOpenVideoLightbox: () => void
onSwitchJob: (id: string) => void
} }
/* ---- 状态映射工具 ---- */ /* ---- 状态映射工具 ---- */
@@ -79,37 +82,62 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
return ( return (
<div className="relative" style={{ width: 320 }}> <div className="relative" style={{ width: 320 }}>
{/* 视频缩略图浮于节点上方 — 跟关键帧缩略图同尺寸(小),点击稍微放大可选帧 */} {/* 视频缩略图浮条 — 每个 job 一张 + 末尾「+」按钮再上传 */}
{hasVideo && job && !videoExpanded && ( {!videoExpanded && d.jobs.length > 0 && (
<div className="absolute left-0 right-0 flex justify-center" style={{ bottom: "calc(100% + 12px)" }}> <div className="absolute left-0 right-0 flex justify-center items-end gap-1.5 flex-wrap" style={{ bottom: "calc(100% + 12px)" }}>
{d.jobs.map((j) => {
const isActive = j.id === d.activeJobId
const ready = !!j.video_url
return (
<button
key={j.id}
type="button"
onClick={(e) => {
e.stopPropagation()
if (isActive && ready) setVideoExpanded(true)
else d.onSwitchJob(j.id)
}}
title={ready ? `${j.width}×${j.height} · ${j.duration.toFixed(1)}s · ${isActive ? "点击展开" : "点击切换"}` : "下载中…"}
className={`group relative rounded-md overflow-hidden border shadow-lg transition hover:-translate-y-0.5 ${
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
}`}
style={{ width: 80, aspectRatio: ready ? `${j.width}/${j.height}` : "9/16" }}
>
{ready ? (
<video
src={videoUrl(j.id)}
muted
loop
playsInline
preload="metadata"
className="block w-full h-full object-cover bg-black"
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
onMouseLeave={(e) => {
const v = e.target as HTMLVideoElement
v.pause()
v.currentTime = 0
}}
/>
) : (
<div className="w-full h-full bg-black/60 flex items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-white/60" />
</div>
)}
<div className="absolute bottom-0.5 right-0.5 bg-black/70 text-white text-[9px] font-mono px-1 py-0.5 rounded">
{ready ? `${j.duration.toFixed(1)}s` : "…"}
</div>
</button>
)
})}
{/* + 再加一个 */}
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); setVideoExpanded(true) }} onClick={(e) => { e.stopPropagation(); fileRef.current?.click() }}
title="点击展开 · 可拖时间轴选帧" title="再上传一个视频"
className="group relative rounded-md overflow-hidden border border-white/30 shadow-lg hover:-translate-y-0.5 transition" className="rounded-md border border-dashed border-white/30 hover:border-white/50 bg-white/[0.04] hover:bg-white/[0.08] inline-flex items-center justify-center text-white/60 hover:text-white transition"
style={{ width: 80 }} style={{ width: 36, height: 64 }}
> >
<video <Plus className="h-4 w-4" />
src={videoUrl(job.id)}
muted
loop
playsInline
preload="metadata"
className="block w-full bg-black"
style={{ aspectRatio: `${job.width}/${job.height}` }}
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-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>
</button> </button>
</div> </div>
)} )}