@speclip/pi-talking-head 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,14 +107,15 @@ talking_head_broll_plan {
107
107
  }
108
108
  ```
109
109
 
110
- 规划结果同时返回 `continuityPlanReceipt`,把当前项目 revision、A-roll、规划后的 B-roll 区间和跳点审阅结果绑定在一起。后续 `talking_head_apply` 会重新计算并核对它,不能跳过规划或把已经衔接的区间手动缩短。
110
+ 规划结果同时返回 `plan.coverage` 和 `continuityPlanReceipt`。`plan.coverage` 会列出 B-roll/A-roll 时长、B-roll 占比和最长连续 B-roll;凭证则把当前项目 revision、A-roll、规划后的 B-roll 区间和跳点审阅结果绑定在一起。后续 `talking_head_apply` 会重新计算并核对,不能跳过规划或把已经衔接的区间手动缩短。
111
111
 
112
112
  规划结果处理两类问题:
113
113
 
114
114
  - **B-roll 闪屏**:两个 B-roll 之间只露出不超过 500ms 的 A-roll 时,`plan.needs` 会把前一个需求延长到后一个需求的起点。后续分别选段后,两段 B-roll 会直接交接,不再闪回 A-roll。
115
115
  - **气口剪辑跳转**:A-roll 相邻片段的源时间不连续时,会生成 `jumpCuts`。工具检查建议遮盖区间是否已被 B-roll 连续覆盖;未覆盖项标为 `needs-broll`,Agent 应结合口播语义补充 `purpose: "mask-cut"` 的需求,再重新规划。
116
+ - **A-roll 出镜预算**:B-roll 总覆盖最多占成片 50%,单次连续覆盖最多 8000ms。规划和最终应用都会独立校验;超限时应删除或缩短低价值需求,而不是继续遮盖 A-roll。
116
117
 
117
- 规划只提供时间轴证据,不会假定每个剪辑点都必须加 B-roll。是否遮盖仍需结合实际画面判断。
118
+ 规划只提供时间轴证据,不会假定每个剪辑点都必须加 B-roll。`needs-broll` 表示“值得检查”,不表示“必须遮盖”;是否添加仍需结合实际画面判断。
118
119
 
119
120
  ### 4. 文件名优先筛选 B-roll
120
121
 
@@ -203,7 +204,7 @@ review { path: "out/launch-final.mp4" }
203
204
  | --- | --- |
204
205
  | `talking_head_create` | 从视频和词级转录建立 revision 1,分析句子、语气词、重复和停顿并生成默认 EDL |
205
206
  | `talking_head_get` | 读取指定 revision,分页返回整句与编辑候选,可选导出 pi-media EDL |
206
- | `talking_head_broll_plan` | 衔接相邻 B-roll、识别 A-roll 跳点并报告尚未遮盖的剪辑区间 |
207
+ | `talking_head_broll_plan` | A-roll 优先预算规划 B-roll、衔接相邻片段并报告待审阅跳点 |
207
208
  | `talking_head_broll_match` | 只用文件名和目录名匹配本地 B-roll,返回受限 shortlist 与视觉升级建议 |
208
209
  | `talking_head_broll_select` | 用 pi-media 联络表 manifest 验证具体源时间段并生成 placement |
209
210
  | `talking_head_apply` | 写入新的不可变口播 revision,并返回 pi-media EDL |
@@ -200,7 +200,7 @@ export default function talkingHead(pi: ExtensionAPI): void {
200
200
  pi.registerTool({
201
201
  name: "talking_head_broll_plan",
202
202
  label: "Plan B-roll visual continuity",
203
- description: "Plan B-roll windows before asset matching. Bridges brief A-roll flashes between neighboring B-roll needs and reports A-roll source jump cuts that still need a mask-cut B-roll.",
203
+ description: "Plan A-roll-first B-roll windows before asset matching. Keeps total B-roll at or below 50% and each continuous run at or below 8 seconds, bridges brief A-roll flashes, and reports source jump cuts for visual review.",
204
204
  parameters: Type.Object({
205
205
  projectId: stableId,
206
206
  revision: Type.Optional(Type.Integer({ minimum: 1 })),
@@ -308,7 +308,7 @@ export default function talkingHead(pi: ExtensionAPI): void {
308
308
  pi.registerTool({
309
309
  name: "talking_head_apply",
310
310
  label: "Apply talking-head timeline",
311
- description: "Create a new immutable talking-head revision from word-boundary A-roll ranges and optional B-roll placements. B-roll requires the matching continuity plan receipt; brief A-roll flashes, unplanned ranges, and overlapping B-roll are rejected. Returns a generic pi-media timeline operation ready for edit_apply.",
311
+ description: "Create a new immutable talking-head revision from word-boundary A-roll ranges and optional B-roll placements. B-roll requires the matching continuity plan receipt; coverage above 50%, continuous runs above 8 seconds, brief A-roll flashes, unplanned ranges, and overlaps are rejected. Returns a generic pi-media timeline operation ready for edit_apply.",
312
312
  parameters: Type.Object({
313
313
  projectId: stableId,
314
314
  expectedRevision: Type.Integer({ minimum: 1 }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@speclip/pi-talking-head",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Pause-aware talking-head editing and B-roll planning for Pi, exported as generic pi-media EDLs",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,6 +2,6 @@
2
2
  description: Tighten a talking-head video without destroying natural speech rhythm
3
3
  ---
4
4
 
5
- Use the `talking-head-edit` skill to review full-sentence context, word-level pauses, filler words, adjacent repetitions, and low-confidence delivery cues; stabilize A-roll, plan B-roll continuity to bridge brief A-roll flashes and mask objectionable jump cuts, match B-roll by filename before escalating through paged contact-sheet batches, validate the selected manifest-backed source window, and export a generic pi-media EDL for this request: $@
5
+ Use the `talking-head-edit` skill to review full-sentence context, word-level pauses, filler words, adjacent repetitions, and low-confidence delivery cues; stabilize A-roll, keep A-roll visible for at least half of the output, limit each continuous B-roll run to 8 seconds, bridge brief A-roll flashes, mask only visually objectionable jump cuts, match B-roll by filename before escalating through paged contact-sheet batches, validate the selected manifest-backed source window, and export a generic pi-media EDL for this request: $@
6
6
 
7
7
  Show the proposed rhythm before writing a new revision. Never overwrite the source or an existing render.
@@ -12,8 +12,8 @@ Use `pi-speech` for word evidence, this package for editorial decisions, and `pi
12
12
  3. Inspect bounded `talking_head_get` pages. Review the full sentence context, filler candidates, and repetition candidates together with pauses. Treat `safe` and every recommendation as evidence, not an instruction. Preserve pauses that carry emphasis, emotion, topic boundaries, or a deliberate breath.
13
13
  4. Never delete a filler token automatically. Decide whether `啊`, `额`, `嗯`, or a repeated token is a false start, a discourse marker, or intentional emphasis from its sentence context. A text-only delivery cue is low-confidence evidence; review audio and picture before relying on emotion or performance intent.
14
14
  5. Before changing the revision, summarize the proposed rhythm: what words and gaps will be removed, which pauses or fillers will remain, and why. Get the user's approval unless they explicitly delegated editorial judgment.
15
- 6. Stabilize A-roll before B-roll. For every B-roll window, record the output range, complete spoken text, one purpose (`demonstrate`, `explain`, `evidence`, `establish`, `transition`, or `mask-cut`), 1-20 concise search terms, and a concrete reason.
16
- 7. Call `talking_head_broll_plan` with the final proposed A-roll and all semantic B-roll needs before matching assets. Keep its `continuityPlanReceipt` unchanged. Use the returned `plan.needs`: it extends the previous B-roll across A-roll gaps of 500ms or less so neighboring B-roll clips hand off directly. Review every reported A-roll jump cut. Add a `purpose: mask-cut` need over each visually objectionable uncovered cut, using the suggested output range, then rerun the plan. Do not create meaningless B-roll solely because a cut exists; picture review remains the editorial gate.
15
+ 6. Stabilize A-roll before B-roll. A-roll is the primary picture, not leftover filler: keep it visible for at least half of the final duration and reintroduce it within every 8 seconds of continuous B-roll. For every B-roll window, record the output range, complete spoken text, one purpose (`demonstrate`, `explain`, `evidence`, `establish`, `transition`, or `mask-cut`), 1-20 concise search terms, and a concrete reason.
16
+ 7. Call `talking_head_broll_plan` with the final proposed A-roll and all semantic B-roll needs before matching assets. Keep its `continuityPlanReceipt` unchanged. Use the returned `plan.needs` and inspect `plan.coverage`: the planner rejects total B-roll above 50% or any continuous run above 8000ms. It extends the previous B-roll across A-roll gaps of 500ms or less so neighboring B-roll clips hand off directly. Review every reported A-roll jump cut, but add `purpose: mask-cut` only when picture review shows a visually objectionable cut and the spoken context supports B-roll. Never cover every reported jump cut by default. If the budget is exceeded, remove or shorten lower-value needs instead of hiding more A-roll, then rerun the plan.
17
17
  8. Call `talking_head_broll_match` before generating contact sheets. Follow its bounded result:
18
18
  - `filename-direct`: probe and visually inspect only the selected asset to choose its source window. Do not call `media_contact_sheet` for unselected assets.
19
19
  - `filename-shortlist`: inspect only the returned shortlist and stop as soon as one asset satisfies the need. If none does and `nextCandidateOffset` is non-null, request that next page; do not repeat the first batch.
@@ -21,7 +21,7 @@ Use `pi-speech` for word evidence, this package for editorial decisions, and `pi
21
21
  - If the scan is truncated, do not paginate it: narrow the asset directory and rescan so the candidate inventory is stable.
22
22
  9. After selecting one asset, call `media_probe`, then use `media_contact_sheet` only as densely as timing requires: one high-density pass for a short ambiguous clip, medium for a normal clip, or low over a long clip followed by high density over the narrowed range. Read `contact_sheet_manifest.json` timestamps instead of guessing times from the PNG. Verify a continuous source window at least as long as the planned output window, preferably with 500ms handles on both sides.
23
23
  10. Call `talking_head_broll_select` with each planned need, selected asset, manifest path, exact source start, and the manifest timestamps actually reviewed. Use its complete returned `placement`, including `selectionReceipt`; do not shorten bridged output ranges or hand-author selection fields.
24
- 11. Call `talking_head_apply` using the same planned A-roll, every validated B-roll placement, and the unchanged `continuityPlanReceipt`. The tool rejects unplanned ranges, B-roll overlaps, and A-roll flashes of 500ms or less. Every B-roll window must have a concrete visual purpose in `reason`; keep `audio: keep-primary`.
24
+ 11. Call `talking_head_apply` using the same planned A-roll, every validated B-roll placement, and the unchanged `continuityPlanReceipt`. The tool independently rejects B-roll above the 50%/8000ms A-roll-first budget, unplanned ranges, overlaps, and A-roll flashes of 500ms or less. Every B-roll window must have a concrete visual purpose in `reason`; keep `audio: keep-primary`.
25
25
  12. Create/read a `pi-media` project for the same source. Pass the returned `mediaOperation` unchanged to `edit_apply`, then `render` the exact new revision and call `review`.
26
26
 
27
27
  Never overwrite source media or outputs. If a revision conflict occurs, re-read both projects and reconcile intent. If the source, transcript, or B-roll hash changed, stop and ask whether to create a new project rather than silently adopting new bytes.
package/src/continuity.ts CHANGED
@@ -5,10 +5,14 @@ import type {
5
5
  BrollContinuityBridge,
6
6
  BrollContinuityPlan,
7
7
  BrollContinuityPlanReceipt,
8
+ BrollCoverageSummary,
8
9
  BrollNeed,
9
10
  } from "./contracts.ts";
10
11
  import { timelineDuration } from "./transcript.ts";
11
12
 
13
+ export const MAX_BROLL_COVERAGE_RATIO = 0.5;
14
+ export const MAX_CONTINUOUS_BROLL_MS = 8_000;
15
+
12
16
  export interface PlanBrollContinuityInput {
13
17
  aroll: ArollSegment[];
14
18
  needs: BrollNeed[];
@@ -105,6 +109,77 @@ export function findOverlappingBrollWindows(windows: BrollOutputWindow[]): Overl
105
109
  return overlaps;
106
110
  }
107
111
 
112
+ export function summarizeBrollCoverage(
113
+ windows: BrollOutputWindow[],
114
+ outputDurationMs: number,
115
+ ): BrollCoverageSummary {
116
+ if (!Number.isFinite(outputDurationMs) || outputDurationMs <= 0) {
117
+ throw new Error("outputDurationMs must be a positive finite number");
118
+ }
119
+ for (const window of windows) {
120
+ if (!Number.isFinite(window.outputStartMs) || !Number.isFinite(window.outputEndMs)
121
+ || window.outputStartMs < 0 || window.outputEndMs <= window.outputStartMs
122
+ || window.outputEndMs > outputDurationMs) {
123
+ throw new Error(`Invalid B-roll coverage window: ${window.id}`);
124
+ }
125
+ }
126
+ const sorted = [...windows].sort((left, right) => (
127
+ left.outputStartMs - right.outputStartMs
128
+ || left.outputEndMs - right.outputEndMs
129
+ || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
130
+ ));
131
+ let brollDurationMs = 0;
132
+ let longestContinuousBrollMs = 0;
133
+ let runStartMs: number | undefined;
134
+ let runEndMs: number | undefined;
135
+ for (const window of sorted) {
136
+ if (runStartMs === undefined || runEndMs === undefined) {
137
+ runStartMs = window.outputStartMs;
138
+ runEndMs = window.outputEndMs;
139
+ continue;
140
+ }
141
+ if (window.outputStartMs <= runEndMs) {
142
+ runEndMs = Math.max(runEndMs, window.outputEndMs);
143
+ continue;
144
+ }
145
+ const durationMs = runEndMs - runStartMs;
146
+ brollDurationMs += durationMs;
147
+ longestContinuousBrollMs = Math.max(longestContinuousBrollMs, durationMs);
148
+ runStartMs = window.outputStartMs;
149
+ runEndMs = window.outputEndMs;
150
+ }
151
+ if (runStartMs !== undefined && runEndMs !== undefined) {
152
+ const durationMs = runEndMs - runStartMs;
153
+ brollDurationMs += durationMs;
154
+ longestContinuousBrollMs = Math.max(longestContinuousBrollMs, durationMs);
155
+ }
156
+ return {
157
+ brollDurationMs,
158
+ arollDurationMs: outputDurationMs - brollDurationMs,
159
+ brollCoverageRatio: Math.round((brollDurationMs / outputDurationMs) * 10_000) / 10_000,
160
+ longestContinuousBrollMs,
161
+ limits: {
162
+ maxBrollCoverageRatio: MAX_BROLL_COVERAGE_RATIO,
163
+ maxContinuousBrollMs: MAX_CONTINUOUS_BROLL_MS,
164
+ },
165
+ };
166
+ }
167
+
168
+ export function assertBrollCoverageBudget(
169
+ windows: BrollOutputWindow[],
170
+ outputDurationMs: number,
171
+ ): BrollCoverageSummary {
172
+ const coverage = summarizeBrollCoverage(windows, outputDurationMs);
173
+ if (coverage.brollDurationMs > outputDurationMs * MAX_BROLL_COVERAGE_RATIO) {
174
+ const percent = ((coverage.brollDurationMs / outputDurationMs) * 100).toFixed(1);
175
+ throw new Error(`B-roll coverage ${percent}% exceeds the 50% A-roll-first limit; remove or shorten lower-priority B-roll needs`);
176
+ }
177
+ if (coverage.longestContinuousBrollMs > MAX_CONTINUOUS_BROLL_MS) {
178
+ throw new Error(`continuous B-roll run ${coverage.longestContinuousBrollMs}ms exceeds the 8000ms limit; split it with a meaningful A-roll appearance`);
179
+ }
180
+ return coverage;
181
+ }
182
+
108
183
  function validateSegments(aroll: ArollSegment[]): void {
109
184
  if (aroll.length === 0 || aroll.length > 1_000) throw new Error("A-roll must contain 1-1000 segments");
110
185
  const ids = new Set<string>();
@@ -239,6 +314,7 @@ export function planBrollContinuity(input: PlanBrollContinuityInput): BrollConti
239
314
  throw new Error("cutCoverBeforeMs and cutCoverAfterMs cannot both be zero");
240
315
  }
241
316
  const bridged = bridgeShortGaps(input.needs, shortGapMs);
317
+ const coverage = assertBrollCoverageBudget(bridged.needs, outputDurationMs);
242
318
  return {
243
319
  outputDurationMs,
244
320
  shortGapMs,
@@ -246,6 +322,7 @@ export function planBrollContinuity(input: PlanBrollContinuityInput): BrollConti
246
322
  cutCoverAfterMs,
247
323
  needs: bridged.needs,
248
324
  bridges: bridged.bridges,
325
+ coverage,
249
326
  jumpCuts: analyzeArollJumpCuts(
250
327
  input.aroll,
251
328
  bridged.needs,
package/src/contracts.ts CHANGED
@@ -185,6 +185,17 @@ export interface ArollJumpCut {
185
185
  status: "covered" | "needs-broll";
186
186
  }
187
187
 
188
+ export interface BrollCoverageSummary {
189
+ brollDurationMs: number;
190
+ arollDurationMs: number;
191
+ brollCoverageRatio: number;
192
+ longestContinuousBrollMs: number;
193
+ limits: {
194
+ maxBrollCoverageRatio: number;
195
+ maxContinuousBrollMs: number;
196
+ };
197
+ }
198
+
188
199
  export interface BrollContinuityPlan {
189
200
  outputDurationMs: number;
190
201
  shortGapMs: number;
@@ -193,6 +204,7 @@ export interface BrollContinuityPlan {
193
204
  needs: BrollNeed[];
194
205
  bridges: BrollContinuityBridge[];
195
206
  jumpCuts: ArollJumpCut[];
207
+ coverage: BrollCoverageSummary;
196
208
  }
197
209
 
198
210
  export interface BrollContinuityPlanReceipt {
@@ -254,6 +266,7 @@ export interface TalkingHeadSnapshot {
254
266
  broll: BrollPlacement[];
255
267
  continuityPlanReceipt?: BrollContinuityPlanReceipt;
256
268
  jumpCuts?: ArollJumpCut[];
269
+ brollCoverage?: BrollCoverageSummary;
257
270
  outputDurationMs: number;
258
271
  }
259
272
 
package/src/project.ts CHANGED
@@ -20,6 +20,7 @@ import type {
20
20
  import { verifyBrollPlacementSelection } from "./broll.ts";
21
21
  import {
22
22
  analyzeArollJumpCuts,
23
+ assertBrollCoverageBudget,
23
24
  findOverlappingBrollWindows,
24
25
  findShortArollFlashGaps,
25
26
  verifyBrollContinuityPlanReceipt,
@@ -90,7 +91,12 @@ async function validateTimeline(
90
91
  revision: number,
91
92
  continuityPlanReceipt: BrollContinuityPlanReceipt | undefined,
92
93
  signal?: AbortSignal,
93
- ): Promise<{ outputDurationMs: number; broll: BrollPlacement[]; jumpCuts: ReturnType<typeof analyzeArollJumpCuts> }> {
94
+ ): Promise<{
95
+ outputDurationMs: number;
96
+ broll: BrollPlacement[];
97
+ brollCoverage: ReturnType<typeof assertBrollCoverageBudget>;
98
+ jumpCuts: ReturnType<typeof analyzeArollJumpCuts>;
99
+ }> {
94
100
  if (aroll.length === 0 || aroll.length > 1_000) throw new Error("A-roll must contain 1-1000 segments");
95
101
  uniqueIds(aroll, "A-roll segment");
96
102
  for (const segment of aroll) {
@@ -124,6 +130,7 @@ async function validateTimeline(
124
130
  if (flashGap) {
125
131
  throw new Error(`B-roll placements ${flashGap.fromBrollId} and ${flashGap.toBrollId} leave a brief ${flashGap.durationMs}ms A-roll flash; run talking_head_broll_plan and reselect the bridged window`);
126
132
  }
133
+ const brollCoverage = assertBrollCoverageBudget(broll, outputDurationMs);
127
134
  if (broll.length > 0 && !continuityPlanReceipt) {
128
135
  throw new Error("B-roll continuityPlanReceipt is required; run talking_head_broll_plan first");
129
136
  }
@@ -144,6 +151,7 @@ async function validateTimeline(
144
151
  return {
145
152
  outputDurationMs,
146
153
  broll: normalizedBroll,
154
+ brollCoverage,
147
155
  jumpCuts: continuityPlanReceipt
148
156
  ? structuredClone(continuityPlanReceipt.jumpCuts)
149
157
  : analyzeArollJumpCuts(aroll, broll, outputDurationMs, 250, 500),
@@ -214,6 +222,7 @@ export async function createTalkingHeadProject(cwd: string, input: CreateTalking
214
222
  policy,
215
223
  aroll: analysis.segments,
216
224
  broll: [],
225
+ brollCoverage: assertBrollCoverageBudget([], analysis.outputDurationMs),
217
226
  jumpCuts: analyzeArollJumpCuts(analysis.segments, [], analysis.outputDurationMs, 250, 500),
218
227
  outputDurationMs: analysis.outputDurationMs,
219
228
  };
@@ -302,6 +311,7 @@ export async function applyTimeline(cwd: string, input: ApplyTimelineInput, sign
302
311
  policy: current.policy,
303
312
  aroll: structuredClone(input.aroll),
304
313
  broll: validated.broll,
314
+ brollCoverage: validated.brollCoverage,
305
315
  jumpCuts: validated.jumpCuts,
306
316
  ...(input.continuityPlanReceipt === undefined
307
317
  ? {}