// 分镜素材库 (/shots-library) // 左:clip 卡片网格(封面 = keyframe[0],hover 缩略 + 标签) // 右:选中 clip → 完整 kh-spec sidecar 视图(summaries / chunks / relations / // previews / 用量 / 文件信息)+ 视频 + 3 张 keyframe const _gainBar = (pct, color) => (
); // 解析 URL 参数:bind_to / current / return const _parseBindParams = () => { const u = new URL(window.location.href); const bindTo = u.searchParams.get("bind_to") || ""; const [jobId, chunkId] = bindTo.includes(":") ? bindTo.split(":") : ["", ""]; return { bindMode: !!(jobId && chunkId), jobId, chunkId, currentClipId: u.searchParams.get("current") || "", returnUrl: u.searchParams.get("return") || "/remix", }; }; // 单 clip 卡片(左侧网格) const ClipCard = ({ clip, usage, active, bindable, isCurrent, deleting, scrubbing, onClick, onBind, onDelete, onScrub }) => { const lifePct = Math.round((usage?.life_pct || 0) * 100); const lifeColor = lifePct >= 86 ? "var(--pink)" : lifePct >= 57 ? "var(--orange)" : "var(--cyan)"; return (
{ e.currentTarget.style.display = "none"; }} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }}/> {clip.has_kh && ( kh ✓ )} {usage && ( {usage.total_uses}/{7} )}
{clip.caption || clip.name}
{usage && _gainBar(lifePct, lifeColor)} {bindable && ( )}
); }; // 详情面板:完整 kh sidecar const SidecarDetail = ({ clip, sidecar, usage, onIngest, ingestBusy, bindable, isCurrent, onBind, bindBusy }) => { if (!clip) return null; if (!sidecar) { return
加载 sidecar…
; } const noKh = sidecar._status === "no_sidecar"; const previewKeyframes = (sidecar.previews || []).filter(p => p.kind === "keyframe"); const seg = (sidecar.previews || []).find(p => p.kind === "segment"); return (
{/* 绑定模式:突出的「绑这条」按钮 */} {bindable && (
绑定这条到当前 chunk? {isCurrent ? "这就是当前已绑定的 clip。" : "确认后会替换掉当前绑定,并将原 clip 加入拒绝列表。"}
)} {/* 视频预览 */}
); }; const ShotsLibraryPage = () => { const [clips, setClips] = React.useState([]); const [usageMap, setUsageMap] = React.useState({}); const [stats, setStats] = React.useState(null); const [filter, setFilter] = React.useState("all"); const [activeId, setActiveId] = React.useState(null); const [sidecar, setSidecar] = React.useState(null); const [loading, setLoading] = React.useState(false); const [err, setErr] = React.useState(""); const [ingestAllBusy, setIngestAllBusy] = React.useState(false); const [ingestOneBusy, setIngestOneBusy] = React.useState(false); const [search, setSearch] = React.useState(""); const [bindBusy, setBindBusy] = React.useState(false); const [bindParams, setBindParams] = React.useState(_parseBindParams()); const [deletingId, setDeletingId] = React.useState(""); const [scrubbingId, setScrubbingId] = React.useState(""); const [scrubAllBusy, setScrubAllBusy] = React.useState(false); const [batchBusy, setBatchBusy] = React.useState(false); const [batchMsg, setBatchMsg] = React.useState(""); const batchInputRef = React.useRef(null); // "需重跑" 面板 const [staleOpen, setStaleOpen] = React.useState(false); const [staleItems, setStaleItems] = React.useState([]); const [staleLoading, setStaleLoading] = React.useState(false); const [reingestId, setReingestId] = React.useState(""); const [reingestAllBusy, setReingestAllBusy] = React.useState(false); const loadStale = async () => { setStaleLoading(true); try { const r = await API.seedLibrary.listStale(); setStaleItems(r.items || []); } catch (e) { setBatchMsg(`✗ 加载 stale 列表失败:${e.message || e}`); } finally { setStaleLoading(false); } }; const reingestOne = async (clipId) => { if (reingestId) return; setReingestId(clipId); try { await API.seedLibrary.ingestOne(clipId, true); await loadStale(); await load(); setBatchMsg(`✓ ${clipId} 已重跑`); } catch (e) { setBatchMsg(`✗ 重跑 ${clipId} 失败:${e.message || e}`); } finally { setReingestId(""); setTimeout(() => setBatchMsg(""), 4000); } }; const reingestAllStale = async () => { if (reingestAllBusy || !staleItems.length) return; if (!confirm(`确认对 ${staleItems.length} 条 stale clip 全部 force ingest?\n会逐条调 kh 服务,耗时按张数线性。`)) return; setReingestAllBusy(true); let ok = 0, fail = 0; for (const it of staleItems) { try { await API.seedLibrary.ingestOne(it.clip_id, true); ok++; setBatchMsg(`重跑中:${ok + fail} / ${staleItems.length}(✓ ${ok} · ✗ ${fail})`); } catch (_) { fail++; } } setReingestAllBusy(false); setBatchMsg(`✓ 批量重跑完成:成功 ${ok} · 失败 ${fail}`); await loadStale(); await load(); setTimeout(() => setBatchMsg(""), 6000); }; const onBatchPick = async (e) => { const files = Array.from(e.target.files || []); e.target.value = ""; // 允许重复选同一个文件 if (!files.length) return; setBatchBusy(true); setBatchMsg(`上传中:0 / ${files.length}`); try { const fd = new FormData(); for (const f of files) fd.append("files", f); const r = await API.seedLibrary.uploadClips(fd); const ok = r.ok_count || (r.uploaded || []).length; const fail = r.fail_count || (r.skipped || []).length; setBatchMsg(`✓ 上传 ${ok} · 跳过 ${fail}${fail ? " · " + (r.skipped[0]?.reason || "") : ""}`); await load(); } catch (err) { setBatchMsg(`✗ 上传失败:${err.message || err}`); } finally { setBatchBusy(false); setTimeout(() => setBatchMsg(""), 6000); } }; // URL 跳转回 / 前进时同步 bindParams + 默认选中当前 clip React.useEffect(() => { const onPop = () => setBindParams(_parseBindParams()); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); React.useEffect(() => { if (bindParams.bindMode && bindParams.currentClipId && !activeId) { setActiveId(bindParams.currentClipId); } }, [bindParams]); const doBind = async (clipId) => { if (!bindParams.bindMode || bindBusy) return; setBindBusy(true); setErr(""); try { await API.shotQc.manualBind(bindParams.jobId, bindParams.chunkId, clipId); // 跳回 /remix(带成功标记便于上游识别) const ret = bindParams.returnUrl || "/remix"; const sep = ret.includes("?") ? "&" : "?"; const url = `${ret}${sep}bound=${encodeURIComponent(clipId)}`; window.history.pushState({}, "", url); window.dispatchEvent(new PopStateEvent("popstate")); } catch (e) { setErr(String(e.message || e)); setBindBusy(false); } }; const cancelBind = () => { const ret = bindParams.returnUrl || "/remix"; window.history.pushState({}, "", ret); window.dispatchEvent(new PopStateEvent("popstate")); }; const load = async () => { setLoading(true); setErr(""); try { const [u, h] = await Promise.all([ API.seedLibrary.usage().catch(() => ({ items: [] })), API.seedLibrary.health().catch(() => null), ]); setStats(h); setClips(u.items || []); const m = {}; for (const it of u.items || []) { m[it.clip_id] = it; } setUsageMap(m); if (!activeId && (u.items || []).length) setActiveId(u.items[0].clip_id); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } }; const loadSidecar = async (clipId) => { if (!clipId) { setSidecar(null); return; } try { const s = await API.seedLibrary.sidecar(clipId); setSidecar(s); } catch (e) { setSidecar({ _status: "no_sidecar", _message: String(e.message || e) }); } }; React.useEffect(() => { load(); }, []); React.useEffect(() => { loadSidecar(activeId); }, [activeId]); const ingestAll = async () => { if (ingestAllBusy) return; setIngestAllBusy(true); try { await API.seedLibrary.ingestAll(false); await load(); if (activeId) await loadSidecar(activeId); } catch (e) { setErr(String(e.message || e)); } finally { setIngestAllBusy(false); } }; const scrubClip = async (clip) => { if (!clip || scrubbingId) return; if (!window.confirm(`清洗并扰乱「${clip.name || clip.clip_id}」的元数据 + 像素?\n会就地修改文件,不可恢复。`)) return; setScrubbingId(clip.clip_id); setErr(""); try { await API.postprocess.scrubClip(clip.clip_id); await load(); if (activeId === clip.clip_id) await loadSidecar(clip.clip_id); } catch (e) { setErr(`脱敏失败:${String(e.message || e)}`); } finally { setScrubbingId(""); } }; const scrubLibraryHits = async () => { if (scrubAllBusy) return; if (!window.confirm("扫全库 → 命中 AIGC 元数据的 clip 一次性脱敏?")) return; setScrubAllBusy(true); setErr(""); try { const r = await API.postprocess.scrubLibrary(); alert(`脱敏完成:成功 ${r.cleaned} · 失败 ${r.failed} · 跳过 ${r.skipped}`); await load(); } catch (e) { setErr(`批量脱敏失败:${String(e.message || e)}`); } finally { setScrubAllBusy(false); } }; const deleteClip = async (clip) => { if (!clip || deletingId) return; const name = clip.name || clip.clip_id; if (!window.confirm(`确认物理删除「${name}」?\n\n会移除视频文件 + sidecar,并清空该 clip 的用量记录,不可恢复。`)) { return; } setDeletingId(clip.clip_id); setErr(""); try { await API.seedLibrary.deleteClip(clip.clip_id); if (activeId === clip.clip_id) { setActiveId(null); setSidecar(null); } await load(); } catch (e) { setErr(`删除失败:${String(e.message || e)}`); } finally { setDeletingId(""); } }; const ingestOne = async (clipId) => { setIngestOneBusy(true); try { await API.seedLibrary.ingestOne(clipId, true); await load(); await loadSidecar(clipId); } catch (e) { setErr(String(e.message || e)); } finally { setIngestOneBusy(false); } }; // 过滤 + 搜索 const visible = React.useMemo(() => { let xs = clips; if (filter === "with_kh") xs = xs.filter(c => c.in_cooldown !== undefined); // usage 已包含的都是已索引 if (filter === "in_cooldown") xs = xs.filter(c => c.in_cooldown); if (filter === "exhausted") xs = xs.filter(c => c.exhausted); if (filter === "available") xs = xs.filter(c => c.available); if (search.trim()) { const q = search.trim().toLowerCase(); xs = xs.filter(c => (c.name || "").toLowerCase().includes(q)); } return xs; }, [clips, filter, search]); const activeClip = clips.find(c => c.clip_id === activeId); return ( <> {stats?.count || 0} clip · {stats?.with_kh || 0} 已 kh } /> {batchMsg && (
{batchMsg}
)} {/* 绑定模式 Banner */} {bindParams.bindMode && (
绑定模式 · 正在为 chunk {bindParams.chunkId} 选片 (job {bindParams.jobId.slice(0, 8)} 点击任何 clip 卡片下的「✓ 选这条」或在右侧详情里「绑定 → 返回」即可完成绑定,自动跳回 {bindParams.returnUrl}
)} {err && (
{err}
)} {/* 顶部 stats + filter + search */}
筛选 {[ { k: "all", l: `全部 ${clips.length}` }, { k: "available", l: `可用 ${clips.filter(c => c.available).length}` }, { k: "in_cooldown", l: `冷却 ${clips.filter(c => c.in_cooldown).length}` }, { k: "exhausted", l: `已耗尽 ${clips.filter(c => c.exhausted).length}` }, ].map(t => ( ))} setSearch(e.target.value)} style={{ fontSize: 11.5, padding: "4px 8px", maxWidth: 280 }}/>
数据持久化:data/seed_clips/.kh-meta/*.json
{/* 左:clip 网格 */}
{visible.length === 0 ? (
{loading ? "加载中…" : "无符合条件的 clip"}
) : (
{visible.map(c => ( setActiveId(c.clip_id)} onBind={doBind} onDelete={deleteClip} onScrub={scrubClip}/> ))}
)}
{/* 右:sidecar 详情 */}
{!activeClip ? (
请在左侧选择一条 clip
右侧将显示完整 kh-spec sidecar(summaries / chunks / relations / previews)
) : ( )}
{staleOpen && (
setStaleOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 9999, background: "rgba(0,0,0,0.5)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24, }}>
e.stopPropagation()} style={{ background: "var(--bg-0)", color: "var(--fg-0)", width: "min(900px, 96vw)", maxHeight: "88vh", borderRadius: 8, display: "flex", flexDirection: "column", border: "1px solid var(--line-1)", boxShadow: "0 8px 32px rgba(0,0,0,0.18)", }}> {/* 顶栏 */}
需重跑(stale) {staleItems.length} 条
{/* 列表 */}
{staleItems.length === 0 ? (
{staleLoading ? "扫描中…" : "✓ 全库 summary 都正常"}
) : ( {staleItems.map(it => ( ))}
文件名 当前 summaries 原因
{it.name}
{it.clip_id}
{(it.summaries || []).length === 0 ? ( (空) ) : (
{(it.summaries || []).slice(0, 2).join(" · ")} {(it.summaries || []).length > 2 && ` +${it.summaries.length - 2}`}
)}
{(it.reasons || []).map((r, i) => ( {r.length > 40 ? r.slice(0, 40) + "…" : r} ))}
)}
{/* 底部 footer */}
⚠ 重跑前确认 kh01 本地服务已启动且 Gemini 配额未耗尽,否则会被回落到本地启发式(filename-only 文件名场景下会写 summaries=[])。
)} ); }; Object.assign(window, { ShotsLibraryPage });