UI Enhancements

dsh-video-clone

2015215218/dsh-video-clone · ★ 0

2015215218

把一支视频复制成 N 份,每份在容器层写入唯一 GUID,使文件哈希互异而媒体流与解码帧逐字节不变;附 guid→文件→标签 溯源账本、CLI 与 DSH Web 面板。

README body is shown in Chinese.

License
MIT
Added
2026-10-30
Maintenance
active
Install kind
github
Manifest details
Manifest status
Declared
Manifest path
package.json#dsh
Manifest checked
2026-10-30T09:37:59.859Z
Lifecycle scripts
None found

Static manifest checks report declarations only; they are not compatibility or security review.

安装

dsh plugin --profile web add 'github:2015215218/dsh-video-clone#8e56fa1cc89f92f463c32f1b2b035fd7eeea6dcc'

GitHub 源码安装:首次需按提示配置 allowBuilds 构建授权后重试

For install and environment setup, plus plugin development tutorials, see DSH community docs ↗

README

Source README · body: zh · Read Chinese README

Table of contents
  1. 一句话原理
  2. 三重自证(每次运行都会跑,结果写进 ledger)
  3. 第 3 条有个必须守住的坑:校验器失效 ≠ 校验通过
  4. 目录结构与装载要求
  5. 装载与热更新
  6. 用法
  7. DSH 插件(已装进 desktop profile)
  8. DSH Web 面板(GUI 工作台)
  9. CLI
  10. 账本
  11. 实测结果
  12. 边界(务必知道)
  13. 目录
  14. 安装到别的 profile
  15. 测试
  16. 版本管理
  17. 什么算破坏性
  18. 发版
  19. 装上之后怎么升级

CI license: MIT node

把一支视频复制成 N 份,每份塞一个唯一 GUID,让文件哈希各不相同——画面内容一个字节都不变。

适合多账号 / 多渠道 / 多设备分发同一素材时做唯一标识与事后溯源。


一句话原理

只改容器,不碰码流。

容器 写标记的位置 为什么合法
MP4 / MOV moov/udta/meta/ilst/©cmt(ffprobe 的 comment)+ 文件尾 free box 注释是元数据;free 是规范定义的"请跳过"填充盒
MKV / WebM Segment 内 Void 元素 + Tags/SimpleTag Void 是规范定义的填充元素;Tags 是元数据
AVI / RIFF 尾部 JUNK chunk + 原地修正 RIFF size JUNK 是 RIFF 的标准填充 chunk
MPEG-TS / M2TS 尾部 null packet(PID 0x1FFF) 标准明确规定 null packet 的载荷"可以是任意值",解复用器必须丢弃
FLV 尾部 script data tag,名字 onXCLN 未知脚本标签会被播放器忽略,不覆盖真正的 onMetaData
认不出 文件尾追加 原始字节仍是副本的完整前缀

改 MP4 时若需要让 moov 变长(把注释挤进去),会同步修正 stco/co64 的绝对偏移——这一步不做,文件能播但会花屏。


三重自证(每次运行都会跑,结果写进 ledger)

  1. 字节布局:按规划出的 splice 区间,把副本的"未触碰区间"与源文件逐一比对,并回读写入的数据确认一致 → layoutOk
  2. 哈希互异:N 份的 sha256 / md5 两两不同,且都不等于源文件 → hashesAllDistinct
  3. 媒体层:用 ffmpeg -f streamhash 比编码流、-f framemd5 逐帧解码比像素 → streamIdentical / framesIdentical

任何一项不通过,ok 就是 false。

第 3 条有个必须守住的坑:校验器失效 ≠ 校验通过

ffmpeg -f streamhash / -f framemd5 拿不到输出时,最初返回的是空串。而调用方 拿"源的空串"和"副本的空串"比相等——两个空串相等,于是:

一个退出码 0、但什么都不输出的假 ffmpeg,能让 streamIdentical 全 true、 media.ok 为 true、整体 ok 为 true,而每份的 streamHash 长度都是 0。

也就是验证器压根没工作,报告却写着「媒体层 OK」。这条是本工具最核心的承诺, 却能被静默误报——已实测复现。

现在的规则:拿不到证据就响亮失败。两个哈希函数在零输出时直接抛错, cloneVideo 捕获后把 media.ok 落成 false 并把原因写进 notes 与 media.error (副本已经写出来了,账本还有用,所以不炸掉整次克隆,但绝不留下"没验过却显示 OK"的状态)。 test/run-tests.js 的「校验器失效不许默认通过」小节用一个假的 ffmpeg 守着这条—— 撤掉守卫会立刻挂 7 条断言。

同一次调用产出的 N 份,文件体积完全一致:写入的填充量按 padBytes 量化到 64 字节对齐, 不随标签的 UTF-8 字节数浮动(否则"抖音号A"=10B 与"快手F"=7B 会让同一支源的副本体积差几字节,分发时很扎眼)。


目录结构与装载要求

index.js            ← 宿主插件入口(导出 apply / inject / name / TOOL_NAMES),package.json 的 exports["."] 指向它
client.js           ← 客户端半边(DSH Web GUI 的面板),package.json 的 exports["./client"] 指向它
lib/index.js        ← 纯库入口(导出 cloneVideo / planMp4 / markerPayload 等函数)
lib/web.js          ← 面板用的 HTTP 路由(只搬运,不重新实现克隆逻辑)
lib/*.js            ← 各容器规划器与工具
bin/video-clone.js  ← CLI

package.json 的 exports["."] 必须指向 index.js,不能指向 lib/index.js。 宿主装载器按包名解析入口(import('dsh-video-clone')),拿到的模块必须带 apply; 指错到纯库入口时装载器拿不到 apply,插件会静默不激活(工具列表里什么都看不到、 宿主也不报错,只在该行 fiberPhase 显示 null)。test/plugin-tests.js 里有专门断言守这条。

index.js 必须导出 inject 数组并含 "tools"。 Cordis 要求插件先声明依赖才能访问 ctx 上的服务;不声明就 ctx.tools.register(...) 会在真实宿主里抛 Error: cannot get property "tools" without inject。 这个契约只在真实 Cordis ctx 下强制——用普通对象伪造 ctx 的测试测不出来, 所以测试里用了会抛错的 Proxy 来模拟。少了它宿主只会报一行 failed,很难定位。

装载与热更新

profile 里装的是指向本仓库的软链(~/.dsh/profiles/desktop/node_modules/dsh-video-clone → 本目录),所以改源码不需要重新安装。但:

宿主进程会缓存已 import 的模块,改完源码必须重启 DSH 才生效。 plugin_manager 的实时重载对已缓存的模块无效——实测会继续报旧错误、连行号都是旧的 (插一个独一无二的探针错误就能验证:宿主报的仍是旧错误,探针从未执行)。 定位这类问题别信"重载一下就好",直接重启。

profile 的 package.json 里该插件声明为 file:/…/dsh-video-clone 依赖, loader 顺着这条 file: 依赖解析到本目录(错误栈里出现的也是本目录路径)。


用法

DSH 插件(已装进 desktop profile)

两个工具:

  • video_clone —— 复制 N 份,每份一个 GUID
  • video_clone_scan —— 在文件里找标记,反查是哪个 GUID / 哪一份
video_clone(input="素材.mp4", count=10, labels=["账号A","账号B",...], outputDir="分发")

常用参数:

参数 说明
count 份数,默认 5
mode both(默认,可见+隐形双保险)/ pad(只写解析器必跳过的填充)/ meta(只写可见元数据)
labels 每份的标签(账号名/设备名),写进标记与账本
deriveFromLabel 用标签确定性推导 GUID —— 同一标签永远同一 GUID,账本丢了也能重算比对
namePattern 文件名模板,占位符 {name} {i} {i:03} {guid} {label} {ext}
verify frames(默认,逐帧)/ media(编码流)/ hash(只算文件哈希)/ off
strictSize 要求副本体积与源完全一致。只有源里存在可复用的 free box 或够大的注释字段才成立,否则直接报错、不做降级

DSH Web 面板(GUI 工作台)

侧边栏会多一个「视频唯一化」图标(排在 SSH 下面),点开是一整块工作台:选源视频、 设份数/标签/策略/校验级别,点「开始生成」看进度条,结果直接列出每份的 标签、文件、大小、GUID、sha256,外加三项自证徽章;底部还有反查框,贴文件路径 就能读出里面写的 GUID。

选源视频有两套方式:

  • 「选择文件…」 —— 弹系统原生的文件选择框。DSH 自带的只有目录选择器 (directoryPicker / uiWorkspace.pickDirectory),没有文件选择器,所以这里直接调 宿主所在系统的对话框程序:macOS osascript … choose file、Windows PowerShell OpenFileDialog(带 -STA,不然 WinForms 对话框起不来)、 Linux zenity --file-selection。按扩展名过滤,起始目录默认落在当前源文件旁边。
  • 「浏览目录」 —— 在面板内列目录,能顺带看到每个文件的大小,适合快速比对。

原生对话框和克隆一样是两段式:pickFile 立刻返回 pickId,对话框在宿主那边 后台开着,面板轮询 pickPoll。这样用户可以在对话框前停几分钟,也不会把 HTTP 请求 挂死;超时 5 分钟自动关闭,点「取消」安静退回、不报错。

面板状态会记住。面板是 slot 组件,一换到别的侧边栏条目 React 就把它卸载; 最初所有字段都是裸 useState,切走再回来源路径、份数、标签、上一轮结果全没了。 现在表单与上次结果都落 localStorage(useStored),回来还是你离开时的样子。

「将输出到 …」是真的。输出目录留空时,面板会显示宿主实际会用的那条路径; 算法与宿主 cloneVideo 的默认规则逐字对齐,测试里拿一次真跑的落盘位置做交叉验证。

其他几处按截图改的:反查卡片不再占用流程编号(原来没跑过时会出现 2 → 4 跳号); 预估结果从按钮旁边一行小字改成了独立卡片(带容器/策略/体积差徽章); 底部整段原理说明折成可展开的「▸ 原理与边界」;内容区最大宽度 1040 → 1320。

pickFile 支持 dryRun: true:只回报「将要执行什么命令」而不起进程。有了它, 测试才能走完整条 HTTP 路径验证命令构造,又不往用户屏幕上弹窗。AppleScript 分支 另有 osacompile(只编译不执行)做真语法校验——脚本合法与否可以离线确证, 同样不弹窗。

它和工具、CLI 跑的是同一份克隆实现——面板只是把参数发过去、把结果画出来, 不存在第二套逻辑。

面板的三条硬约束(写错任何一条,界面都不会出现):

约束 写错会怎样
exports["./client"] 必须指向 client.js 宿主扫不到客户端半边
package.json 里要有 dsh.client = { platform: "web" } 同上——宿主就是靠这个字段发现 web 半边的
main 席位的 key 与 sidebar.panellist 行的 id 逐字相同 点图标选中一行,主区域空白

再加两条顺序/契约:

  • 先注册 main,再注册 sidebar.panellist。宿主会拒绝选中一张还没有页面的行。
  • 客户端半边的 inject 数组同理要写全(本插件是 ['slots', 'layout'])。少了 slots 会在严格 ctx 下抛 cannot get property "slots" without inject。

客户端半边是裸模块,没有构建步骤:外层是 window.__ModuleLoader__.load({ id, factory }) 的工厂包装,里面用 require("react") 拿 React、用 React.createElement 画界面(不写 JSX,免得要编译器)。 颜色全部走主题 token(--dsw-alias-*),亮/暗主题自动跟随。

宿主侧的路由挂在 /video-clone/api(ctx.webServer 前缀路由), 只接受 POST,过浏览器信任 fence(回环/可信域 + 非跨站 + Origin 同源)。 克隆是长任务,所以 clone 立刻返回 jobId,面板轮询 job 取进度—— 不让一个可能跑几分钟的请求挂在 HTTP 上。刻意不把 webServer 写进模块级 inject:它只存在于 web 部署,写进去会让插件在纯对话场景整个装不上; 改用 ctx.inject(['webServer'], …) 做可选依赖,服务缺席时两个工具照常可用。

CLI

# 复制 10 份,按账号打标签,逐帧校验
node bin/video-clone.js clone 素材.mp4 -n 10 -l 账号A,账号B,账号C --verify frames

# 看某个文件里有没有标记、是哪个 GUID
node bin/video-clone.js scan 分发/*.mp4

# 干跑:只输出规划(写哪里、体积变化多少),不落盘
node bin/video-clone.js plan 素材.mp4

# 看容器结构
node bin/video-clone.js info 素材.mp4

账本

每次运行会在输出目录生成:

  • ledger.json —— 完整记录(源哈希、选项、每份的 guid/标签/哈希/校验结论)
  • ledger.jsonl —— 每行一份,方便 grep
  • ledger.csv —— 方便 Excel 打开

实测结果

node test/run-tests.js(138 项)+ node test/plugin-tests.js(73 项),0 失败。覆盖:

场景 结果
MP4(moov 在尾)/ faststart(moov 在头,需修正 151 个 stco)/ MOV 哈希互异 ✔ 流不变 ✔ 帧不变 ✔
MKV / WebM、AVI、TS、M2TS(sync@4)、FLV、未知容器 同上 ✔
已有 ©cmt 但容量不足 → 增长 moov 并修正偏移 ✔
已有够大的注释字段 → 原地覆盖(体积零变化) ✔
有预留 free box → 原地写入(体积零变化) ✔
无可复用填充位 + strictSize 明确报错,不做降级 ✔
N=50 批量 50 份 sha256 全不同 ✔
源文件本身 运行前后大小/内容不变 ✔

真实素材(1280×720 H.264 + AAC,43.7 秒,9.3 MB)实测:6 份,21.6 秒(含逐帧校验),每份 streamIdentical=true、framesIdentical=true。

同一素材走面板 API 的端到端实测(真 HTTP、真 job 轮询):5 份,17.6 秒, 阶段 copy → stream → frames → done,布局 ✔ 哈希互异 ✔ 媒体层 frames ✔, 5 份体积全为 9319557、sha256 去重后仍是 5、反查每份命中 2 个标记且 CRC 全部 OK, 源文件 sha256 前后一字未变。


边界(务必知道)

  • 它不会影响平台的内容层查重。 本工具只改容器元数据,画面与音轨的每一个采样点都没变。平台用感知哈希 / 视频签名比对内容时,这些副本仍然是"同一支视频"。做唯一标识、溯源、防呆可以;指望靠它过内容查重不行。
  • meta 模式写的注释可以被剥离。 转码、平台二次处理、-map_metadata -1 都会清掉可见元数据;但尾部 free / Void / null packet 这类填充因为动到它就等于动文件结构,反而更耐操。所以默认 both:两处都写,剥掉一处仍能溯源。
  • ©cmt 不是隐写。 它是明文 ASCII,ffprobe 直接读得到。要"看不出来"请用 mode=pad。
  • 不重新编码。 本工具不做转码、不加片头片尾、不换 BGM —— 那些会改画面,与本工具的承诺正好相反。
  • 分片 MP4(fMP4 / DASH) 不重排 moov,自动降级为尾部追加。
  • 需要 ffmpeg 才能做媒体层校验;没有 ffmpeg 时自动降级为哈希校验,不会失败。

目录

index.js                  DSH 插件入口(注册 video_clone / video_clone_scan + 挂 web 路由)
client.js                 DSH Web 面板(裸模块,无构建步骤)
cordis.patch.yml          bundle patch:把插件行插进 profile roster
bin/video-clone.js        CLI(clone / scan / plan / info)
lib/web.js                面板的 HTTP 路由 + 任务表 + 浏览器信任 fence
lib/clone.js              编排核心 + ledger + 三重自证
lib/plan.js               容器探测与规划调度
lib/mp4.js  mkv.js  riff.js  mpegts.js  flv.js  generic.js   各容器规划器
lib/io.js                 随机读 / splice 流式改写 / 区间哈希
lib/marker.js             标记编解码 + CRC32 + 确定性 GUID
lib/verify.js             ffmpeg streamhash / framemd5 封装
lib/bytes.js              BMFF box 与 EBML VINT 原语
test/run-tests.js         端到端测试(自动造素材,离线可跑)
test/plugin-tests.js      插件装载与工具执行测试
test/web-tests.js         面板测试:真 HTTP + 真 Cordis 装载 + 客户端半边契约

零第三方依赖,只用 Node 内建 + ffmpeg。


安装到别的 profile

从本地源码装(开发时改完刷新即可生效):

cd ~/.dsh/profiles/<profile>
pnpm add file:/path/to/dsh-video-clone

或直接从 Git 装:

pnpm add github:<你的账号>/dsh-video-clone

装完再把 "dsh-video-clone" 追加进该 profile package.json 的 dsh.profile.bundles。 宿主侧通过 $DSH_HOME/profiles/node_modules 这个 module fallback 目录解析 @deepseek-ai/dsh-tools,所以插件不需要自带 node_modules。


测试

npm test          # 四套:核心库 179 + 插件装载 86 + web/面板 127 + 版本与包装 29
  • 核心库(test/run-tests.js)只用 Node 内建 + ffmpeg,自带素材生成,离线可跑。
  • web/面板(test/web-tests.js)起真 node:http 服务、发真 fetch、跑真 ffmpeg。 其中「真 Cordis 装载」「主题 token 真实性」两组需要机器上装了 DSH;没装会显式跳过并说明, 不会假装通过。DSH_CORDIS_PATH=… 可手动指定。
  • 插件装载(test/plugin-tests.js)必须有 DSH 安装才能解析 @deepseek-ai/dsh-tools。 没有就打印跳过原因并 exit 0;想让缺 DSH 直接算失败,设 REQUIRE_DSH=1。
  • 版本与包装(test/version-tests.js)不需要 DSH 也不需要 ffmpeg,永远会跑。 它钉住的是「这个包有没有被正确地说成它自己」——见下面「版本管理」。

为什么要这么大费周章地区分「跳过」和「通过」:本项目栽过一次 —— 媒体层校验器在零输出时 返回空串,两个空串比相等,于是「什么都没验」被报成了「验过了」。参见下面「校验器失效 ≠ 校验通过」。

Node 18 / 20 / 22 / 24 均已实测全绿(CI 也跑这四个版本)。


版本管理

版本号遵循语义化版本。唯一事实源是 package.json 的 version —— lib/clone.js 的 TOOL_VERSION 由它推导,不允许再写第二份。

为什么较真这一条:TOOL_VERSION 会流进溯源账本的 version 字段(还有面板徽章与 CLI 横幅)。两处漂移的话,账本就会记下一个根本不存在的版本 —— 那是溯源完整性问题,不是笔误。

什么算破坏性

对外契约不是「源码没变」,而是:DSH 工具名与输入字段、面板 API 的动作名与字段、账本字段、 package.json 里的 dsh.* 与 exports["./client"]、CLI 子命令与参数语义。 动到其中任何一个就要发 major;新容器/新选项/new feature 发 minor;修 bug 发 patch。

发版

npm run release -- patch          # 或 minor / major / 1.2.3
npm run release -- patch --push   # 顺带推送(默认只做本地,把推送命令打给你)
npm run release -- patch --dry-run

脚本会依次校验:工作区干净 → 在 main 上 → 与 origin/main 同步 → 测试全过 → 版本号确实变大 → tag 不重名 → [Unreleased] 段真的有内容。任一不过就停下并说明原因。

CHANGELOG.md 里的 ## [Unreleased] 是待发区:平时往那儿记,发版时脚本把它滚成 ## [x.y.z] - 日期,并自动补底部的比较链接。

装上之后怎么升级

本机 DSH profile 是钉 tag 装的:

cd ~/.dsh/profiles/desktop
pnpm add github:2015215218/dsh-video-clone#v1.0.0    # 升到指定版本

注意:github: 安装拿到的是冻结副本,不是活链接 —— files 白名单里的东西才会装下来。 本地改源码不会影响已安装的插件,要改完 push 再 pnpm add …#vX.Y.Z 升上去。