auto-save 2026-05-14 04:04 (~6)

This commit is contained in:
2026-05-14 04:04:54 +08:00
parent b95706a3e4
commit 87f1182afe
6 changed files with 171 additions and 65 deletions

View File

@@ -16,7 +16,7 @@ import { toast } from "sonner"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import { HoverPreview } from "./hover-preview"
import {
type Job, type ImageRef, type FrameExtractTarget,
type Job, type ImageRef, type FrameExtractMode, type FrameExtractTarget,
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
} from "@/lib/api"
import { FrameLightbox } from "@/components/lightbox"
@@ -30,6 +30,7 @@ export interface NodeData {
submitting: boolean
analyzing: boolean
frameTarget: FrameExtractTarget
frameCount: number
selectedFrames: Set<number>
expandedFrame: number | null
framePanelScale?: number
@@ -40,8 +41,9 @@ export interface NodeData {
videoPanelDock?: CanvasPanelDock
onSubmitUrl: (url: string) => void
onUploadFile: (file: File) => void
onAnalyze: () => void
onAnalyze: (options?: { mode?: FrameExtractMode }) => void
onFrameTargetChange: (target: FrameExtractTarget) => void
onFrameCountChange: (count: number) => void
onToggleFrame: (idx: number) => void
onExpandFrame: (idx: number) => void
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
@@ -128,6 +130,7 @@ const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hi
{ value: "expression", label: "表情瞬间", hint: "人物 / 动物表情倾向" },
{ value: "motion", label: "动作峰值", hint: "动作变化更明显" },
]
const FRAME_COUNT_OPTIONS = [3, 5, 8, 12]
function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) {
if (!root) return { x: 160, y: 0 }
@@ -317,14 +320,17 @@ function ThumbnailScrollRail({
function FloatingThumbnailStrip({
children,
label,
toolbar,
}: {
children: ReactNode
label?: string
toolbar?: ReactNode
}) {
const scrollRef = useRef<HTMLDivElement>(null)
return (
<div className="absolute left-0 right-0" style={{ bottom: "calc(100% + 12px)" }}>
{toolbar && <div className="mb-2">{toolbar}</div>}
<div ref={scrollRef} className="thumbnail-strip flex items-end gap-1.5 overflow-x-auto">
{children}
</div>
@@ -400,6 +406,75 @@ function DeleteConfirmDialog({
)
}
function FrameExtractQuickBar({
target,
count,
disabled,
running,
hasFrames,
onTargetChange,
onCountChange,
onAnalyze,
}: {
target: FrameExtractTarget
count: number
disabled: boolean
running: boolean
hasFrames: boolean
onTargetChange: (target: FrameExtractTarget) => void
onCountChange: (count: number) => void
onAnalyze: () => void
}) {
const option = FRAME_TARGET_OPTIONS.find((item) => item.value === target) ?? FRAME_TARGET_OPTIONS[0]
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"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<select
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"
aria-label="选择自动抽帧目标"
title={option.hint}
>
{FRAME_TARGET_OPTIONS.map((item) => (
<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>
<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>
)
}
/* ============================================================
1. InputNode — TK 链接 / 上传
============================================================ */
@@ -429,17 +504,29 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
const hasVideo = !!job?.video_url
const isDownloading = job?.status === "downloading" || job?.status === "created"
const isAnalyzing = !!job && ["splitting", "transcribing"].includes(job.status)
const isDone = job?.status === "transcribed"
const hasFrames = (job?.frames.length ?? 0) > 0
const inputLocked = isDownloading || d.submitting
const activeFrameTarget = FRAME_TARGET_OPTIONS.find((option) => option.value === d.frameTarget) ?? FRAME_TARGET_OPTIONS[0]
return (
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
{/* 多视频缩略图浮条 — 「+」在最左job 按时间倒序(最新靠左高亮),统一高度 64宽度按视频原比例一行横滚。
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
{d.jobs.length > 0 && (
<FloatingThumbnailStrip label="输入视频缩略图横向滑动条">
<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}
>
{/* + 再上传一个(放在最前面) */}
<button
type="button"
@@ -617,40 +704,11 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
<span className="text-[var(--text-faint)]">{job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接"}</span>
</div>
<label className="mt-2 block rounded-md border border-black/10 bg-white/55 px-2.5 py-2 text-[11px] dark:border-white/10 dark:bg-white/[0.06]">
<div className="flex items-center gap-2">
<span className="shrink-0 font-semibold text-[var(--text-strong)]"></span>
<select
value={d.frameTarget}
disabled={isAnalyzing || d.analyzing}
onChange={(e) => d.onFrameTargetChange(e.target.value as FrameExtractTarget)}
className="min-w-0 flex-1 cursor-pointer rounded-md border border-black/10 bg-white/80 px-2 py-1.5 text-[11px] font-medium text-[var(--text-strong)] outline-none transition focus:ring-2 focus:ring-[var(--ring)] disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-black/35"
aria-label="选择自动抽帧目标"
>
{FRAME_TARGET_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
{hasFrames && (
<div className="mt-2 rounded-md border border-violet-400/20 bg-violet-500/10 px-3 py-2 text-[11px] font-medium text-violet-100">
{job.frames.length} ·
</div>
<div className="mt-1 truncate text-[10px] text-[var(--text-faint)]">{activeFrameTarget.hint}</div>
</label>
<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 && !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 || hasFrames ? (
"重新解析"
) : (
<> </>
)}
</button>
)}
</>
)}
</NodeShell>