@speclip/pi-media

Workspace-safe media analysis, editing, rendering, review, and stock-footage sourcing tools for Pi

Packages

Package details

extensionskillprompt

Install @speclip/pi-media from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@speclip/pi-media
Package
@speclip/pi-media
Version
0.3.4
Published
Sep 5, 2026
Downloads
894/mo · 894/wk
Author
tapcli
License
unknown
Types
extension, skill, prompt
Size
30.1 MB
Dependencies
0 dependencies · 2 peers
Pi manifest JSON
{
  "extensions": [
    "./extensions/media-local/index.ts",
    "./extensions/media-cloud/index.ts"
  ],
  "skills": [
    "./skills"
  ],
  "prompts": [
    "./prompts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@speclip/pi-media

Pi 用的视频处理工具包。让 AI Agent 能安全地取材、分析、裁剪、渲染视频,并且每一个成片都能说清自己是从哪来的

它解决什么问题

让 Agent 直接调 ffmpeg 命令有两个麻烦:一是容易误伤——覆盖原文件、写到工作区外面去;二是出了片没法追溯——这个 MP4 到底是从哪个素材、剪了哪一段来的,事后说不清。

这个包的做法是:编辑不改文件,只记账。

你的原始素材自始至终不会被动。所有"想怎么剪"的意图,都记录成工作区里 .media/ 目录下一份份不可变的说明书(叫快照)。渲染时照着某一份说明书生成一个文件,同时留下一张凭证。最后验收时,顺着凭证倒推回去核对,对不上就不通过。

环境要求

  • Node.js 22.19+
  • macOS 或 Linux(渲染链路依赖 POSIX 文件描述符,不支持 Windows)
  • Pi 0.85.x
  • PATH 里能找到 ffprobeffmpeg
  • media_scene_detect 额外要求 FFmpeg 9.0+,且构建时启用 --enable-libonnxruntime 并包含 dnn_processing;仅版本号正确还不够

安装

npm install
npm run check
pi install ./

安装后会得到两个 Extension,边界按是否需要凭证划分:

  • media-local — 9 个工具,不需要任何 API key。视频探测、联系表、TransNetV2 镜头检测、编辑、渲染、验收,外加一个下载公共 HTTPS 素材的 asset_import
  • media-cloud — 需要配置云服务 API key 才能用的工具,目前是 Pexels 图库的搜索与下载。没配 key 或用不上时,建议用 pi config 关掉它。

注意这条边界划的是「要不要凭证」而不是「联不联网」——asset_import 待在 local 但它确实会联网。两边的下载走的是同一套加固过的下载器,详见安全设计

Pi Extension 是以你当前用户的权限运行的,Pi 的"项目可信"机制不是安全沙箱。能关的边界就关掉。

完整走一遍

假设你有 raw/interview.mp4,想剪出第 10 秒到第 40 秒交付。

1. 看清楚素材是什么

media_probe  { path: "raw/interview.mp4" }

返回时长、容器格式、每条视频/音频流的编码和参数,外加文件的 SHA-256 指纹和字节数。

指纹是整套设计的地基——有了它,后面每一步都能确认"我处理的还是当初那个文件吗"。

需要让 Agent 通过视觉选择 BGM 片段时,生成音频分析图:

media_audio_analyze {
  path: "assets/bgm.wav",
  outputDir: "analysis/bgm"
}

输出的 1920×520 PNG 包含波形、短时 LUFS 曲线和 HH:MM:SS.mmm 绝对时间轴;manifest 使用 EBU R128 / ITU-R BS.1770 记录 Integrated LUFS、LRA、True Peak、来源指纹和每张图片指纹。分析最终口播时可传 ranges,按成片顺序只测保留的 A-roll 区间。

如果需要先快速理解视频内容,可以在建档前生成联系表:

media_contact_sheet {
  path: "raw/interview.mp4",
  outputDir: "analysis/interview-storyboards",
  precision: "medium",
  segmentSeconds: 20,
  sampling: { mode: "fps", framesPerSecond: 1 }
}

工具把代表帧排成固定 1920×1440(4:3)的故事板,列数会根据帧密度和横屏/竖屏方向自动计算,不再固定为 3 列;缩略帧低于可读阈值时会自动分页。每行下方都有固定 64px 高的局部音频波形,每张 PNG 都带帧编号、页码和 HH:MM:SS.mmm 绝对时间戳。PNG 本身不能携带可播放音轨,波形是从源音频提取的可视证据。输出目录还包含 contact_sheet_manifest.json,记录源视频 SHA-256、抽帧时间点、分页布局和每张图片的 SHA-256。

precision 必须由 Agent 根据任务选择:

精度 默认分段 抽帧密度 适用场景
low 60 秒 15 个均匀帧,可按强转场补到最多 30 帧 长视频初筛、快速总览、优先速度
medium 20 秒 固定每秒 1 帧(每段 20 帧) 常规剧情理解,速度与覆盖平衡
high 10 秒 每秒 2 个均匀帧,可按强转场补到最多每秒 5 帧 快切、动作密集、证据敏感或结果存在歧义

可用 segmentSeconds 调整每段时长;sampling 可选择 { mode: "count", framesPerSegment } 精确指定完整分段的帧数(末尾不足一段时按时长等比缩减),或 { mode: "fps", framesPerSecond } 指定均匀采样率。两种模式都不能超过 5fps。显式设置 sampling 后不再额外补强转场帧;省略时使用上表的自适应策略。输出目录必须是新路径,已有目录不会被覆盖。长视频可用 startSeconds / endSeconds 限定分析范围。

如果任务需要可直接用于精确切片的镜头边界,而不只是联系表里的启发式补帧,运行 TransNetV2:

media_scene_detect {
  path: "raw/interview.mp4",
  outputPath: "analysis/interview-scenes.json",
  threshold: 0.5
}

它把固定在包内的 transnetv2-ffmpeg v0.1.0 ONNX 模型交给 FFmpeg 9.0+ 的 dnn_processing / ONNX Runtime 后端,返回合并后的切镜点、单帧与 all-frame 概率,以及首尾相接的 scene ranges。peakFrame 是模型概率峰所在的旧镜头末帧,boundaryFrame 是下一镜头首帧,切片应使用后者。报告包含源文件和模型 SHA-256,并以新 JSON 文件落盘;已有文件绝不覆盖。

这个工具会同时检查版本、--enable-libonnxruntimednn_processing。普通 Homebrew/apt 安装即使显示 FFmpeg 9,也可能没带 ONNX 后端;不满足时错误会指出实际检测结果,并链接到已验证的 build_ffmpeg_9.sh。构建好后把兼容版 ffmpeg 放进 PATH,或设置:

export PI_MEDIA_FFMPEG_BINARY=/absolute/path/to/ffmpeg

ffprobe 默认仍从 PATH 读取;需要单独指定时使用 PI_MEDIA_FFPROBE_BINARY

2. 建一个档案

project_create  { projectId: "interview-cut", sourcePath: "raw/interview.mp4" }

.media/projects/interview-cut/ 下建档,并直接写入 revision 1:一份"素材是 interview.mp4(指纹 a3f5…),编辑操作:无"的快照。

revision 1 就是未经编辑的原始状态

3. 改之前先查当前第几版

project_get  { projectId: "interview-cut" }

不传 revision 就返回最新版。为什么必须先查?见下一步。

4. 写下新的一版

edit_apply  {
  projectId: "interview-cut",
  expectedRevision: 1,
  operations: [{ kind: "trim", startSeconds: 10, endSeconds: 40 }]
}

需要把多个口播片段或多机位素材拼成一条时间线时,改用通用 EDL:

edit_apply {
  projectId: "interview-cut",
  expectedRevision: 1,
  operations: [{
    kind: "timeline",
    segments: [
      { id: "hook", sourcePath: "raw/take-1.mp4", sourceStartSeconds: 2.4, sourceEndSeconds: 8.1 },
      { id: "answer", sourcePath: "raw/take-2.mp4", sourceStartSeconds: 11.2, sourceEndSeconds: 24.6 }
    ],
    overlays: [{
      id: "product-demo",
      sourcePath: "assets/demo.mp4",
      outputStartSeconds: 4.0,
      outputEndSeconds: 7.5,
      sourceStartSeconds: 0,
      fit: "cover",
      audio: "keep-primary"
    }],
    subtitleTrack: {
      sourcePath: "subtitles/final.ass",
      format: "ass",
      mode: "burn-in"
    },
    musicTrack: {
      sourcePath: "assets/bgm.wav",
      sourceStartSeconds: 12,
      sourceEndSeconds: 18,
      outputStartSeconds: 0,
      outputEndSeconds: 30,
      playback: "loop",
      fadeInSeconds: 0.5,
      fadeOutSeconds: 1.5,
      ducking: "dialogue-sidechain",
      mix: {
        method: "ebu-r128-dialogue-relative",
        dialogueAnalysisPath: "analysis/voice/audio_analysis_manifest.json",
        musicAnalysisPath: "analysis/bgm-window/audio_analysis_manifest.json",
        targetMusicBelowDialogueLu: 16,
        maxTruePeakDbtp: -1
      }
    }
  }]
}

segments 的数组顺序就是成片顺序;overlays 使用成片时间轴定位 B-roll,并始终保留主音轨;可选的 subtitleTrack 把审核完成的 SRT/ASS 烧录进画面。musicTrack 不接受音量百分比或手填 LUFS,而是读取口播与最终选中音乐窗口的 EBU R128 manifest,再按目标 LU 差值计算增益;说话时自动 ducking,循环连接处交叉淡化,结尾按指定时长淡出。所有媒体、分析 manifest 和字幕素材在 revision 写入时都会哈希,后续文件被替换就拒绝渲染。

不改 revision 1,而是新写一份 revision 2。旧版本永远原样保留。

expectedRevision乐观并发控制——你在声明"我是基于第 1 版改的"。如果这期间别人已经写到第 2 版了,会直接报冲突让你重读,而不是默默把对方的改动盖掉。这就是第 3 步的意义。

5. 照着某一版出片

render  { projectId: "interview-cut", revision: 2, outputPath: "out/final.mp4" }

读 revision 2 的快照,用 ffmpeg 生成 H.264 MP4(源文件有音轨就编 AAC)。过程中实时报进度,可以中途取消,默认 30 分钟超时。

两个硬性行为:

  • 目标文件已存在就报错,绝不覆盖。
  • 渲染前重新校验源文件指纹。如果 raw/interview.mp4 在建档之后被人改过,指纹对不上会直接失败——而不是悄悄拿新素材出片。

同时在 .media/renders/ 下留一张凭证:成片指纹、来自哪个项目的第几版、当时的源素材指纹。

6. 验收

review  { path: "out/final.mp4" }

做两件事:

内容检查 — 没有视频轨(error)、时长缺失或非正数(error)、没有音频轨(warning,提醒你确认是不是故意的)。

来源核验 — 拿成片指纹去翻凭证,然后顺着倒推:凭证 → 指向哪个快照 → 那个快照记的源素材指纹 → 对不对得上。任何一环断了都是 error。

只要有一条 error,accepted 就是 false

你自己用命令行 ffmpeg 剪出来的 MP4,扔给 review 一定不通过——因为它没有凭证,追溯不到任何快照。这是故意的。

工具速查

Extension 工具 干什么 需要 key
local media_probe 读元数据、流信息、SHA-256、字节数
local media_audio_analyze 生成带绝对时间戳、短时 LUFS 的波形图及 EBU R128 manifest
local media_contact_sheet 按 Agent 选择的三级精度生成带局部波形的 PNG 故事板
local media_scene_detect 用 TransNetV2 + FFmpeg 9.0+ 检出精确切镜点并写 JSON 报告
local project_create 建项目,写入 revision 1
local project_get 读项目当前版本号和指定版本的快照
local edit_apply 基于指定版本写新快照:单段裁剪或通用多源 EDL + B-roll/BGM
local render 照指定版本渲染新 MP4,留下凭证
local review 内容检查 + 来源核验,返回结构化验收报告
local asset_import 下载公共 HTTPS 素材(需用户逐次确认)
cloud pexels_search 搜 Pexels 图库的图片或视频,返回候选与可选清晰度
cloud pexels_download 按 ID 下载某个 Pexels 素材,并记下署名信息

projectId 用小写字母、数字和中间的连字符,1–64 字符。渲染输出必须是 .mp4

包里还附带一个 edit-video Skill,把上面 6 步固化成流程,Agent 照着走即可。

用 Pexels 找素材

pexels_search 拿候选,pexels_download 按 ID 取回。分两步是为了让你(或 Agent)先看清楚再决定下哪个,而不是靠运气。

pexels_search   { query: "ocean waves", mediaType: "video", perPage: 5 }
                → [{ assetId, width, height, durationSeconds, author, pexelsUrl,
                     previewUrl, variants: [{ width, height, fps, bytes }] }, ...]

pexels_download { mediaType: "video", assetId: 25961000,
                  targetPath: "assets/ocean.mp4", minWidth: 1920 }
                → 落盘 + 带署名的来源记录

minWidth 会挑满足条件里最小的那档,所以要 1080 不会给你拽个 4K 回来。不填就取最大档。

下载回来的素材,来源信息长这样:

{
  "kind": "pexels",
  "url": "https://videos.pexels.com/video-files/25961000/1920.mp4",
  "assetKind": "video",
  "assetId": 25961000,
  "pexelsUrl": "https://www.pexels.com/video/a-boat-25961000/",
  "author": "Nisasu",
  "authorUrl": "https://www.pexels.com/@nisasu-1151927884"
}

署名字段是刻意留的——Pexels 的授权条款要求署名。它会沿 MediaRef → 快照 → 渲染凭证 一路带到成片,所以交付时你随时能查出用了谁的素材。

配 API key

key 不从环境变量读

在 Speclip 里:设置 → 媒体,粘贴 key 即可。没配 key 时工具抛出的错误里会带一个可点的「打开设置」按钮,直接跳到那一页,不用自己找。

裸 Pi CLI 下:手工创建这个文件。

<agentDir>/media-credentials.json

agentDir 在 Speclip 下是 ~/.speclip/agent/,裸 Pi 下是 ~/.pi/agent/。格式:

{
  "schemaVersion": 1,
  "providers": {
    "pexels": { "apiKey": "你的 key" }
  }
}

文件权限必须是 600 里面是明文 key,所以只要其他用户可读,工具会直接拒绝运行而不是凑合用下去。

chmod 600 ~/.pi/agent/media-credentials.json

pi-media 只读不写这个文件。Pexels 的 key 在 pexels.com/api 免费申请,配额 200 次/小时、20000 次/月。

.media/ 里存了什么

.media/
├── projects/<projectId>/
│   ├── project.json          当前版本号等元信息
│   ├── snapshots/1.json      revision 1(原始状态)
│   ├── snapshots/2.json      revision 2(剪 10–40s)
│   └── .write-lock           并发写锁
├── renders/<成片SHA256>/
│   └── <路径哈希>.json        渲染凭证
├── probe-inputs/             探测用临时文件(用完即删)
├── contact-sheet-inputs/     联系表用私有源快照(打开后立即 unlink)
├── contact-sheet-work/       抽帧、波形和 PPM 临时文件(用完即删)
├── scene-detection-inputs/   镜头检测用私有源快照(打开后立即 unlink)
└── render-inputs/            渲染用临时文件(用完即删)

快照一旦写入就不再修改。想回到旧版本重新出片,直接对着那个 revision 号跑 render 就行。

安全设计

这个包相当大比例的代码在处理边界防护,简单说明动机:

跑不出工作区。 每个路径都做两道检查:先按字符串算出绝对路径看是否越界,再 realpath 解析真实位置复查一遍。状态文件额外用 lstat 拒绝符号链接——防的是有人在 .media/projects/ 底下塞一个指向工作区外的软链。

防"掉包"(TOCTOU)。 probe 和 render 都不直接把路径交给 ffmpeg,而是:先把源文件复制成私有副本 → 打开拿到文件描述符 → 立刻 unlink 断开路径 → 对这个描述符校验哈希 → 只把 /dev/fd/3 交给 ffmpeg。整个过程中 ffmpeg 拿不到任何可以被中途替换的路径。

从不覆盖。 单文件产物先写临时文件,再 link() 到目标位置;联系表则先在临时目录完整生成,再用原子 mkdir() 占用最终目录,逐个移入 PNG,最后写入 manifest 作为完成标志。遇到 EEXIST 就报错;失败回滚前会用 dev/ino 确认目录仍是本次任务创建的目标。

防 SSRF。 所有下载——asset_importpexels_download 都算——走同一个加固过的下载器:强制 HTTPS、拒绝 URL 里带凭证、DNS 解析后逐个 IP 比对私有网段黑名单(IPv4/IPv6 全覆盖)、把解析结果固定给底层请求防 DNS rebinding、重定向每一跳重新校验、最多 5 跳。默认大小上限 100MB(硬顶 500MB),默认超时 60 秒(上限 5 分钟)。Content-Length 会撒谎,所以下载过程中还按实际字节数二次把关。

Pexels 下载额外收紧。 目标主机锁定在 images.pexels.com / videos.pexels.com / static-videos.pexels.com,且完全不允许重定向(实测这些资源本来就是直连响应)。而且下载 URL 是按素材 ID 重新问 API 拿的,不接受调用方传 URL——多一次 API 调用,换掉一整类注入风险。

asset_import 联网必须人工确认。 三重门:参数里 confirmNetworkAccess: true、当前会话有 UI、用户在弹窗点确认。缺一不可,非交互式会话直接拒绝。这个工具不持有任何凭证。

凭证文件权限强制。media-credentials.json 前先查 mode,只要 group 或 other 可读就拒绝运行并提示 chmod 600,不静默降级。同时用 lstat 拒绝符号链接。这个包只读不写该文件。

并发安全。 项目写入用可恢复的 .write-lock 加日志。进程中途被杀不会留下半成品——下次访问时会检测到死锁并从日志续完或回滚。

当前能做什么

0.1 版有意收窄范围,只做一条完整可用的链路:

  • 单个源视频片段
  • 三级精度的视频联系表、局部音频波形和启发式镜头变化补帧
  • TransNetV2 + FFmpeg 9.0+ 精确镜头边界、概率与可切片 scene ranges
  • 单段裁剪,或按顺序拼接最多 1000 个跨文件片段的通用 EDL
  • 最多 500 个 B-roll 视频覆盖窗口,支持 cover/contain 并保留主音轨
  • 输出 H.264 MP4(源有音轨则编 AAC)
  • 基于完整性和来源关系的结构化验收
  • 从工作区、公共 HTTPS、或 Pexels 图库取得素材

暂不支持:转录、图片覆盖、转场、字幕、混音、响度分析、AI 媒体生成、Pexels 以外的图库、Graph 工作流。语义选段和 B-roll 匹配仍由上层领域插件负责;pi-media 只接收明确 EDL 并做可验证渲染。

设计上也刻意不把 ffmpeg 和 HTTPS 包装成通用底层工具暴露给 Agent——那样就回到最开始的问题了。工作流编排交给 Skill 或 @viccydev/pi-graph 这类独立包。

想把这个包接进 Speclip 桌面端,看 docs/speclip-integration.md

开发

npm run typecheck    # tsc --noEmit
npm test             # node --test
npm run check        # 上面两个
npm pack --dry-run   # 检查打包产物

纯 TypeScript,没有构建步骤——Node 22 用 --experimental-strip-types 直接跑 .ts。零运行时依赖。

测试覆盖这些边界:

  • 用真实 Pi 0.84.1 Loader 加载两个 Extension 和全部 11 个工具
  • 联系表三级精度、场景补帧、静音源、并发 no-clobber、取消/超时和临时目录清理
  • TransNetV2 镜头检测的边界聚合、scene ranges、模型校验、FFmpeg 版本/ONNX 能力预检和 JSON no-clobber
  • 目录和状态文件的符号链接逃逸
  • 编辑与建项目被中断后的恢复
  • 取消、超时、进度反馈
  • 联网授权门禁和 SSRF 拒绝
  • 凭证文件缺失、损坏、软链、权限过松各自的拒绝路径
  • Pexels 下载的主机白名单、per_page 夹紧、HLS 条目过滤、清晰度选档
  • 渲染凭证写入失败后只清理本次产物
  • 目标已存在时拒绝覆盖
  • 伪造凭证被 review 拒绝
  • 真实 ffmpeg 跑通 H.264/AAC MP4 和无音频 MPEG-4 AVI 两条链路

Pexels 相关用例全部用打桩的 fetch 跑,不触真实网络,CI 不需要 API key。

兼容性验证还额外手工解压了 npm 打包产物,用 Pi 0.84.2 做过加载冒烟测试。

发布到 npm

发布动作由 GitHub Release 触发。Release 标签必须严格使用 v<package.json version>,例如 package.json0.1.0 时使用 v0.1.0。工作流会检出 Release 标签,依次执行 npm cinpm run checknpm pack --dry-run,全部通过后通过 npm Trusted Publishing(OIDC)发布公开包 @speclip/pi-media,不读取长期 npm Token。正式 Release 发布到 latest,Prerelease 发布到 next

在 npm 包设置中一次性配置 Trusted Publisher:

  1. Provider 选择 GitHub Actions。
  2. Organization/User 填写 linyqh,Repository 填写 pi-media
  3. Workflow filename 填写 publish.yml,Allowed actions 启用 npm publish

每次发布前同时更新 package.jsonpackage-lock.json 的版本并提交,然后创建标签与版本完全匹配的 GitHub Release。