// 5 · 数据脱敏与扰乱 (/scrub) // - 左:脱敏方案表单(相机 + 相机参数 + 饱和度/颗粒度/版权/作者/软件/日期)+ 说明面板 // - 中:命中 AIGC 元数据的 clip 列表 + 单条 / 批量脱敏 // - 顶部:底层依赖健康灯 + 扫描分镜库 + 一键脱敏命中项 // 一行表单:左侧 label,右侧任意 control const ProfileFieldRow = ({ label, children }) => (
{label}
{children}
); // 内联样式给本页 select/input 共用(避免污染全局 styles.css) const _ppInputStyle = { width: "100%", padding: "5px 8px", fontSize: 11.5, border: "1px solid var(--line-1)", borderRadius: 4, background: "white", color: "var(--fg-0)", fontFamily: "inherit", }; // 注:JSX 不支持 className → style 注入,于是我用一个轻量 CSS-in-JS 钩子: // 第一次组件挂载时给页面注入一段 .ppinput 规则 (function injectPPInputCSS() { if (typeof document === "undefined") return; if (document.getElementById("ppinput-css")) return; const s = document.createElement("style"); s.id = "ppinput-css"; s.textContent = ` .ppinput { width: 100%; padding: 5px 8px; font-size: 11.5px; border: 1px solid var(--line-1); border-radius: 4px; background: white; color: var(--fg-0); font-family: inherit; } .ppinput:disabled { background: var(--bg-1); color: var(--fg-3); cursor: not-allowed; } `; document.head.appendChild(s); })(); // 默认 profile(沿用后端 ScrubProfile 默认值) const DEFAULT_PROFILE = { camera_id: "off", camera_param_id: "", saturation: 1.0, grain_strength: 3.0, copyright: "", artist: "", software: "光年AI 后处理 v1", description: "", capture_date: "", }; const ScrubPage = () => { const [health, setHealth] = React.useState(null); const [scanData, setScanData] = React.useState(null); const [scanning, setScanning] = React.useState(false); const [scrubbing, setScrubbing] = React.useState(false); const [busyId, setBusyId] = React.useState(""); const [err, setErr] = React.useState(""); const [lastScrub, setLastScrub] = React.useState(null); const [catalog, setCatalog] = React.useState(null); // {cameras, saturation, grain, software} const [profile, setProfile] = React.useState(DEFAULT_PROFILE); // 顶部库切换 + 图片库扫描 const [mode, setMode] = React.useState("shots"); // "shots" | "images" const [scanImagesData, setScanImagesData] = React.useState(null); const [scanningImages, setScanningImages] = React.useState(false); React.useEffect(() => { (async () => { try { const [h, c] = await Promise.all([ API.postprocess.health(), API.postprocess.cameras(), ]); setHealth(h); setCatalog(c); } catch (e) { setErr(String(e.message || e)); } })(); }, []); // 切换相机时清空相机参数(避免上一相机的 preset id 残留) const onCameraChange = (camId) => { setProfile(p => ({ ...p, camera_id: camId, camera_param_id: "" })); }; const setProfileField = (k, v) => setProfile(p => ({ ...p, [k]: v })); const doScan = async () => { if (scanning) return; setScanning(true); setErr(""); try { setScanData(await API.postprocess.scanLibrary()); } catch (e) { setErr(`扫描失败:${e.message || e}`); } finally { setScanning(false); } }; const doScanImages = async () => { if (scanningImages) return; setScanningImages(true); setErr(""); setMode("images"); try { setScanImagesData(await API.postprocess.scanImageLibrary()); } catch (e) { setErr(`扫描图片失败:${e.message || e}`); } finally { setScanningImages(false); } }; const doScrubAll = async () => { if (scrubbing) return; if (!confirm("将对所有命中 AIGC 元数据的 clip 就地脱敏(不可恢复)。\n继续?")) return; setScrubbing(true); setErr(""); try { const r = await API.postprocess.scrubLibrary(profile); setLastScrub(r); await doScan(); } catch (e) { setErr(`批量脱敏失败:${e.message || e}`); } finally { setScrubbing(false); } }; const doScrubOne = async (clipId) => { if (busyId) return; setBusyId(clipId); setErr(""); try { await API.postprocess.scrubClip(clipId, profile); await doScan(); } catch (e) { setErr(`脱敏失败:${e.message || e}`); } finally { setBusyId(""); } }; // 当前相机的 params 子数组(依赖于 camera_id —— 上下级关系) const currentCamera = (catalog?.cameras || []).find(c => c.id === profile.camera_id); const cameraParams = currentCamera?.params || []; const items = (scanData?.items || []).sort((a, b) => { const order = { high: 0, low: 1, none: 2 }; return (order[a.confidence] ?? 3) - (order[b.confidence] ?? 3); }); const aigcCount = scanData?.aigc || 0; const total = scanData?.total || 0; return ( <> 底层依赖 {health?.status === "ok" ? "正常" : "缺失"} } /> {err && (
{err}
)}
{/* 左:脱敏方案表单 + 说明面板 */}
{/* ───── 脱敏方案(profile) ───── */}
脱敏方案 每次脱敏都会按此方案写入元数据
{/* 1. 相机 → 相机参数(上下级) */} {/* 2. 饱和度 / 颗粒度 */} {/* 3. 版权 / 作者 / 软件 / 日期 */} setProfileField("copyright", e.target.value)}/> setProfileField("artist", e.target.value)}/> setProfileField("capture_date", e.target.value)}/>
方案不持久化,刷新即丢
这个组件做什么
  • 剥离原 EXIF / 容器元数据(Software / Comment / creation_time / PNG parameters …)
  • 图:重像素化 + 颗粒 + USM + 微旋 + WebP↔JPEG 多次有损洗荡
  • 视:ffmpeg 重编码 + 可选颗粒
  • 写入真实元数据(版权 / 作者 / 软件 / 日期)—— 不伪造相机型号 / GPS
自动触发点
  • 复刻爆款:云端视频每个镜头下载完,自动经过
  • 分镜素材库:clip 入库 ingest 时,自动检测 AIGC 元数据,命中即脱敏
  • 这两个入口外,可在本页手动「扫描 + 脱敏」
底层依赖
{Object.entries(health?.deps || {}).map(([k, v]) => (
{k} {String(v)}
))}
{lastScrub && (
上次批量脱敏
成功 {lastScrub.cleaned} · 失败 {lastScrub.failed} · 跳过 {lastScrub.skipped}
)}
{/* 中:扫描结果 */}
{/* 顶部:库切换 */}
{[ { id: "shots", label: "分镜库", count: scanData?.total }, { id: "images", label: "图片库", count: scanImagesData?.total }, ].map(t => { const active = mode === t.id; return ( ); })}
{mode === "images" ? ( !scanImagesData ? (
点击右上「扫描图片」开始检测图片库…
) : (scanImagesData.items || []).length === 0 ? (
图片库 {scanImagesData.total || 0} 张全部干净,无 AIGC 元数据信号
) : (
图片库扫描结果 {scanImagesData.aigc || 0} 命中 / {scanImagesData.total || 0} 总数
{(scanImagesData.items || []).map((it, i) => (
0 ? "1px solid var(--line-1)" : "none", gap: 10, alignItems: "center", }}> {it.confidence === "high" ? "高置信" : it.confidence === "low" ? "弱信号" : "干净"} {it.name} {it.is_aigc && Array.isArray(it.signals) && ( {it.signals.slice(0, 2).map(s => `${s.source}: ${s.marker}`).join(" · ")} {it.signals.length > 2 ? ` +${it.signals.length - 2}` : ""} )}
))}
) ) : !scanData ? (
点击右上「扫描分镜库」开始检测…
) : items.length === 0 ? (
分镜库 {total} 条 clip 全部干净,无 AIGC 元数据信号
) : (
扫描结果 {aigcCount} 命中 / {total} 总数
{items.map((it, i) => (
0 ? "1px solid var(--line-1)" : "none", gap: 10, alignItems: "center", }}> {it.confidence === "high" ? "高置信" : it.confidence === "low" ? "弱信号" : "干净"} {it.name} {it.is_aigc && ( {it.signals.slice(0, 2).map(s => `${s.source}: ${s.marker}`).join(" · ")} {it.signals.length > 2 ? ` +${it.signals.length - 2}` : ""} )} {it.is_aigc && ( )}
))}
)}
); }; Object.assign(window, { ScrubPage });