auto-save 2026-05-12 17:28 (~6)

This commit is contained in:
2026-05-12 17:28:54 +08:00
parent e6b8615e3a
commit 6a9abeabc0
6 changed files with 210 additions and 22 deletions

View File

@@ -13,7 +13,7 @@ import {
type NodeData,
} from "@/components/nodes"
import { ThemeToggle } from "@/components/theme-toggle"
import { analyzeJob, createJob, getJob, uploadJob, type Job } from "@/lib/api"
import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, type Job } from "@/lib/api"
import { FrameLightbox } from "@/components/lightbox"
const NODE_TYPES = {
@@ -113,6 +113,17 @@ export default function Home() {
}
}, [job?.id])
const handleAddManualFrame = useCallback(async (t: number) => {
if (!job) return
try {
const updated = await addManualFrame(job.id, t)
setJob(updated)
toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length}`)
} catch (e) {
toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [job?.id])
const handleToggleFrame = useCallback((idx: number) => {
setSelectedFrames((prev) => {
const next = new Set(prev)
@@ -174,7 +185,8 @@ export default function Home() {
onAnalyze: handleAnalyze,
onToggleFrame: handleToggleFrame,
onExpandFrame: setExpandedFrame,
}), [job, submitting, analyzing, selectedFrames, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame])
onAddManualFrame: handleAddManualFrame,
}), [job, submitting, analyzing, selectedFrames, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(

View File

@@ -3,7 +3,7 @@ import { useRef, useState } from "react"
import { type NodeProps } from "@xyflow/react"
import {
Link2, Upload, Download, Scissors, Image as ImageIcon,
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2,
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus,
} from "lucide-react"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import { type Job, frameUrl, videoUrl } from "@/lib/api"
@@ -18,6 +18,7 @@ export interface NodeData {
onAnalyze: () => void
onToggleFrame: (idx: number) => void
onExpandFrame: (idx: number) => void
onAddManualFrame: (t: number) => void
}
/* ---- 状态映射工具 ---- */
@@ -60,7 +61,10 @@ function asrStatus(job: Job | null): NodeStatus {
export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | any) {
const d: NodeData = data
const [url, setUrl] = useState("")
const [videoT, setVideoT] = useState(0)
const [addingFrame, setAddingFrame] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const job = d.job
// 是否已下载 → 显示视频 + 解析按钮
@@ -68,6 +72,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
const isDownloading = job?.status === "downloading" || job?.status === "created"
const isAnalyzing = !!job && ["splitting", "frames_extracted", "transcribing"].includes(job.status)
const isDone = job?.status === "transcribed"
const hasFrames = (job?.frames.length ?? 0) > 0
const inputLocked = isDownloading || d.submitting
return (
@@ -127,8 +132,10 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
{hasVideo && job && (
<>
<video
ref={videoRef}
src={videoUrl(job.id)}
controls
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
className="w-full aspect-video rounded-md bg-black border border-black/10 dark:border-white/10"
/>
<div className="mt-2 flex items-center justify-between text-[10.5px] font-mono text-[var(--text-faint)]">
@@ -137,17 +144,41 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : "🔗"}
</span>
</div>
{/* 手动拖加帧(已抽过帧才出现) */}
{hasFrames && (
<button
type="button"
disabled={addingFrame}
onClick={async (e) => {
e.stopPropagation()
const t = videoRef.current?.currentTime ?? 0
setAddingFrame(true)
try {
await d.onAddManualFrame(t)
} finally {
setAddingFrame(false)
}
}}
className="mt-2 w-full text-[11.5px] py-2 rounded-md border border-dashed border-emerald-400/40 bg-emerald-400/5 hover:bg-emerald-400/10 text-emerald-300 dark:text-emerald-300 disabled:opacity-50 flex items-center justify-center gap-1.5"
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>
)}
<button
type="button"
disabled={isAnalyzing || d.analyzing}
onClick={d.onAnalyze}
className={`mt-2 w-full text-[14px] py-3 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 text-white hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-semibold shadow-lg shadow-violet-500/30 ${
!isAnalyzing && !d.analyzing && !isDone ? "animate-[pulse_2s_ease-in-out_infinite] ring-2 ring-violet-400/40 ring-offset-2 ring-offset-transparent" : ""
!isAnalyzing && !d.analyzing && !isDone && !hasFrames ? "animate-[pulse_2s_ease-in-out_infinite] ring-2 ring-violet-400/40 ring-offset-2 ring-offset-transparent" : ""
}`}
>
{(isAnalyzing || d.analyzing) ? (
<><Loader2 className="h-4 w-4 animate-spin" /> </>
) : isDone ? (
) : isDone || hasFrames ? (
"重新解析"
) : (
<> </>

View File

@@ -84,6 +84,15 @@ export async function analyzeJob(id: string, frames = 5): Promise<Job> {
return res.json()
}
export async function addManualFrame(id: string, t: number): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${id}/frames?t=${encodeURIComponent(t.toFixed(2))}`, { method: "POST" })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`addFrame ${res.status} ${txt.slice(0, 200)}`)
}
return res.json()
}
export function frameUrl(jobId: string, frameIndex: number): string {
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}.jpg`
}