auto-save 2026-05-14 03:53 (~5)

This commit is contained in:
2026-05-14 03:53:51 +08:00
parent 9572111254
commit 6eb1f98e06
5 changed files with 234 additions and 68 deletions

View File

@@ -3102,6 +3102,25 @@
"type": "session-heartbeat", "type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-14 03:42 (~3)", "message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-14 03:42 (~3)",
"files_changed": 1 "files_changed": 1
},
{
"ts": "2026-05-14T03:48:20+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 03:48 (~1)",
"hash": "9572111",
"files_changed": 1
},
{
"ts": "2026-05-13T19:48:49Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 1 项未提交变更 · 最近提交auto-save 2026-05-14 03:48 (~1)",
"files_changed": 1
},
{
"ts": "2026-05-13T19:53:12Z",
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 5 项未提交变更 · 最近提交auto-save 2026-05-14 03:48 (~1)",
"files_changed": 5
} }
] ]
} }

View File

@@ -88,6 +88,14 @@ JobStatus = Literal[
] ]
KEYFRAME_COUNT = int(os.getenv("KEYFRAME_COUNT", "5")) KEYFRAME_COUNT = int(os.getenv("KEYFRAME_COUNT", "5"))
FrameExtractTarget = Literal["balanced", "subject", "transition", "expression", "motion"]
FRAME_TARGET_LABELS: dict[FrameExtractTarget, str] = {
"balanced": "综合关键帧",
"subject": "清晰主体",
"transition": "转场变化",
"expression": "表情瞬间",
"motion": "动作峰值",
}
class GeneratedImage(BaseModel): class GeneratedImage(BaseModel):
@@ -383,37 +391,115 @@ import numpy as np
from PIL import Image, ImageEnhance, ImageFilter, ImageOps from PIL import Image, ImageEnhance, ImageFilter, ImageOps
def _sharpness(img_path: Path) -> float: def _sharpness_from_gray(g: np.ndarray) -> float:
"""Laplacian variance值越大越清晰模糊/转场帧值低。""" """Laplacian variance值越大越清晰模糊/转场帧值低。"""
g = np.asarray(Image.open(img_path).convert("L").resize((320, 180)), dtype=np.float32)
lap = (-4 * g[1:-1, 1:-1] lap = (-4 * g[1:-1, 1:-1]
+ g[:-2, 1:-1] + g[2:, 1:-1] + g[1:-1, :-2] + g[1:-1, 2:]) + g[:-2, 1:-1] + g[2:, 1:-1] + g[1:-1, :-2] + g[1:-1, 2:])
return float(lap.var()) return float(lap.var())
def _select_keyframes(candidates: list[Path], n: int, dup_threshold: int = 8) -> list[Path]: def _frame_metrics(img_path: Path, idx: int, timestamp: float) -> dict | None:
"""低清候选帧的本地评分特征。只用于排序,最终仍从原视频抽原尺寸帧。"""
try:
with Image.open(img_path) as raw:
img = raw.convert("RGB")
h = imagehash.phash(img)
small = img.resize((160, 90))
except Exception:
return None
arr = np.asarray(small, dtype=np.float32)
# Rec. 601 luma保留 0-255 范围,便于和清晰度 / 对比度阈值一起看。
gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.float32)
center = gray[22:68, 40:120]
rg = arr[:, :, 0] - arr[:, :, 1]
yb = 0.5 * (arr[:, :, 0] + arr[:, :, 1]) - arr[:, :, 2]
colorfulness = float(np.sqrt(rg.var() + yb.var()) + 0.3 * np.sqrt(rg.mean() ** 2 + yb.mean() ** 2))
return {
"path": img_path,
"idx": idx,
"timestamp": timestamp,
"hash": h,
"gray": gray,
"sharp": _sharpness_from_gray(gray),
"center_sharp": _sharpness_from_gray(center),
"brightness": float(gray.mean()),
"contrast": float(gray.std()),
"colorfulness": colorfulness,
"scene_score": 0.0,
"motion": 0.0,
}
def _attach_temporal_metrics(items: list[dict]) -> None:
"""相邻低清帧差异:转场 / 动作目标依赖它,不需要逐帧高分辨率扫描。"""
for i, it in enumerate(items):
prev_delta = 0.0
next_delta = 0.0
if i > 0:
prev_delta = float(np.mean(np.abs(it["gray"] - items[i - 1]["gray"])) / 255.0)
if i + 1 < len(items):
next_delta = float(np.mean(np.abs(items[i + 1]["gray"] - it["gray"])) / 255.0)
it["scene_score"] = max(prev_delta, next_delta)
it["motion"] = (prev_delta + next_delta) / 2.0
def _normalize_item_metrics(items: list[dict]) -> None:
for key in ("sharp", "center_sharp", "contrast", "colorfulness", "scene_score", "motion"):
vals = [float(it.get(key, 0.0)) for it in items if float(it.get(key, 0.0)) > 0]
cap = float(np.percentile(vals, 95)) if vals else 1.0
if cap <= 0:
cap = 1.0
for it in items:
it[f"{key}_n"] = min(float(it.get(key, 0.0)) / cap, 1.0)
def _target_score(item: dict, target: FrameExtractTarget) -> float:
sharp = float(item.get("sharp_n", 0.0))
center = float(item.get("center_sharp_n", 0.0))
contrast = float(item.get("contrast_n", 0.0))
color = float(item.get("colorfulness_n", 0.0))
scene = float(item.get("scene_score_n", 0.0))
motion = float(item.get("motion_n", 0.0))
if target == "subject":
score = center * 0.48 + sharp * 0.25 + contrast * 0.17 + color * 0.10
elif target == "transition":
score = scene * 0.55 + sharp * 0.28 + contrast * 0.12 + color * 0.05
elif target == "expression":
# 没有额外视觉模型时,表情/动物瞬间只能用中心细节 + 清晰 + 轻微动作变化做本地近似。
score = center * 0.40 + sharp * 0.24 + motion * 0.18 + contrast * 0.12 + color * 0.06
elif target == "motion":
score = motion * 0.45 + sharp * 0.30 + center * 0.15 + contrast * 0.10
else:
score = sharp * 0.45 + scene * 0.22 + center * 0.15 + contrast * 0.12 + color * 0.06
brightness = float(item.get("brightness", 0.0))
raw_contrast = float(item.get("contrast", 0.0))
if raw_contrast < 4 or brightness < 8 or brightness > 247:
return score * 0.15
if raw_contrast < 9:
return score * 0.65
return score
def _select_keyframes(candidates: list[dict], n: int, target: FrameExtractTarget, dup_threshold: int = 8) -> list[dict]:
""" """
candidates: 按时间排序的候选帧路径 candidates: 按时间排序的低清候选帧评分项
n: 目标帧数 n: 目标帧数
dup_threshold: pHash 汉明距离 < 此值视为相似(默认 864bit hash 大致 ~12.5% 像素差) dup_threshold: pHash 汉明距离 < 此值视为相似(默认 864bit hash 大致 ~12.5% 像素差)
""" """
if len(candidates) <= n: if len(candidates) <= n:
return candidates return candidates
# 算 pHash + sharpness _attach_temporal_metrics(candidates)
items = [] _normalize_item_metrics(candidates)
for i, p in enumerate(candidates): for it in candidates:
try: it["score"] = _target_score(it, target)
img = Image.open(p)
h = imagehash.phash(img)
s = _sharpness(p)
items.append({"path": p, "idx": i, "hash": h, "sharp": s})
except Exception:
continue
# 去重:相似帧保留 sharpness 高的 # 去重:相似帧保留当前目标下分数更高的
deduped: list[dict] = [] deduped: list[dict] = []
for it in items: for it in candidates:
dup = None dup = None
for kept in deduped: for kept in deduped:
if (it["hash"] - kept["hash"]) < dup_threshold: if (it["hash"] - kept["hash"]) < dup_threshold:
@@ -421,10 +507,10 @@ def _select_keyframes(candidates: list[Path], n: int, dup_threshold: int = 8) ->
break break
if dup is None: if dup is None:
deduped.append(it) deduped.append(it)
elif it["sharp"] > dup["sharp"]: elif it["score"] > dup["score"]:
deduped[deduped.index(dup)] = it deduped[deduped.index(dup)] = it
# 时序分桶:把候选时间轴等分 n 段,每段取去重后 sharpness 最高 # 时序分桶:把候选时间轴等分 n 段,每段取当前目标下最优
total = len(candidates) total = len(candidates)
buckets: list[list[dict]] = [[] for _ in range(n)] buckets: list[list[dict]] = [[] for _ in range(n)]
for it in deduped: for it in deduped:
@@ -434,18 +520,18 @@ def _select_keyframes(candidates: list[Path], n: int, dup_threshold: int = 8) ->
selected: list[dict] = [] selected: list[dict] = []
for b in buckets: for b in buckets:
if b: if b:
selected.append(max(b, key=lambda x: x["sharp"])) selected.append(max(b, key=lambda x: x["score"]))
# 空桶补足:从未选的 deduped 里按 sharpness 排序 # 空桶补足:从未选的 deduped 里按目标分数
chosen_paths = {it["path"] for it in selected} chosen_paths = {it["path"] for it in selected}
remaining = sorted([it for it in deduped if it["path"] not in chosen_paths], remaining = sorted([it for it in deduped if it["path"] not in chosen_paths],
key=lambda x: -x["sharp"]) key=lambda x: -x["score"])
while len(selected) < n and remaining: while len(selected) < n and remaining:
selected.append(remaining.pop(0)) selected.append(remaining.pop(0))
# 按时间排序输出 # 按时间排序输出
selected.sort(key=lambda x: x["idx"]) selected.sort(key=lambda x: x["idx"])
return [it["path"] for it in selected] return selected
def ffprobe_meta(mp4: Path) -> dict: def ffprobe_meta(mp4: Path) -> dict:
@@ -492,7 +578,11 @@ async def pipeline_download(job_id: str) -> None:
update(job, status="failed", error=str(e), message="下载失败") update(job, status="failed", error=str(e), message="下载失败")
async def pipeline_analyze(job_id: str, frame_count: int = KEYFRAME_COUNT) -> None: async def pipeline_analyze(
job_id: str,
frame_count: int = KEYFRAME_COUNT,
target: FrameExtractTarget = "balanced",
) -> None:
"""阶段 2拆音轨 + 抽关键帧。ASR/翻译是独立文案轨,不阻塞视觉素材流。""" """阶段 2拆音轨 + 抽关键帧。ASR/翻译是独立文案轨,不阻塞视觉素材流。"""
job = JOBS[job_id] job = JOBS[job_id]
d = job_dir(job_id) d = job_dir(job_id)
@@ -510,62 +600,73 @@ async def pipeline_analyze(job_id: str, frame_count: int = KEYFRAME_COUNT) -> No
]) ])
n = max(1, min(int(frame_count), 20)) n = max(1, min(int(frame_count), 20))
# 候选数n 的 6 倍或至少 24封顶 60 target_label = FRAME_TARGET_LABELS.get(target, FRAME_TARGET_LABELS["balanced"])
candidate_count = max(24, min(60, n * 6)) 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))
update(job, message=f"抽取候选 {candidate_count} ", progress=45) update(job, message=f"低清扫描候选 · {target_label} · 约 {estimated_scan_count} ", progress=45)
frames_dir = d / "frames" frames_dir = d / "frames"
if frames_dir.exists(): if frames_dir.exists():
shutil.rmtree(frames_dir) shutil.rmtree(frames_dir)
frames_dir.mkdir(parents=True) frames_dir.mkdir(parents=True)
cand_dir = d / "candidates" scan_dir = d / "frame_scan"
if cand_dir.exists(): if scan_dir.exists():
shutil.rmtree(cand_dir) shutil.rmtree(scan_dir)
cand_dir.mkdir(parents=True) scan_dir.mkdir(parents=True)
# 1) 均匀采样大批候选fast seek每张 < 0.5s # 1) 低分辨率、低帧率扫描。扫描图只用于候选评分,最终不直接作为关键帧。
duration = max(float(job.duration or 1.0), 0.1) run([
step = duration / (candidate_count + 1) "ffmpeg", "-y", "-i", str(mp4),
candidate_meta: list[tuple[Path, float]] = [] # (path, timestamp) "-vf", f"fps={scan_fps:.4f},scale=360:-2",
for i in range(candidate_count): "-q:v", "4",
t = step * (i + 1) str(scan_dir / "s_%05d.jpg"),
out = cand_dir / f"c_{i:03d}.jpg" ])
scan_paths = sorted(scan_dir.glob("s_*.jpg"))
if not scan_paths:
raise RuntimeError("低清扫描没有生成候选帧")
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)
if item:
candidates.append(item)
if not candidates:
raise RuntimeError("候选帧评分失败")
# 2) 目标化筛选pHash 去重 + 清晰度 / 中心细节 / 转场变化 / 动作强度 + 时序分桶。
update(job, message=f"{target_label}筛选 {n} / {len(candidates)} 张…", progress=60)
chosen = _select_keyframes(candidates, n, 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"
t = float(item["timestamp"])
run([ run([
"ffmpeg", "-y", "-ss", str(t), "-i", str(mp4), "ffmpeg", "-y", "-ss", f"{t:.3f}", "-i", str(mp4),
"-frames:v", "1", "-frames:v", "1",
"-pix_fmt", "yuvj420p", "-q:v", "3", "-pix_fmt", "yuvj420p", "-q:v", "3",
str(out), str(dst),
]) ])
if out.exists():
candidate_meta.append((out, t))
# 2) D 启发式选 n 张pHash 去重 + Laplacian 清晰度 + 时序分桶
update(job, message=f"启发式筛选 {n} / {len(candidate_meta)} 张…", progress=60)
cand_paths = [m[0] for m in candidate_meta]
ts_by_path = {m[0]: m[1] for m in candidate_meta}
chosen = _select_keyframes(cand_paths, n)
# 3) 落盘到 frames/<idx>.jpg
renamed: list[KeyFrame] = []
chosen_sorted = sorted(chosen, key=lambda p: ts_by_path[p])
for i, src in enumerate(chosen_sorted):
dst = frames_dir / f"{i:03d}.jpg"
shutil.copyfile(src, dst)
renamed.append(KeyFrame( renamed.append(KeyFrame(
index=i, index=i,
timestamp=round(ts_by_path[src], 2), timestamp=round(t, 2),
url=f"/jobs/{job_id}/frames/{i}.jpg", url=f"/jobs/{job_id}/frames/{i}.jpg",
)) ))
# 4) 清理候选目录 # 4) 清理扫描目录
shutil.rmtree(cand_dir, ignore_errors=True) shutil.rmtree(scan_dir, ignore_errors=True)
update( update(
job, job,
status="frames_extracted", status="frames_extracted",
frames=renamed, frames=renamed,
progress=70, progress=70,
message=f"已抽取 {len(renamed)} 张关键帧 · 可继续清洗 / 提取元素 / 分镜编排", message=f"按「{target_label}抽取 {len(renamed)} 张关键帧 · 可继续清洗 / 提取元素 / 分镜编排",
) )
except Exception as e: except Exception as e:
@@ -934,13 +1035,18 @@ async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(..
@app.post("/jobs/{job_id}/analyze", response_model=Job) @app.post("/jobs/{job_id}/analyze", response_model=Job)
async def trigger_analyze(job_id: str, bg: BackgroundTasks, frames: int = KEYFRAME_COUNT) -> Job: async def trigger_analyze(
job_id: str,
bg: BackgroundTasks,
frames: int = KEYFRAME_COUNT,
target: FrameExtractTarget = "balanced",
) -> Job:
job = JOBS.get(job_id) job = JOBS.get(job_id)
if not job: if not job:
raise HTTPException(404, "job not found") raise HTTPException(404, "job not found")
if job.status not in {"downloaded", "frames_extracted", "transcribed", "failed"}: if job.status not in {"downloaded", "frames_extracted", "transcribed", "failed"}:
raise HTTPException(409, f"status must be downloaded/failed, got {job.status}") raise HTTPException(409, f"status must be downloaded/failed, got {job.status}")
bg.add_task(pipeline_analyze, job_id, frames) bg.add_task(pipeline_analyze, job_id, frames, target)
return job return job

View File

@@ -19,7 +19,7 @@ import { ThemeToggle } from "@/components/theme-toggle"
import { import {
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage, addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, deleteGeneratedVideo, deleteCutout, generateStoryboardVideo,
type Job, type ImageRef, type StoryboardScene, type Job, type ImageRef, type StoryboardScene, type FrameExtractTarget,
} from "@/lib/api" } from "@/lib/api"
const NODE_TYPES = { const NODE_TYPES = {
@@ -34,6 +34,13 @@ const NODE_TYPES = {
const KEYFRAME_PANEL_ID = "keyframe-detail-panel" const KEYFRAME_PANEL_ID = "keyframe-detail-panel"
const VIDEO_FRAME_PANEL_ID = "video-frame-panel" const VIDEO_FRAME_PANEL_ID = "video-frame-panel"
const FLOATING_PANEL_IDS = new Set([KEYFRAME_PANEL_ID, VIDEO_FRAME_PANEL_ID]) const FLOATING_PANEL_IDS = new Set([KEYFRAME_PANEL_ID, VIDEO_FRAME_PANEL_ID])
const FRAME_TARGET_LABELS: Record<FrameExtractTarget, string> = {
balanced: "综合关键帧",
subject: "清晰主体",
transition: "转场变化",
expression: "表情瞬间",
motion: "动作峰值",
}
// 合并 input + download + split 为一个节点 // 合并 input + download + split 为一个节点
// 分叉:上路 input → visual lab ↘ // 分叉:上路 input → visual lab ↘
@@ -85,6 +92,7 @@ export default function Home() {
const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId]) const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId])
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [analyzing, setAnalyzing] = useState(false) const [analyzing, setAnalyzing] = useState(false)
const [frameTarget, setFrameTarget] = useState<FrameExtractTarget>("balanced")
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set()) const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
const [expandedFrame, setExpandedFrame] = useState<number | null>(null) const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
const [framePanelScale, setFramePanelScale] = useState(1) const [framePanelScale, setFramePanelScale] = useState(1)
@@ -163,16 +171,16 @@ export default function Home() {
setAnalyzing(true) setAnalyzing(true)
setSelectedFrames(new Set()) setSelectedFrames(new Set())
try { try {
await analyzeJob(job.id, 5) await analyzeJob(job.id, 5, frameTarget)
toast.info("开始解析:拆轨 → 抽帧。声音文案轨单独处理") toast.info(`开始解析:拆轨 → ${FRAME_TARGET_LABELS[frameTarget]}抽帧。声音文案轨单独处理`)
// 乐观更新本地状态,让轮询 useEffect 重新启动 // 乐观更新本地状态,让轮询 useEffect 重新启动
setJob((prev) => prev ? { ...prev, status: "splitting", message: "拆轨中…", progress: 30 } : prev) setJob((prev) => prev ? { ...prev, status: "splitting", message: `拆轨中 · ${FRAME_TARGET_LABELS[frameTarget]}`, progress: 30 } : prev)
} catch (e) { } catch (e) {
toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e))) toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e)))
} finally { } finally {
setAnalyzing(false) setAnalyzing(false)
} }
}, [job?.id]) }, [job?.id, frameTarget])
const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => { const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => {
try { try {
@@ -496,6 +504,7 @@ export default function Home() {
activeJobId, activeJobId,
submitting, submitting,
analyzing, analyzing,
frameTarget,
selectedFrames, selectedFrames,
expandedFrame, expandedFrame,
framePanelScale, framePanelScale,
@@ -507,6 +516,7 @@ export default function Home() {
onSubmitUrl: handleSubmit, onSubmitUrl: handleSubmit,
onUploadFile: handleUpload, onUploadFile: handleUpload,
onAnalyze: handleAnalyze, onAnalyze: handleAnalyze,
onFrameTargetChange: setFrameTarget,
onToggleFrame: handleToggleFrame, onToggleFrame: handleToggleFrame,
onExpandFrame: setExpandedFrame, onExpandFrame: setExpandedFrame,
onOpenFramePanel: handleOpenFramePanel, onOpenFramePanel: handleOpenFramePanel,

View File

@@ -16,7 +16,7 @@ import { toast } from "sonner"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell" import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import { HoverPreview } from "./hover-preview" import { HoverPreview } from "./hover-preview"
import { import {
type Job, type ImageRef, type Job, type ImageRef, type FrameExtractTarget,
apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl, apiAssetUrl, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl,
} from "@/lib/api" } from "@/lib/api"
import { FrameLightbox } from "@/components/lightbox" import { FrameLightbox } from "@/components/lightbox"
@@ -29,6 +29,7 @@ export interface NodeData {
activeJobId: string | null activeJobId: string | null
submitting: boolean submitting: boolean
analyzing: boolean analyzing: boolean
frameTarget: FrameExtractTarget
selectedFrames: Set<number> selectedFrames: Set<number>
expandedFrame: number | null expandedFrame: number | null
framePanelScale?: number framePanelScale?: number
@@ -40,6 +41,7 @@ export interface NodeData {
onSubmitUrl: (url: string) => void onSubmitUrl: (url: string) => void
onUploadFile: (file: File) => void onUploadFile: (file: File) => void
onAnalyze: () => void onAnalyze: () => void
onFrameTargetChange: (target: FrameExtractTarget) => void
onToggleFrame: (idx: number) => void onToggleFrame: (idx: number) => void
onExpandFrame: (idx: number) => void onExpandFrame: (idx: number) => void
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板 onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
@@ -119,6 +121,13 @@ function clamp(value: number, min: number, max: number) {
const THUMBNAIL_HEIGHT = 176 const THUMBNAIL_HEIGHT = 176
const FLOATING_PANEL_EDGE_INSET = 8 const FLOATING_PANEL_EDGE_INSET = 8
const FRAME_TARGET_OPTIONS: Array<{ value: FrameExtractTarget; label: string; hint: string }> = [
{ value: "balanced", label: "综合关键帧", hint: "清晰、去重、变化、时间覆盖" },
{ value: "subject", label: "清晰主体", hint: "人物 / 产品主体更清楚" },
{ value: "transition", label: "转场变化", hint: "切镜和画面变化优先" },
{ value: "expression", label: "表情瞬间", hint: "人物 / 动物表情倾向" },
{ value: "motion", label: "动作峰值", hint: "动作变化更明显" },
]
function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) { function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) {
if (!root) return { x: 160, y: 0 } if (!root) return { x: 160, y: 0 }
@@ -423,6 +432,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
const isDone = job?.status === "transcribed" const isDone = job?.status === "transcribed"
const hasFrames = (job?.frames.length ?? 0) > 0 const hasFrames = (job?.frames.length ?? 0) > 0
const inputLocked = isDownloading || d.submitting const inputLocked = isDownloading || d.submitting
const activeFrameTarget = FRAME_TARGET_OPTIONS.find((option) => option.value === d.frameTarget) ?? FRAME_TARGET_OPTIONS[0]
return ( return (
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}> <div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
@@ -607,6 +617,24 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
<span className="text-[var(--text-faint)]">{job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接"}</span> <span className="text-[var(--text-faint)]">{job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接"}</span>
</div> </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>
</div>
<div className="mt-1 truncate text-[10px] text-[var(--text-faint)]">{activeFrameTarget.hint}</div>
</label>
<button <button
type="button" type="button"
disabled={isAnalyzing || d.analyzing} disabled={isAnalyzing || d.analyzing}

View File

@@ -128,6 +128,8 @@ export interface KeyFrame {
generated_images?: GeneratedImage[] generated_images?: GeneratedImage[]
} }
export type FrameExtractTarget = "balanced" | "subject" | "transition" | "expression" | "motion"
export interface TranscriptSegment { export interface TranscriptSegment {
index: number index: number
start: number start: number
@@ -259,8 +261,9 @@ export async function triggerTranscribe(id: string): Promise<Job> {
return res.json() return res.json()
} }
export async function analyzeJob(id: string, frames = 5): Promise<Job> { export async function analyzeJob(id: string, frames = 5, target: FrameExtractTarget = "balanced"): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${id}/analyze?frames=${frames}`, { method: "POST" }) const qs = new URLSearchParams({ frames: String(frames), target })
const res = await fetch(`${API_BASE}/jobs/${id}/analyze?${qs.toString()}`, { method: "POST" })
if (!res.ok) { if (!res.ok) {
const t = await res.text().catch(() => "") const t = await res.text().catch(() => "")
throw new Error(`analyze ${res.status} ${t.slice(0, 200)}`) throw new Error(`analyze ${res.status} ${t.slice(0, 200)}`)