auto-save 2026-05-12 17:06 (+1, ~3)
This commit is contained in:
@@ -104,6 +104,13 @@
|
||||
"message": "auto-save 2026-05-12 16:55 (~4)",
|
||||
"hash": "345391d",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-12T17:01:09+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-12 17:00 (~3)",
|
||||
"hash": "4138bea",
|
||||
"files_changed": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/components/nodes"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import { analyzeJob, createJob, getJob, uploadJob, type Job } from "@/lib/api"
|
||||
import { FrameLightbox } from "@/components/lightbox"
|
||||
|
||||
const NODE_TYPES = {
|
||||
input: InputNode,
|
||||
@@ -64,6 +65,7 @@ export default function Home() {
|
||||
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 pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const handleSubmit = useCallback(async (url: string) => {
|
||||
@@ -102,6 +104,8 @@ export default function Home() {
|
||||
try {
|
||||
await analyzeJob(job.id, 5)
|
||||
toast.info("开始解析:拆轨 → 抽帧 → ASR → 翻译")
|
||||
// 乐观更新本地状态,让轮询 useEffect 重新启动
|
||||
setJob((prev) => prev ? { ...prev, status: "splitting", message: "拆轨中…", progress: 30 } : prev)
|
||||
} catch (e) {
|
||||
toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
@@ -118,6 +122,24 @@ export default function Home() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// URL ?job=xxx 自动恢复 job state
|
||||
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}`))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 写回 URL(不刷新页面)
|
||||
useEffect(() => {
|
||||
if (!job) return
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("job", job.id)
|
||||
window.history.replaceState({}, "", url.toString())
|
||||
}, [job?.id])
|
||||
|
||||
// 轮询 Job(downloaded / transcribed / failed 三态停止)
|
||||
useEffect(() => {
|
||||
if (!job) return
|
||||
@@ -144,6 +166,7 @@ export default function Home() {
|
||||
onUploadFile: handleUpload,
|
||||
onAnalyze: handleAnalyze,
|
||||
onToggleFrame: handleToggleFrame,
|
||||
onExpandFrame: setExpandedFrame,
|
||||
}), [job, submitting, analyzing, selectedFrames, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame])
|
||||
|
||||
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||||
@@ -231,6 +254,19 @@ export default function Home() {
|
||||
</footer>
|
||||
|
||||
<Toaster theme="system" position="top-right" />
|
||||
|
||||
{/* Lightbox 看大图 */}
|
||||
{job && (
|
||||
<FrameLightbox
|
||||
jobId={job.id}
|
||||
frames={job.frames}
|
||||
activeIndex={expandedFrame}
|
||||
selected={selectedFrames}
|
||||
onClose={() => setExpandedFrame(null)}
|
||||
onChange={setExpandedFrame}
|
||||
onToggleSelect={handleToggleFrame}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
|
||||
97
web/components/lightbox.tsx
Normal file
97
web/components/lightbox.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
import { X, ChevronLeft, ChevronRight, Check } from "lucide-react"
|
||||
import { frameUrl, type KeyFrame } from "@/lib/api"
|
||||
|
||||
interface Props {
|
||||
jobId: string
|
||||
frames: KeyFrame[]
|
||||
activeIndex: number | null
|
||||
selected: Set<number>
|
||||
onClose: () => void
|
||||
onChange: (idx: number) => void
|
||||
onToggleSelect: (idx: number) => void
|
||||
}
|
||||
|
||||
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect }: Props) {
|
||||
useEffect(() => {
|
||||
if (activeIndex === null) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose()
|
||||
if (e.key === "ArrowLeft" && activeIndex > 0) onChange(activeIndex - 1)
|
||||
if (e.key === "ArrowRight" && activeIndex < frames.length - 1) onChange(activeIndex + 1)
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
onToggleSelect(activeIndex)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [activeIndex, frames.length, onClose, onChange, onToggleSelect])
|
||||
|
||||
if (activeIndex === null || !frames[activeIndex]) return null
|
||||
const f = frames[activeIndex]
|
||||
const isSelected = selected.has(f.index)
|
||||
|
||||
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>
|
||||
|
||||
{/* 左右切换 */}
|
||||
{activeIndex > 0 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onChange(activeIndex - 1) }}
|
||||
className="absolute left-5 h-12 w-12 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</button>
|
||||
)}
|
||||
{activeIndex < frames.length - 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onChange(activeIndex + 1) }}
|
||||
className="absolute right-5 h-12 w-12 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 大图 */}
|
||||
<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"
|
||||
/>
|
||||
<div className="flex items-center gap-4 text-white">
|
||||
<div className="font-mono text-sm tabular-nums">
|
||||
{String(f.index + 1).padStart(2, "0")} / {String(frames.length).padStart(2, "0")}
|
||||
<span className="mx-3 text-white/40">·</span>
|
||||
<span className="text-white/70">{f.timestamp.toFixed(2)}s</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onToggleSelect(f.index)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium inline-flex items-center gap-2 transition ${
|
||||
isSelected
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-400"
|
||||
: "bg-white/10 text-white hover:bg-white/20"
|
||||
}`}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
{isSelected ? "已选用(点取消)" : "选用此帧"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[11px] text-white/40 font-mono">←/→ 切换 · Space 选用 · ESC 关闭</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2,
|
||||
} from "lucide-react"
|
||||
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||||
import { type Job, videoUrl } from "@/lib/api"
|
||||
import { type Job, frameUrl, videoUrl } from "@/lib/api"
|
||||
|
||||
export interface NodeData {
|
||||
job: Job | null
|
||||
@@ -17,6 +17,7 @@ export interface NodeData {
|
||||
onUploadFile: (file: File) => void
|
||||
onAnalyze: () => void
|
||||
onToggleFrame: (idx: number) => void
|
||||
onExpandFrame: (idx: number) => void
|
||||
}
|
||||
|
||||
/* ---- 状态映射工具 ---- */
|
||||
@@ -211,51 +212,82 @@ export function SplitNode({ data, selected }: any) {
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
4. KeyframeNode — 缩略图网格 + 多选
|
||||
4. KeyframeNode — 缩略图横排浮在节点上方,点击展开 lightbox
|
||||
============================================================ */
|
||||
const KEYFRAME_WIDTH = 360
|
||||
const THUMB_W = 64
|
||||
const THUMB_GAP = 6
|
||||
|
||||
export function KeyframeNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
const st = keyframeStatus(d.job)
|
||||
const frames = d.job?.frames ?? []
|
||||
const jobId = d.job?.id
|
||||
|
||||
return (
|
||||
<NodeShell
|
||||
type="ai" status={st}
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
title="关键帧 · Keyframes"
|
||||
subtitle={`STEP 4 · ${d.selectedFrames.size}/${d.job?.frames.length || 5}`}
|
||||
width={360}
|
||||
selected={selected}
|
||||
>
|
||||
{d.job?.frames.length ? (
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{d.job.frames.map((f) => {
|
||||
<div className="relative" style={{ width: KEYFRAME_WIDTH }}>
|
||||
{/* 缩略图浮条(节点上方) */}
|
||||
{frames.length > 0 && jobId && (
|
||||
<div
|
||||
className="absolute left-0 right-0 -top-[68px] flex items-end justify-center"
|
||||
style={{ gap: THUMB_GAP }}
|
||||
>
|
||||
{frames.map((f) => {
|
||||
const isSel = d.selectedFrames.has(f.index)
|
||||
return (
|
||||
<button
|
||||
key={f.index}
|
||||
onClick={(e) => { e.stopPropagation(); d.onToggleFrame(f.index) }}
|
||||
className={`relative aspect-video overflow-hidden rounded-md border ${
|
||||
isSel ? "border-[var(--ring)] ring-2 ring-[var(--ring)]" : "border-black/10 dark:border-white/10"
|
||||
onClick={(e) => { e.stopPropagation(); d.onExpandFrame(f.index) }}
|
||||
title={`第 ${f.index + 1} 张 · ${f.timestamp.toFixed(1)}s · 点击放大`}
|
||||
className={`group relative overflow-hidden rounded-md border transition shadow-lg hover:scale-110 hover:-translate-y-1 ${
|
||||
isSel
|
||||
? "border-emerald-400 ring-2 ring-emerald-400/60"
|
||||
: "border-white/30 dark:border-white/20"
|
||||
}`}
|
||||
style={{ width: THUMB_W, height: Math.round(THUMB_W * 9 / 16) }}
|
||||
>
|
||||
<img
|
||||
src={`${process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"}${f.url}`}
|
||||
src={frameUrl(jobId, f.index)}
|
||||
alt={`frame ${f.index}`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{isSel && (
|
||||
<div className="absolute inset-0 bg-[var(--ring)]/15" />
|
||||
<div className="absolute inset-0 bg-emerald-400/15" />
|
||||
)}
|
||||
<div className="absolute bottom-0.5 right-0.5 bg-black/60 text-white text-[8px] font-mono px-1 rounded">
|
||||
{/* 时间戳 */}
|
||||
<div className="absolute bottom-0 right-0 bg-black/70 text-white text-[8.5px] font-mono px-1 py-0.5 leading-none">
|
||||
{f.timestamp.toFixed(1)}s
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11.5px] text-[var(--text-faint)] py-2">等待解析后抽取(默认 5 张)</div>
|
||||
)}
|
||||
</NodeShell>
|
||||
|
||||
<NodeShell
|
||||
type="ai" status={st}
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
title="关键帧 · Keyframes"
|
||||
subtitle={`STEP 4 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`}
|
||||
width={KEYFRAME_WIDTH}
|
||||
selected={selected}
|
||||
>
|
||||
{frames.length > 0 ? (
|
||||
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
|
||||
自动抽取 <span className="text-[var(--text-strong)] font-medium">{frames.length}</span> 张 ·
|
||||
点击上方缩略图展开
|
||||
<br />
|
||||
<span className="text-[10.5px] text-[var(--text-faint)]">
|
||||
空格 / 选用此帧 按钮可勾选;选中的传入「生图」节点
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11.5px] text-[var(--text-faint)] py-1">
|
||||
等待解析后抽取(默认 5 张,可在 API 配置 KEYFRAME_COUNT 改)
|
||||
</div>
|
||||
)}
|
||||
</NodeShell>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user