auto-save 2026-05-12 17:28 (~6)
This commit is contained in:
@@ -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>(
|
||||
|
||||
@@ -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 ? (
|
||||
"重新解析"
|
||||
) : (
|
||||
<>▶ 点这里开始解析</>
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user