auto-save 2026-05-14 04:10 (~4)

This commit is contained in:
2026-05-14 04:10:26 +08:00
parent 87f1182afe
commit 0448d28b99
4 changed files with 163 additions and 113 deletions

View File

@@ -29,8 +29,8 @@ export interface NodeData {
activeJobId: string | null
submitting: boolean
analyzing: boolean
frameTarget: FrameExtractTarget
frameCount: number
frameTargets: Record<string, FrameExtractTarget>
frameCounts: Record<string, number>
selectedFrames: Set<number>
expandedFrame: number | null
framePanelScale?: number
@@ -42,8 +42,9 @@ export interface NodeData {
onSubmitUrl: (url: string) => void
onUploadFile: (file: File) => void
onAnalyze: (options?: { mode?: FrameExtractMode }) => void
onFrameTargetChange: (target: FrameExtractTarget) => void
onFrameCountChange: (count: number) => void
onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void
onFrameTargetChange: (jobId: string, target: FrameExtractTarget) => void
onFrameCountChange: (jobId: string, count: number) => void
onToggleFrame: (idx: number) => void
onExpandFrame: (idx: number) => void
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
@@ -429,7 +430,7 @@ function FrameExtractQuickBar({
return (
<div
className="nodrag nopan flex w-full items-center gap-1.5 rounded-lg border border-white/16 bg-zinc-950/88 p-1.5 text-white shadow-2xl shadow-violet-950/30 backdrop-blur"
className="nodrag nopan w-full rounded-lg border border-white/16 bg-zinc-950/90 p-1.5 text-white shadow-2xl shadow-violet-950/30 backdrop-blur"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
@@ -437,7 +438,7 @@ function FrameExtractQuickBar({
value={target}
disabled={disabled}
onChange={(e) => onTargetChange(e.target.value as FrameExtractTarget)}
className="h-8 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-2 text-[11px] font-semibold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
className="h-7 w-full cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-1.5 text-[10px] font-semibold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
aria-label="选择自动抽帧目标"
title={option.hint}
>
@@ -445,32 +446,29 @@ function FrameExtractQuickBar({
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
<div className="flex shrink-0 rounded-md border border-white/12 bg-white/[0.06] p-0.5" aria-label="选择抽帧张数">
{FRAME_COUNT_OPTIONS.map((item) => (
<button
key={item}
type="button"
disabled={disabled}
onClick={() => onCountChange(item)}
title={`${item}`}
className={`h-7 min-w-7 rounded px-1.5 text-[11px] font-bold transition disabled:cursor-not-allowed disabled:opacity-45 ${
count === item ? "bg-white text-violet-700" : "text-white/72 hover:bg-white/[0.10] hover:text-white"
}`}
>
{item}
</button>
))}
<div className="mt-1 flex gap-1">
<select
value={count}
disabled={disabled}
onChange={(e) => onCountChange(Number(e.target.value))}
className="h-7 min-w-0 flex-1 cursor-pointer rounded-md border border-white/12 bg-white/[0.08] px-1.5 text-[10px] font-bold text-white outline-none transition focus:ring-2 focus:ring-violet-300/70 disabled:cursor-not-allowed disabled:opacity-45"
aria-label="选择抽帧张数"
>
{FRAME_COUNT_OPTIONS.map((item) => (
<option key={item} value={item}>{item} </option>
))}
</select>
<button
type="button"
disabled={disabled}
onClick={onAnalyze}
title={hasFrames ? "追加自动抽帧" : "自动抽帧"}
className="inline-flex h-7 shrink-0 items-center justify-center gap-1 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 px-2 text-[10.5px] font-bold text-white shadow-lg shadow-violet-950/35 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-45"
>
{running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Scissors className="h-3 w-3" />}
{running ? "抽取" : hasFrames ? "追加" : "抽帧"}
</button>
</div>
<button
type="button"
disabled={disabled}
onClick={onAnalyze}
title={hasFrames ? "追加自动抽帧" : "自动抽帧"}
className="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 px-3 text-[11.5px] font-bold text-white shadow-lg shadow-violet-950/35 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-45"
>
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
{running ? "抽取中" : hasFrames ? "追加" : "抽帧"}
</button>
</div>
)
}
@@ -512,21 +510,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
{/* 多视频缩略图浮条 — 「+」在最左job 按时间倒序(最新靠左高亮),统一高度 64宽度按视频原比例一行横滚。
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
{d.jobs.length > 0 && (
<FloatingThumbnailStrip
label="输入视频缩略图横向滑动条"
toolbar={hasVideo && job ? (
<FrameExtractQuickBar
target={d.frameTarget}
count={d.frameCount}
disabled={isAnalyzing || d.analyzing || !job.video_url}
running={isAnalyzing || d.analyzing}
hasFrames={hasFrames}
onTargetChange={d.onFrameTargetChange}
onCountChange={d.onFrameCountChange}
onAnalyze={() => d.onAnalyze({ mode: hasFrames ? "append" : "replace" })}
/>
) : null}
>
<FloatingThumbnailStrip label="输入视频缩略图横向滑动条">
{/* + 再上传一个(放在最前面) */}
<button
type="button"
@@ -541,68 +525,92 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
const isActive = j.id === d.activeJobId
const ready = !!j.video_url
const aspectStr = ready ? `${j.width}/${j.height}` : "9/16"
const thumbWidth = ready && j.height ? Math.max(118, Math.round(THUMBNAIL_HEIGHT * j.width / j.height)) : 118
const target = d.frameTargets[j.id] ?? "balanced"
const count = d.frameCounts[j.id] ?? 5
const jHasFrames = j.frames.length > 0
const jRunning = ["splitting", "transcribing"].includes(j.status)
return (
<div
key={j.id}
className={`group relative shrink-0 rounded-md overflow-visible border shadow-lg transition hover:-translate-y-0.5 ${
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
}`}
style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspectStr }}
onMouseEnter={(e) => setHoverPreviewJob({ id: j.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewJob(null)}
className="group relative flex shrink-0 flex-col gap-1.5"
style={{ width: thumbWidth }}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (!ready) return
setPinnedPreviewJob(null)
if (!isActive) d.onSwitchJob(j.id)
d.onOpenVideoPanel?.(j.id)
}}
onDoubleClick={(e) => {
e.stopPropagation()
if (ready) {
setPinnedPreviewJob(null)
if (!isActive) d.onSwitchJob(j.id)
d.onOpenVideoPanel?.(j.id)
}
}}
title={ready ? `${j.width}×${j.height} · ${(j.duration ?? 0).toFixed(1)}s · 单击打开抽帧面板` : "下载中…"}
className="absolute inset-0 w-full h-full overflow-hidden rounded-md"
{ready ? (
<FrameExtractQuickBar
target={target}
count={count}
disabled={jRunning || d.analyzing}
running={jRunning}
hasFrames={jHasFrames}
onTargetChange={(next) => d.onFrameTargetChange(j.id, next)}
onCountChange={(next) => d.onFrameCountChange(j.id, next)}
onAnalyze={() => d.onAnalyzeJob(j.id, { mode: jHasFrames ? "append" : "replace" })}
/>
) : (
<div className="h-[72px] rounded-lg border border-white/10 bg-white/[0.03]" />
)}
<div
className={`relative rounded-md overflow-visible border shadow-lg transition hover:-translate-y-0.5 ${
isActive ? "border-violet-400 ring-2 ring-violet-400/60" : "border-white/25"
}`}
style={{ height: THUMBNAIL_HEIGHT, aspectRatio: aspectStr }}
onMouseEnter={(e) => setHoverPreviewJob({ id: j.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreviewJob(null)}
>
{ready ? (
<video
src={videoUrl(j.id)}
muted
loop
playsInline
preload="metadata"
poster=""
className="block w-full h-full object-cover bg-black"
/>
) : (
<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 ?? 0).toFixed(1)}s` : "…"}
</div>
</button>
{d.onDeleteJob && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setDeleteJobTarget(j)
if (!ready) return
setPinnedPreviewJob(null)
if (!isActive) d.onSwitchJob(j.id)
d.onOpenVideoPanel?.(j.id)
}}
title="删除这个输入视频"
className="absolute right-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
onDoubleClick={(e) => {
e.stopPropagation()
if (ready) {
setPinnedPreviewJob(null)
if (!isActive) d.onSwitchJob(j.id)
d.onOpenVideoPanel?.(j.id)
}
}}
title={ready ? `${j.width}×${j.height} · ${(j.duration ?? 0).toFixed(1)}s · 单击打开抽帧面板` : "下载中…"}
className="absolute inset-0 w-full h-full overflow-hidden rounded-md"
>
<Trash2 className="h-3.5 w-3.5" />
{ready ? (
<video
src={videoUrl(j.id)}
muted
loop
playsInline
preload="metadata"
poster=""
className="block w-full h-full object-cover bg-black"
/>
) : (
<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 ?? 0).toFixed(1)}s` : "…"}
</div>
</button>
)}
{d.onDeleteJob && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setDeleteJobTarget(j)
}}
title="删除这个输入视频"
className="absolute right-1.5 top-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
)
})}