auto-save 2026-05-14 04:04 (~6)
This commit is contained in:
@@ -3134,6 +3134,19 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 03:53 (~5)",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-14T03:59:22+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-14 03:59 (~3)",
|
||||
"hash": "b95706a",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T20:03:12Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 03:59 (~3)",
|
||||
"files_changed": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
50
api/main.py
50
api/main.py
@@ -89,6 +89,7 @@ JobStatus = Literal[
|
||||
|
||||
KEYFRAME_COUNT = int(os.getenv("KEYFRAME_COUNT", "5"))
|
||||
FrameExtractTarget = Literal["balanced", "subject", "transition", "expression", "motion"]
|
||||
FrameExtractMode = Literal["replace", "append"]
|
||||
FRAME_TARGET_LABELS: dict[FrameExtractTarget, str] = {
|
||||
"balanced": "综合关键帧",
|
||||
"subject": "清晰主体",
|
||||
@@ -582,6 +583,7 @@ async def pipeline_analyze(
|
||||
job_id: str,
|
||||
frame_count: int = KEYFRAME_COUNT,
|
||||
target: FrameExtractTarget = "balanced",
|
||||
mode: FrameExtractMode = "replace",
|
||||
) -> None:
|
||||
"""阶段 2:拆音轨 + 抽关键帧。ASR/翻译是独立文案轨,不阻塞视觉素材流。"""
|
||||
job = JOBS[job_id]
|
||||
@@ -591,13 +593,16 @@ async def pipeline_analyze(
|
||||
if not mp4.exists():
|
||||
raise RuntimeError("source.mp4 不存在,先完成下载")
|
||||
|
||||
update(job, status="splitting", message="ffmpeg 拆分音轨…", progress=35)
|
||||
wav = d / "audio.wav"
|
||||
run([
|
||||
"ffmpeg", "-y", "-i", str(mp4),
|
||||
"-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le",
|
||||
str(wav),
|
||||
])
|
||||
if wav.exists():
|
||||
update(job, status="splitting", message="复用音轨 · 准备抽帧…", progress=35)
|
||||
else:
|
||||
update(job, status="splitting", message="ffmpeg 拆分音轨…", progress=35)
|
||||
run([
|
||||
"ffmpeg", "-y", "-i", str(mp4),
|
||||
"-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le",
|
||||
str(wav),
|
||||
])
|
||||
|
||||
n = max(1, min(int(frame_count), 20))
|
||||
target_label = FRAME_TARGET_LABELS.get(target, FRAME_TARGET_LABELS["balanced"])
|
||||
@@ -607,7 +612,9 @@ async def pipeline_analyze(
|
||||
|
||||
update(job, message=f"低清扫描候选 · {target_label} · 约 {estimated_scan_count} 帧…", progress=45)
|
||||
frames_dir = d / "frames"
|
||||
if frames_dir.exists():
|
||||
replacing = mode == "replace"
|
||||
existing_frames = list(job.frames) if not replacing else []
|
||||
if replacing and frames_dir.exists():
|
||||
shutil.rmtree(frames_dir)
|
||||
frames_dir.mkdir(parents=True)
|
||||
scan_dir = d / "frame_scan"
|
||||
@@ -637,15 +644,23 @@ async def pipeline_analyze(
|
||||
raise RuntimeError("候选帧评分失败")
|
||||
|
||||
# 2) 目标化筛选:pHash 去重 + 清晰度 / 中心细节 / 转场变化 / 动作强度 + 时序分桶。
|
||||
selection_count = n if replacing else min(len(candidates), max(n * 4, n + len(existing_frames) + 2))
|
||||
update(job, message=f"{target_label}筛选 {n} / {len(candidates)} 张…", progress=60)
|
||||
chosen = _select_keyframes(candidates, n, target)
|
||||
chosen = _select_keyframes(candidates, selection_count, target)
|
||||
|
||||
# 3) 只对最终选中的时间点,从原视频抽高质量关键帧。
|
||||
renamed: list[KeyFrame] = []
|
||||
chosen_sorted = sorted(chosen, key=lambda it: float(it["timestamp"]))
|
||||
for i, item in enumerate(chosen_sorted):
|
||||
dst = frames_dir / f"{i:03d}.jpg"
|
||||
existing_timestamps = [float(f.timestamp) for f in existing_frames]
|
||||
next_idx = max((int(f.index) for f in existing_frames), default=-1) + 1
|
||||
for item in chosen_sorted:
|
||||
if len(renamed) >= n:
|
||||
break
|
||||
t = float(item["timestamp"])
|
||||
if not replacing and any(abs(t - old) < 0.35 for old in existing_timestamps):
|
||||
continue
|
||||
idx = next_idx + len(renamed)
|
||||
dst = frames_dir / f"{idx:03d}.jpg"
|
||||
run([
|
||||
"ffmpeg", "-y", "-ss", f"{t:.3f}", "-i", str(mp4),
|
||||
"-frames:v", "1",
|
||||
@@ -653,20 +668,24 @@ async def pipeline_analyze(
|
||||
str(dst),
|
||||
])
|
||||
renamed.append(KeyFrame(
|
||||
index=i,
|
||||
index=idx,
|
||||
timestamp=round(t, 2),
|
||||
url=f"/jobs/{job_id}/frames/{i}.jpg",
|
||||
url=f"/jobs/{job_id}/frames/{idx}.jpg",
|
||||
))
|
||||
existing_timestamps.append(t)
|
||||
|
||||
# 4) 清理扫描目录
|
||||
shutil.rmtree(scan_dir, ignore_errors=True)
|
||||
|
||||
merged_frames = sorted(existing_frames + renamed, key=lambda f: f.timestamp)
|
||||
action_label = "追加" if not replacing else "抽取"
|
||||
|
||||
update(
|
||||
job,
|
||||
status="frames_extracted",
|
||||
frames=renamed,
|
||||
frames=merged_frames,
|
||||
progress=70,
|
||||
message=f"已按「{target_label}」抽取 {len(renamed)} 张关键帧 · 可继续清洗 / 提取元素 / 分镜编排",
|
||||
message=f"已按「{target_label}」{action_label} {len(renamed)} 张关键帧 · 共 {len(merged_frames)} 张",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -1040,13 +1059,14 @@ async def trigger_analyze(
|
||||
bg: BackgroundTasks,
|
||||
frames: int = KEYFRAME_COUNT,
|
||||
target: FrameExtractTarget = "balanced",
|
||||
mode: FrameExtractMode = "replace",
|
||||
) -> Job:
|
||||
job = JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
if job.status not in {"downloaded", "frames_extracted", "transcribed", "failed"}:
|
||||
raise HTTPException(409, f"status must be downloaded/failed, got {job.status}")
|
||||
bg.add_task(pipeline_analyze, job_id, frames, target)
|
||||
bg.add_task(pipeline_analyze, job_id, frames, target, mode)
|
||||
return job
|
||||
|
||||
|
||||
|
||||
@@ -701,7 +701,7 @@ api/main.py
|
||||
<tr><td>创建任务</td><td><code>POST /jobs</code></td><td><code>createJob</code></td><td>提交 TK 链接,后台开始下载,停在 downloaded 等用户点解析。</td></tr>
|
||||
<tr><td>上传视频</td><td><code>POST /jobs/upload</code></td><td><code>uploadJob</code></td><td>保存 source.mp4,然后同样进入下载完成状态。</td></tr>
|
||||
<tr><td>删除输入视频</td><td><code>DELETE /jobs/{id}</code></td><td><code>deleteJob</code></td><td>从任务队列、URL 和磁盘 <code>jobs/<id></code> 目录移除整个 job,包括源视频、关键帧、元素提取图和生成视频。</td></tr>
|
||||
<tr><td>解析视频</td><td><code>POST /jobs/{id}/analyze?frames=&target=</code></td><td><code>analyzeJob</code></td><td>拆轨 + 目标化抽关键帧。<code>target</code> 支持综合、清晰主体、转场变化、表情瞬间、动作峰值;当前不自动跑 ASR,避免 audio 阻塞视觉管线。</td></tr>
|
||||
<tr><td>解析视频</td><td><code>POST /jobs/{id}/analyze?frames=&target=&mode=</code></td><td><code>analyzeJob</code></td><td>拆轨 + 目标化抽关键帧。<code>target</code> 支持综合、清晰主体、转场变化、表情瞬间、动作峰值;<code>mode=append</code> 追加新关键帧,<code>mode=replace</code> 重抽覆盖。</td></tr>
|
||||
<tr><td>手动加帧</td><td><code>POST /jobs/{id}/frames?t=</code></td><td><code>addManualFrame</code></td><td>按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。</td></tr>
|
||||
<tr><td>Vision 识别</td><td><code>POST /frames/{idx}/describe</code></td><td><code>describeFrame</code></td><td>写入 frame.description,后续可从 objects 加候选元素。</td></tr>
|
||||
<tr><td>清洗水印</td><td><code>POST /frames/{idx}/cleanup</code></td><td><code>cleanupFrame</code></td><td>支持全图和区域清洗,生成 cleaned 待应用版本。</td></tr>
|
||||
@@ -723,7 +723,7 @@ api/main.py
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="tag blue">输入 Input</span></td>
|
||||
<td>创建/上传任务,显示视频就绪,选择抽帧目标并触发解析;单击视频缩略图打开画布内抽帧面板。</td>
|
||||
<td>创建/上传任务,显示视频就绪;视频缩略图上方提供自动抽帧快捷工具条,可快速选目标、张数并多次追加;单击视频缩略图打开画布内抽帧面板。</td>
|
||||
<td>不要自动一路跑到 ASR 或生图;用户需要控制解析节奏。</td>
|
||||
<td><code>page.tsx</code>、<code>InputNode</code>、<code>VideoFramePanelNode</code>、<code>api/main.py</code></td>
|
||||
</tr>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import {
|
||||
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
|
||||
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo,
|
||||
type Job, type ImageRef, type StoryboardScene, type FrameExtractTarget,
|
||||
type Job, type ImageRef, type StoryboardScene, type FrameExtractMode, type FrameExtractTarget,
|
||||
} from "@/lib/api"
|
||||
|
||||
const NODE_TYPES = {
|
||||
@@ -93,6 +93,7 @@ export default function Home() {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [frameTarget, setFrameTarget] = useState<FrameExtractTarget>("balanced")
|
||||
const [frameCount, setFrameCount] = useState(5)
|
||||
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
|
||||
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
|
||||
const [framePanelScale, setFramePanelScale] = useState(1)
|
||||
@@ -166,21 +167,27 @@ export default function Home() {
|
||||
}
|
||||
}, [addJob])
|
||||
|
||||
const handleAnalyze = useCallback(async () => {
|
||||
const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => {
|
||||
if (!job) return
|
||||
const mode = options?.mode ?? (job.frames.length > 0 ? "append" : "replace")
|
||||
setAnalyzing(true)
|
||||
setSelectedFrames(new Set())
|
||||
if (mode === "replace") setSelectedFrames(new Set())
|
||||
try {
|
||||
await analyzeJob(job.id, 5, frameTarget)
|
||||
toast.info(`开始解析:拆轨 → ${FRAME_TARGET_LABELS[frameTarget]}抽帧。声音文案轨单独处理`)
|
||||
await analyzeJob(job.id, frameCount, frameTarget, mode)
|
||||
toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}:${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`)
|
||||
// 乐观更新本地状态,让轮询 useEffect 重新启动
|
||||
setJob((prev) => prev ? { ...prev, status: "splitting", message: `拆轨中 · ${FRAME_TARGET_LABELS[frameTarget]}…`, progress: 30 } : prev)
|
||||
setJob((prev) => prev ? {
|
||||
...prev,
|
||||
status: "splitting",
|
||||
message: `${mode === "append" ? "追加抽帧中" : "拆轨中"} · ${FRAME_TARGET_LABELS[frameTarget]}…`,
|
||||
progress: 30,
|
||||
} : prev)
|
||||
} catch (e) {
|
||||
toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}, [job?.id, frameTarget])
|
||||
}, [job?.id, job?.frames.length, frameCount, frameTarget])
|
||||
|
||||
const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => {
|
||||
try {
|
||||
@@ -505,6 +512,7 @@ export default function Home() {
|
||||
submitting,
|
||||
analyzing,
|
||||
frameTarget,
|
||||
frameCount,
|
||||
selectedFrames,
|
||||
expandedFrame,
|
||||
framePanelScale,
|
||||
@@ -517,6 +525,7 @@ export default function Home() {
|
||||
onUploadFile: handleUpload,
|
||||
onAnalyze: handleAnalyze,
|
||||
onFrameTargetChange: setFrameTarget,
|
||||
onFrameCountChange: setFrameCount,
|
||||
onToggleFrame: handleToggleFrame,
|
||||
onExpandFrame: setExpandedFrame,
|
||||
onOpenFramePanel: handleOpenFramePanel,
|
||||
@@ -546,7 +555,7 @@ export default function Home() {
|
||||
onCopyImage: handleCopyImage,
|
||||
pinnedNodes,
|
||||
onToggleNodePin: handleToggleNodePin,
|
||||
}), [job, jobs, activeJobId, submitting, analyzing, frameTarget, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin])
|
||||
}), [job, jobs, activeJobId, submitting, analyzing, frameTarget, frameCount, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin])
|
||||
|
||||
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||||
const savedSizes = useMemo(() => loadNodeSizes(), [])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface KeyFrame {
|
||||
}
|
||||
|
||||
export type FrameExtractTarget = "balanced" | "subject" | "transition" | "expression" | "motion"
|
||||
export type FrameExtractMode = "replace" | "append"
|
||||
|
||||
export interface TranscriptSegment {
|
||||
index: number
|
||||
@@ -261,8 +262,13 @@ export async function triggerTranscribe(id: string): Promise<Job> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function analyzeJob(id: string, frames = 5, target: FrameExtractTarget = "balanced"): Promise<Job> {
|
||||
const qs = new URLSearchParams({ frames: String(frames), target })
|
||||
export async function analyzeJob(
|
||||
id: string,
|
||||
frames = 5,
|
||||
target: FrameExtractTarget = "balanced",
|
||||
mode: FrameExtractMode = "replace",
|
||||
): Promise<Job> {
|
||||
const qs = new URLSearchParams({ frames: String(frames), target, mode })
|
||||
const res = await fetch(`${API_BASE}/jobs/${id}/analyze?${qs.toString()}`, { method: "POST" })
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "")
|
||||
|
||||
Reference in New Issue
Block a user