auto-save 2026-05-14 04:21 (~6)
This commit is contained in:
@@ -3173,6 +3173,19 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 04:10 (~4)",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-14T04:15:56+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-14 04:15 (~4)",
|
||||
"hash": "b52642b",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T20:18:50Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 6 项未提交变更 · 最近提交:auto-save 2026-05-14 04:15 (~4)",
|
||||
"files_changed": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
18
api/main.py
18
api/main.py
@@ -607,6 +607,7 @@ async def pipeline_analyze(
|
||||
frame_count: int = KEYFRAME_COUNT,
|
||||
target: FrameExtractTarget = "balanced",
|
||||
mode: FrameExtractMode = "replace",
|
||||
quality: FrameExtractQuality = "accurate",
|
||||
) -> None:
|
||||
"""阶段 2:拆音轨 + 抽关键帧。ASR/翻译是独立文案轨,不阻塞视觉素材流。"""
|
||||
job = JOBS[job_id]
|
||||
@@ -629,11 +630,11 @@ async def pipeline_analyze(
|
||||
|
||||
n = max(1, min(int(frame_count), 20))
|
||||
target_label = FRAME_TARGET_LABELS.get(target, FRAME_TARGET_LABELS["balanced"])
|
||||
quality_label = FRAME_QUALITY_LABELS.get(quality, FRAME_QUALITY_LABELS["accurate"])
|
||||
duration = max(float(job.duration or 1.0), 0.1)
|
||||
scan_fps = min(2.0, max(0.02, 180.0 / duration))
|
||||
estimated_scan_count = max(1, int(duration * scan_fps))
|
||||
scan_fps, scan_width, metric_width, estimated_scan_count = _scan_profile(duration, quality)
|
||||
|
||||
update(job, message=f"低清扫描候选 · {target_label} · 约 {estimated_scan_count} 帧…", progress=45)
|
||||
update(job, message=f"本地{quality_label}扫描 · {target_label} · 约 {estimated_scan_count} 帧…", progress=45)
|
||||
frames_dir = d / "frames"
|
||||
replacing = mode == "replace"
|
||||
existing_frames = list(job.frames) if not replacing else []
|
||||
@@ -648,7 +649,7 @@ async def pipeline_analyze(
|
||||
# 1) 低分辨率、低帧率扫描。扫描图只用于候选评分,最终不直接作为关键帧。
|
||||
run([
|
||||
"ffmpeg", "-y", "-i", str(mp4),
|
||||
"-vf", f"fps={scan_fps:.4f},scale=360:-2",
|
||||
"-vf", f"fps={scan_fps:.4f},scale={scan_width}:-2",
|
||||
"-q:v", "4",
|
||||
str(scan_dir / "s_%05d.jpg"),
|
||||
])
|
||||
@@ -660,7 +661,7 @@ async def pipeline_analyze(
|
||||
candidates: list[dict] = []
|
||||
for i, p in enumerate(scan_paths):
|
||||
t = min(i / scan_fps, max(duration - 0.05, 0.0))
|
||||
item = _frame_metrics(p, i, t)
|
||||
item = _frame_metrics(p, i, t, metric_width)
|
||||
if item:
|
||||
candidates.append(item)
|
||||
if not candidates:
|
||||
@@ -668,7 +669,7 @@ async def pipeline_analyze(
|
||||
|
||||
# 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)
|
||||
update(job, message=f"{quality_label}筛选 · {target_label} · {n} / {len(candidates)} 张…", progress=60)
|
||||
chosen = _select_keyframes(candidates, selection_count, target)
|
||||
|
||||
# 3) 只对最终选中的时间点,从原视频抽高质量关键帧。
|
||||
@@ -708,7 +709,7 @@ async def pipeline_analyze(
|
||||
status="frames_extracted",
|
||||
frames=merged_frames,
|
||||
progress=70,
|
||||
message=f"已按「{target_label}」{action_label} {len(renamed)} 张关键帧 · 共 {len(merged_frames)} 张",
|
||||
message=f"已按「{quality_label} · {target_label}」{action_label} {len(renamed)} 张关键帧 · 共 {len(merged_frames)} 张",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -1083,13 +1084,14 @@ async def trigger_analyze(
|
||||
frames: int = KEYFRAME_COUNT,
|
||||
target: FrameExtractTarget = "balanced",
|
||||
mode: FrameExtractMode = "replace",
|
||||
quality: FrameExtractQuality = "accurate",
|
||||
) -> 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, mode)
|
||||
bg.add_task(pipeline_analyze, job_id, frames, target, mode, quality)
|
||||
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=&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}/analyze?frames=&target=&mode=&quality=</code></td><td><code>analyzeJob</code></td><td>拆轨 + 目标化抽关键帧。<code>target</code> 支持综合、清晰主体、转场变化、表情瞬间、动作峰值;<code>mode=append</code> 追加新关键帧;<code>quality</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>
|
||||
@@ -817,6 +817,18 @@ api/main.py
|
||||
<h2>变更记录</h2>
|
||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||
<div class="changelog">
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-14 · 自动抽帧增加本地精度模式</h3>
|
||||
<span class="tag violet">Input</span>
|
||||
<span class="tag blue">Performance</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>当前抽帧默认偏轻量,用户机器是 Apple M2 Max、12 核 CPU、38 核 GPU、64GB 内存,足以承受更密集的本地候选扫描;但需要在 UI 里提示算力档位,避免长视频误用重模式。</p>
|
||||
<p><strong>改动:</strong>后端 <code>/jobs/{id}/analyze</code> 新增 <code>quality</code> 参数:快速为 2fps/360px,精细为 8fps/720px,极准为 12fps/960px;高精度模式还会提高本地评分图分辨率,保留竖屏比例,不再把竖屏压扁到 16:9。每个缩略图工具条新增精度选择。</p>
|
||||
<p><strong>影响:</strong><code>api/main.py</code>、<code>web/lib/api.ts</code>、<code>web/app/page.tsx</code>、<code>web/components/nodes/index.tsx</code>、<code>docs/source-analysis.html</code>。实测本机 64.5s、1080×1920、60fps 视频,12fps/960px 扫描 774 张候选约 2.61s;重型 ffmpeg 场景/模糊滤镜约 21.63s,因此继续使用本地 PIL/NumPy 评分更划算。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-14 · 每个输入视频缩略图绑定自己的抽帧工具条</h3>
|
||||
@@ -825,7 +837,7 @@ api/main.py
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>统一放在缩略图浮条上方的抽帧工具条仍然不够明确,用户无法一眼判断当前会抽哪一个视频。</p>
|
||||
<p><strong>改动:</strong>抽帧目标、张数和抽帧按钮改为渲染在每个视频缩略图正上方,并且每个 job 独立保存目标和张数设置。点击某个缩略图上方的抽帧按钮时,前端直接把该 <code>jobId</code> 传给 <code>analyzeJob</code>,同时切换 active job 并进入该 job 的进度轮询。</p>
|
||||
<p><strong>改动:</strong>抽帧目标、张数、精度和抽帧按钮改为渲染在每个视频缩略图正上方,并且每个 job 独立保存目标、张数和精度设置。点击某个缩略图上方的抽帧按钮时,前端直接把该 <code>jobId</code> 传给 <code>analyzeJob</code>,同时切换 active job 并进入该 job 的进度轮询。</p>
|
||||
<p><strong>影响:</strong><code>web/app/page.tsx</code>、<code>web/components/nodes/index.tsx</code>、<code>docs/source-analysis.html</code>。后续与输入视频相关的快捷操作都应优先贴近对应缩略图,不再依赖全局当前选择的心智。</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -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 FrameExtractMode, type FrameExtractTarget,
|
||||
type Job, type ImageRef, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
|
||||
} from "@/lib/api"
|
||||
|
||||
const NODE_TYPES = {
|
||||
@@ -41,6 +41,11 @@ const FRAME_TARGET_LABELS: Record<FrameExtractTarget, string> = {
|
||||
expression: "表情瞬间",
|
||||
motion: "动作峰值",
|
||||
}
|
||||
const FRAME_QUALITY_LABELS: Record<FrameExtractQuality, string> = {
|
||||
fast: "快速",
|
||||
accurate: "精细",
|
||||
ultra: "极准",
|
||||
}
|
||||
|
||||
// 合并 input + download + split 为一个节点
|
||||
// 分叉:上路 input → visual lab ↘
|
||||
@@ -94,6 +99,7 @@ export default function Home() {
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [frameTargets, setFrameTargets] = useState<Record<string, FrameExtractTarget>>({})
|
||||
const [frameCounts, setFrameCounts] = useState<Record<string, number>>({})
|
||||
const [frameQualities, setFrameQualities] = useState<Record<string, FrameExtractQuality>>({})
|
||||
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
|
||||
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
|
||||
const [framePanelScale, setFramePanelScale] = useState(1)
|
||||
@@ -172,17 +178,18 @@ export default function Home() {
|
||||
if (!targetJob) return
|
||||
const frameTarget = frameTargets[jobId] ?? "balanced"
|
||||
const frameCount = frameCounts[jobId] ?? 5
|
||||
const frameQuality = frameQualities[jobId] ?? "accurate"
|
||||
const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace")
|
||||
setActiveJobId(jobId)
|
||||
setAnalyzing(true)
|
||||
if (mode === "replace") setSelectedFrames(new Set())
|
||||
try {
|
||||
await analyzeJob(jobId, frameCount, frameTarget, mode)
|
||||
toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}:${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`)
|
||||
await analyzeJob(jobId, frameCount, frameTarget, mode, frameQuality)
|
||||
toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}:${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`)
|
||||
setJobs((prev) => prev.map((item) => item.id === jobId ? {
|
||||
...item,
|
||||
status: "splitting",
|
||||
message: `${mode === "append" ? "追加抽帧中" : "拆轨中"} · ${FRAME_TARGET_LABELS[frameTarget]}…`,
|
||||
message: `${mode === "append" ? "追加抽帧中" : "拆轨中"} · ${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]}…`,
|
||||
progress: 30,
|
||||
} : item))
|
||||
} catch (e) {
|
||||
@@ -190,7 +197,7 @@ export default function Home() {
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}, [jobs, frameCounts, frameTargets])
|
||||
}, [jobs, frameCounts, frameQualities, frameTargets])
|
||||
|
||||
const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => {
|
||||
if (!job) return
|
||||
@@ -205,6 +212,10 @@ export default function Home() {
|
||||
setFrameCounts((prev) => ({ ...prev, [jobId]: Math.max(1, Math.min(20, count)) }))
|
||||
}, [])
|
||||
|
||||
const handleFrameQualityChange = useCallback((jobId: string, quality: FrameExtractQuality) => {
|
||||
setFrameQualities((prev) => ({ ...prev, [jobId]: quality }))
|
||||
}, [])
|
||||
|
||||
const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => {
|
||||
try {
|
||||
const updated = await addManualFrame(jobId, t)
|
||||
@@ -529,6 +540,7 @@ export default function Home() {
|
||||
analyzing,
|
||||
frameTargets,
|
||||
frameCounts,
|
||||
frameQualities,
|
||||
selectedFrames,
|
||||
expandedFrame,
|
||||
framePanelScale,
|
||||
@@ -543,6 +555,7 @@ export default function Home() {
|
||||
onAnalyzeJob: handleAnalyzeJob,
|
||||
onFrameTargetChange: handleFrameTargetChange,
|
||||
onFrameCountChange: handleFrameCountChange,
|
||||
onFrameQualityChange: handleFrameQualityChange,
|
||||
onToggleFrame: handleToggleFrame,
|
||||
onExpandFrame: setExpandedFrame,
|
||||
onOpenFramePanel: handleOpenFramePanel,
|
||||
@@ -572,7 +585,7 @@ export default function Home() {
|
||||
onCopyImage: handleCopyImage,
|
||||
pinnedNodes,
|
||||
onToggleNodePin: handleToggleNodePin,
|
||||
}), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin])
|
||||
}), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, 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 FrameExtractMode, type FrameExtractTarget,
|
||||
type Job, type ImageRef, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
|
||||
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
|
||||
} from "@/lib/api"
|
||||
import { FrameLightbox } from "@/components/lightbox"
|
||||
@@ -31,6 +31,7 @@ export interface NodeData {
|
||||
analyzing: boolean
|
||||
frameTargets: Record<string, FrameExtractTarget>
|
||||
frameCounts: Record<string, number>
|
||||
frameQualities: Record<string, FrameExtractQuality>
|
||||
selectedFrames: Set<number>
|
||||
expandedFrame: number | null
|
||||
framePanelScale?: number
|
||||
@@ -45,6 +46,7 @@ export interface NodeData {
|
||||
onAnalyzeJob: (jobId: string, options?: { mode?: FrameExtractMode }) => void
|
||||
onFrameTargetChange: (jobId: string, target: FrameExtractTarget) => void
|
||||
onFrameCountChange: (jobId: string, count: number) => void
|
||||
onFrameQualityChange: (jobId: string, quality: FrameExtractQuality) => void
|
||||
onToggleFrame: (idx: number) => void
|
||||
onExpandFrame: (idx: number) => void
|
||||
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
|
||||
@@ -132,6 +134,11 @@ const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hi
|
||||
{ value: "motion", label: "动作峰值", hint: "动作变化更明显" },
|
||||
]
|
||||
const FRAME_COUNT_OPTIONS = [3, 5, 8, 12]
|
||||
const FRAME_QUALITY_OPTIONS: Array<{ value: FrameExtractQuality; label: string; hint: string }> = [
|
||||
{ value: "fast", label: "快速", hint: "2fps / 360px,长视频省电" },
|
||||
{ value: "accurate", label: "精细", hint: "8fps / 720px,M2 Max 轻松可用" },
|
||||
{ value: "ultra", label: "极准", hint: "12fps / 960px,本机约 3 秒扫描 1 分钟视频" },
|
||||
]
|
||||
|
||||
function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) {
|
||||
if (!root) return { x: 160, y: 0 }
|
||||
@@ -410,23 +417,28 @@ function DeleteConfirmDialog({
|
||||
function FrameExtractQuickBar({
|
||||
target,
|
||||
count,
|
||||
quality,
|
||||
disabled,
|
||||
running,
|
||||
hasFrames,
|
||||
onTargetChange,
|
||||
onCountChange,
|
||||
onQualityChange,
|
||||
onAnalyze,
|
||||
}: {
|
||||
target: FrameExtractTarget
|
||||
count: number
|
||||
quality: FrameExtractQuality
|
||||
disabled: boolean
|
||||
running: boolean
|
||||
hasFrames: boolean
|
||||
onTargetChange: (target: FrameExtractTarget) => void
|
||||
onCountChange: (count: number) => void
|
||||
onQualityChange: (quality: FrameExtractQuality) => void
|
||||
onAnalyze: () => void
|
||||
}) {
|
||||
const option = FRAME_TARGET_OPTIONS.find((item) => item.value === target) ?? FRAME_TARGET_OPTIONS[0]
|
||||
const qualityOption = FRAME_QUALITY_OPTIONS.find((item) => item.value === quality) ?? FRAME_QUALITY_OPTIONS[1]
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -458,6 +470,18 @@ function FrameExtractQuickBar({
|
||||
<option key={item} value={item}>{item} 张</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={quality}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onQualityChange(e.target.value as FrameExtractQuality)}
|
||||
title={qualityOption.hint}
|
||||
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_QUALITY_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
@@ -525,36 +549,40 @@ 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 thumbNaturalWidth = ready && j.height ? Math.max(96, Math.round(THUMBNAIL_HEIGHT * j.width / j.height)) : 96
|
||||
const toolWidth = Math.max(148, thumbNaturalWidth)
|
||||
const target = d.frameTargets[j.id] ?? "balanced"
|
||||
const count = d.frameCounts[j.id] ?? 5
|
||||
const quality = d.frameQualities[j.id] ?? "accurate"
|
||||
const jHasFrames = j.frames.length > 0
|
||||
const jRunning = ["splitting", "transcribing"].includes(j.status)
|
||||
return (
|
||||
<div
|
||||
key={j.id}
|
||||
className="group relative flex shrink-0 flex-col gap-1.5"
|
||||
style={{ width: thumbWidth }}
|
||||
style={{ width: toolWidth }}
|
||||
>
|
||||
{ready ? (
|
||||
<FrameExtractQuickBar
|
||||
target={target}
|
||||
count={count}
|
||||
quality={quality}
|
||||
disabled={jRunning || d.analyzing}
|
||||
running={jRunning}
|
||||
hasFrames={jHasFrames}
|
||||
onTargetChange={(next) => d.onFrameTargetChange(j.id, next)}
|
||||
onCountChange={(next) => d.onFrameCountChange(j.id, next)}
|
||||
onQualityChange={(next) => d.onFrameQualityChange(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 ${
|
||||
className={`relative self-center 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 }}
|
||||
style={{ width: thumbNaturalWidth, height: THUMBNAIL_HEIGHT, aspectRatio: aspectStr }}
|
||||
onMouseEnter={(e) => setHoverPreviewJob({ id: j.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
|
||||
onMouseLeave={() => setHoverPreviewJob(null)}
|
||||
>
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface KeyFrame {
|
||||
|
||||
export type FrameExtractTarget = "balanced" | "subject" | "transition" | "expression" | "motion"
|
||||
export type FrameExtractMode = "replace" | "append"
|
||||
export type FrameExtractQuality = "fast" | "accurate" | "ultra"
|
||||
|
||||
export interface TranscriptSegment {
|
||||
index: number
|
||||
@@ -267,8 +268,9 @@ export async function analyzeJob(
|
||||
frames = 5,
|
||||
target: FrameExtractTarget = "balanced",
|
||||
mode: FrameExtractMode = "replace",
|
||||
quality: FrameExtractQuality = "accurate",
|
||||
): Promise<Job> {
|
||||
const qs = new URLSearchParams({ frames: String(frames), target, mode })
|
||||
const qs = new URLSearchParams({ frames: String(frames), target, mode, quality })
|
||||
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