auto-save 2026-05-12 20:10 (~3)
This commit is contained in:
@@ -293,6 +293,13 @@
|
||||
"message": "auto-save 2026-05-12 19:58 (+1, ~4)",
|
||||
"hash": "375494e",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -57,12 +57,42 @@ const EDGES_RAW: Array<[string, string]> = [
|
||||
|
||||
export default function Home() {
|
||||
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 [analyzing, setAnalyzing] = useState(false)
|
||||
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
|
||||
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
|
||||
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 handleSubmit = useCallback(async (url: string) => {
|
||||
@@ -70,14 +100,14 @@ export default function Home() {
|
||||
setSelectedFrames(new Set())
|
||||
try {
|
||||
const created = await createJob(url)
|
||||
setJob(created)
|
||||
addJob(created)
|
||||
toast.success(`已创建任务 ${created.id.slice(0, 8)}`)
|
||||
} catch (e) {
|
||||
toast.error("提交失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [])
|
||||
}, [addJob])
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
setSubmitting(true)
|
||||
@@ -85,14 +115,14 @@ export default function Home() {
|
||||
try {
|
||||
toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`)
|
||||
const created = await uploadJob(file)
|
||||
setJob(created)
|
||||
addJob(created)
|
||||
toast.success(`已上传 ${created.id.slice(0, 8)}`)
|
||||
} catch (e) {
|
||||
toast.error("上传失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [])
|
||||
}, [addJob])
|
||||
|
||||
const handleAnalyze = useCallback(async () => {
|
||||
if (!job) return
|
||||
@@ -130,23 +160,29 @@ export default function Home() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// URL ?job=xxx 自动恢复 job state
|
||||
// URL ?job=xxx,yyy 自动恢复多个 job
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const id = params.get("job")
|
||||
if (id && !job) {
|
||||
getJob(id).then(setJob).catch(() => toast.error(`找不到 job ${id}`))
|
||||
}
|
||||
const idsStr = params.get("job") ?? ""
|
||||
const ids = idsStr.split(",").filter(Boolean)
|
||||
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
|
||||
}, [])
|
||||
|
||||
// 写回 URL(不刷新页面)
|
||||
// 写回 URL(所有 jobs id 用 , 分隔)
|
||||
useEffect(() => {
|
||||
if (!job) return
|
||||
if (jobs.length === 0) return
|
||||
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())
|
||||
}, [job?.id])
|
||||
}, [jobs.length])
|
||||
|
||||
// 轮询 Job(downloaded / transcribed / failed 三态停止)
|
||||
const prevStatusRef = useRef<string | null>(null)
|
||||
@@ -174,6 +210,8 @@ export default function Home() {
|
||||
|
||||
const nodeData: NodeData = useMemo(() => ({
|
||||
job,
|
||||
jobs,
|
||||
activeJobId,
|
||||
submitting,
|
||||
analyzing,
|
||||
selectedFrames,
|
||||
@@ -184,7 +222,8 @@ export default function Home() {
|
||||
onExpandFrame: setExpandedFrame,
|
||||
onAddManualFrame: handleAddManualFrame,
|
||||
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)
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
|
||||
|
||||
@@ -9,7 +9,9 @@ import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||||
import { type Job, frameUrl, videoUrl } from "@/lib/api"
|
||||
|
||||
export interface NodeData {
|
||||
job: Job | null
|
||||
job: Job | null // 当前 active job
|
||||
jobs: Job[] // 所有 job 列表
|
||||
activeJobId: string | null
|
||||
submitting: boolean
|
||||
analyzing: boolean
|
||||
selectedFrames: Set<number>
|
||||
@@ -20,6 +22,7 @@ export interface NodeData {
|
||||
onExpandFrame: (idx: number) => void
|
||||
onAddManualFrame: (t: number) => void
|
||||
onOpenVideoLightbox: () => void
|
||||
onSwitchJob: (id: string) => void
|
||||
}
|
||||
|
||||
/* ---- 状态映射工具 ---- */
|
||||
@@ -79,37 +82,62 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: 320 }}>
|
||||
{/* 视频缩略图浮于节点上方 — 跟关键帧缩略图同尺寸(小),点击稍微放大可选帧 */}
|
||||
{hasVideo && job && !videoExpanded && (
|
||||
<div className="absolute left-0 right-0 flex justify-center" style={{ bottom: "calc(100% + 12px)" }}>
|
||||
{/* 多视频缩略图浮条 — 每个 job 一张 + 末尾「+」按钮再上传 */}
|
||||
{!videoExpanded && d.jobs.length > 0 && (
|
||||
<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
|
||||
type="button"
|
||||
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 }}
|
||||
onClick={(e) => { e.stopPropagation(); fileRef.current?.click() }}
|
||||
title="再上传一个视频"
|
||||
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: 36, height: 64 }}
|
||||
>
|
||||
<video
|
||||
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>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user