@speclip/pi-talking-head 0.1.1 → 0.1.3

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
@@ -1,6 +1,6 @@
1
1
  # @speclip/pi-talking-head
2
2
 
3
- 给 [Pi](https://github.com/earendil-works/pi) 用的口播剪辑决策包。它读取 `pi-speech` 风格的词级时间戳,识别词间停顿,生成保守的 A-roll 剪辑方案,并可加入 B-roll 规划。
3
+ 给 [Pi](https://github.com/earendil-works/pi) 用的口播剪辑决策包。它读取 `pi-speech` 风格的词级时间戳,结合整句上下文识别词间停顿、语气词和相邻重复,生成保守的 A-roll 剪辑方案,并可加入 B-roll 规划。
4
4
 
5
5
  它不直接调用 FFmpeg。最终输出是 `pi-media@0.3.1` 的通用 timeline EDL,由 `pi-media` 负责素材校验、不可变 revision、渲染和验收。
6
6
 
@@ -42,27 +42,86 @@ talking_head_create {
42
42
  }
43
43
  ```
44
44
 
45
- 默认只自动移除至少 500ms 的词间停顿。每个保留片段前留 50ms、后留 80ms,避免切掉辅音、尾音和自然气口。停顿还会分为:
45
+ 默认只自动移除至少 500ms、且没有语气词或表达边界保护信号的词间停顿。每个保留片段前留 50ms、后留 80ms,避免切掉辅音、尾音和自然气口。停顿还会分为:
46
46
 
47
47
  - `safe`:至少 400ms,通常可以切。
48
48
  - `review`:150–399ms,必须结合语义和画面判断。
49
49
  - `unsafe`:少于 150ms,默认不切。
50
50
 
51
- 工具返回 revision 1、停顿摘要和可直接交给 `pi-media.edit_apply` 的 `mediaOperation`。
51
+ 若停顿紧邻候选语气词,或位于问号、感叹号等表达边界之后,即使超过阈值也会降级为 `review`,不会进入默认自动剪辑。工具返回 revision 1、内容与停顿摘要,以及可直接交给 `pi-media.edit_apply` 的 `mediaOperation`。
52
52
 
53
- ### 2. 分页检查候选气口
53
+ ### 2. 分页检查整句和编辑候选
54
54
 
55
55
  ```js
56
56
  talking_head_get {
57
57
  projectId: "launch-video",
58
+ sentenceOffset: 0,
59
+ sentenceLimit: 20,
58
60
  pauseOffset: 0,
59
- pauseLimit: 50
61
+ pauseLimit: 50,
62
+ fillerOffset: 0,
63
+ fillerLimit: 50,
64
+ repetitionOffset: 0,
65
+ repetitionLimit: 50,
66
+ includeTranscriptText: true
60
67
  }
61
68
  ```
62
69
 
63
- 只有调用这个工具时才会把候选停顿放进当前会话上下文;安装 package 不会把整份转录常驻注入上下文。
70
+ 工具会分页返回整句上下文、停顿、语气词和相邻重复;需要判断整段结构时可显式取得完整转录文本。`啊`、`额`、`嗯` 等只会成为 `review` 候选,Agent 必须判断它是口癖、语义成分还是刻意表达,不能自动删除。文本标点只能提供低置信度且可以并存的表达线索,不等同于声学情绪识别。
64
71
 
65
- ### 3. 写入人工确认后的时间线
72
+ 只有调用这个工具时才会把这些证据放进当前会话上下文;安装 package 不会把整份转录常驻注入上下文。
73
+
74
+ ### 3. 文件名优先筛选 B-roll
75
+
76
+ 先为已经稳定的 A-roll 片段建立一个结构化需求,再扫描指定素材目录:
77
+
78
+ ```js
79
+ talking_head_broll_match {
80
+ projectId: "launch-video",
81
+ assetDirectory: "assets/broll",
82
+ recursive: true,
83
+ maxFiles: 1000,
84
+ maxEntries: 20000,
85
+ maxDepth: 12,
86
+ maxCandidates: 5,
87
+ candidateOffset: 0,
88
+ need: {
89
+ id: "mouse-demo",
90
+ outputStartMs: 5200,
91
+ outputEndMs: 8200,
92
+ speechText: "电脑除了键盘,还需要鼠标",
93
+ purpose: "demonstrate",
94
+ searchTerms: ["鼠标", "操作"],
95
+ reason: "展示口播提到的具体对象和动作"
96
+ }
97
+ }
98
+ ```
99
+
100
+ 该工具只读取目录项和文件名,不解码、不探测、不哈希视频。它最多返回限定数量的候选,并给出三种结果:
101
+
102
+ - `filename-direct`:一个文件名唯一且明确命中,只检查这一条素材。
103
+ - `filename-shortlist`:多个名字可能匹配,只检查返回的 shortlist。
104
+ - `visual-fallback`:文件名没有有效语义,再对限定 shortlist 使用低成本联络表。
105
+
106
+ 扫描同时受 `maxFiles`、`maxEntries` 和 `maxDepth` 约束并支持取消。完整扫描时,若当前 shortlist 都不合格,可使用非空的 `nextCandidateOffset` 读取下一批。结果一旦截断,工具不会声称唯一命中,也不会返回可复用的分页游标;此时应缩小素材目录后重扫,避免文件系统枚举顺序造成候选漂移。
107
+
108
+ 确定素材后才调用 `media_probe` 和 `media_contact_sheet`。长素材先低密度定位大致范围,再只对候选范围高密度抽帧;必须使用 `contact_sheet_manifest.json` 的真实时间戳,不能从 PNG 猜时间。找到连续可用且覆盖完整成片窗口的片段后,验证并生成 placement:
109
+
110
+ ```js
111
+ talking_head_broll_select {
112
+ projectId: "launch-video",
113
+ need: { /* 与 match 时相同 */ },
114
+ assetPath: "assets/broll/mouse-demo.mp4",
115
+ manifestPath: "analysis/mouse-demo/contact_sheet_manifest.json",
116
+ selectedStartMs: 12400,
117
+ evidenceTimestampsMs: [12400, 13900, 15400],
118
+ fit: "cover"
119
+ }
120
+ ```
121
+
122
+ 该工具会验证素材 SHA-256、manifest 分析范围、起始帧和覆盖片段尾部的证据时间戳,返回可直接交给 `talking_head_apply` 的完整 placement。placement 内含选择凭证;不要手写或删除其中字段。
123
+
124
+ ### 4. 写入人工确认后的时间线
66
125
 
67
126
  ```js
68
127
  talking_head_apply {
@@ -72,23 +131,13 @@ talking_head_apply {
72
131
  { id: "hook", sourceStartMs: 50, sourceEndMs: 4120 },
73
132
  { id: "answer", sourceStartMs: 4860, sourceEndMs: 13200 }
74
133
  ],
75
- broll: [{
76
- id: "product-demo",
77
- assetPath: "assets/product-demo.mp4",
78
- outputStartMs: 5200,
79
- outputEndMs: 7900,
80
- assetStartMs: 0,
81
- fit: "cover",
82
- audio: "keep-primary",
83
- query: "产品界面操作特写",
84
- reason: "具体展示口播中提到的三步操作"
85
- }]
134
+ broll: [selection.placement]
86
135
  }
87
136
  ```
88
137
 
89
- B-roll 使用成片时间轴定位,永远保留主口播音轨。工具会固定素材字节数和 SHA-256,素材被替换后拒绝导出 EDL。
138
+ B-roll 使用成片时间轴定位,`assetStartMs` 来自联络表 manifest,并永远保留主口播音轨。`talking_head_apply` 会重新校验选择凭证、素材与 manifest 的 SHA-256;时间点被手改或素材被替换都会拒绝写入新修订。
90
139
 
91
- ### 4. 交给 pi-media 渲染
140
+ ### 5. 交给 pi-media 渲染
92
141
 
93
142
  先用同一个源文件创建 `pi-media` 项目,再把上一步的 `mediaOperation` 原样传入:
94
143
 
@@ -114,10 +163,12 @@ review { path: "out/launch-final.mp4" }
114
163
 
115
164
  | 工具 | 作用 |
116
165
  | --- | --- |
117
- | `talking_head_create` | 从视频和词级转录建立 revision 1,分析停顿并生成默认 EDL |
118
- | `talking_head_get` | 读取指定 revision,分页返回停顿候选,可选导出 pi-media EDL |
166
+ | `talking_head_create` | 从视频和词级转录建立 revision 1,分析句子、语气词、重复和停顿并生成默认 EDL |
167
+ | `talking_head_get` | 读取指定 revision,分页返回整句与编辑候选,可选导出 pi-media EDL |
168
+ | `talking_head_broll_match` | 只用文件名和目录名匹配本地 B-roll,返回受限 shortlist 与视觉升级建议 |
169
+ | `talking_head_broll_select` | 用 pi-media 联络表 manifest 验证具体源时间段并生成 placement |
119
170
  | `talking_head_apply` | 写入新的不可变口播 revision,并返回 pi-media EDL |
120
171
 
121
172
  ## 当前边界
122
173
 
123
- 0.1.0 不负责语音转录、素材搜索、字幕、画面理解或渲染。它只提供稳定的口播时间线决策层。B-roll 的搜索与视觉匹配可以后续新增为独立 Skill 或 Extension,最终仍写入同一份 `broll` 数据并由 `pi-media` 渲染。
174
+ 当前版本不负责语音转录、声学情绪识别、联网素材搜索、字幕、画面理解或渲染。它提供整句文本、时间轴和本地素材名称层面的编辑证据;需要依赖真实语气、呼吸、表演意图或画面内容的决定,必须由 Agent 结合原音频、联络表和画面复核。最终 B-roll 仍写入同一份 `broll` 数据并由 `pi-media` 渲染。
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
+ import { matchWorkspaceBrollAssets, selectBrollWindow } from "../../src/broll.ts";
3
4
  import { toMediaTimelineOperation } from "../../src/edl.ts";
4
5
  import {
5
6
  applyTimeline,
@@ -33,11 +34,36 @@ const brollPlacement = Type.Object({
33
34
  assetPath: Type.String({ description: "Workspace-relative B-roll video path." }),
34
35
  outputStartMs: Type.Number({ minimum: 0 }),
35
36
  outputEndMs: Type.Number({ exclusiveMinimum: 0 }),
36
- assetStartMs: Type.Optional(Type.Number({ minimum: 0 })),
37
+ assetStartMs: Type.Number({ minimum: 0, description: "Required manifest-backed source timestamp selected from the B-roll asset." }),
37
38
  fit: Type.Union([Type.Literal("cover"), Type.Literal("contain")]),
38
39
  audio: Type.Literal("keep-primary", { description: "B-roll never replaces the talking-head audio." }),
39
40
  query: Type.Optional(Type.String({ maxLength: 500 })),
40
41
  reason: Type.Optional(Type.String({ maxLength: 1_000 })),
42
+ selectionReceipt: Type.Object({
43
+ assetBytes: Type.Integer({ minimum: 0 }),
44
+ assetSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }),
45
+ manifestPath: Type.String({ minLength: 1 }),
46
+ manifestSha256: Type.String({ pattern: "^[a-f0-9]{64}$" }),
47
+ selectedEndMs: Type.Number({ minimum: 0 }),
48
+ evidenceTimestampsMs: Type.Array(Type.Number({ minimum: 0 }), { minItems: 1, maxItems: 100 }),
49
+ }, { additionalProperties: false }),
50
+ }, { additionalProperties: false });
51
+
52
+ const brollNeed = Type.Object({
53
+ id: stableId,
54
+ outputStartMs: Type.Number({ minimum: 0 }),
55
+ outputEndMs: Type.Number({ exclusiveMinimum: 0 }),
56
+ speechText: Type.String({ minLength: 1, maxLength: 2_000 }),
57
+ purpose: Type.Union([
58
+ Type.Literal("demonstrate"),
59
+ Type.Literal("explain"),
60
+ Type.Literal("evidence"),
61
+ Type.Literal("establish"),
62
+ Type.Literal("transition"),
63
+ Type.Literal("mask-cut"),
64
+ ]),
65
+ searchTerms: Type.Array(Type.String({ minLength: 1, maxLength: 100 }), { minItems: 1, maxItems: 20 }),
66
+ reason: Type.String({ minLength: 1, maxLength: 1_000 }),
41
67
  }, { additionalProperties: false });
42
68
 
43
69
  export default function talkingHead(pi: ExtensionAPI): void {
@@ -53,7 +79,7 @@ export default function talkingHead(pi: ExtensionAPI): void {
53
79
  headPaddingMs: Type.Optional(Type.Number({ minimum: 30, maximum: 200 })),
54
80
  tailPaddingMs: Type.Optional(Type.Number({ minimum: 30, maximum: 200 })),
55
81
  }, { additionalProperties: false }),
56
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
82
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
57
83
  const policy = {
58
84
  ...(params.cutThresholdMs === undefined ? {} : { cutThresholdMs: params.cutThresholdMs }),
59
85
  ...(params.headPaddingMs === undefined ? {} : { headPaddingMs: params.headPaddingMs }),
@@ -75,29 +101,64 @@ export default function talkingHead(pi: ExtensionAPI): void {
75
101
  pi.registerTool({
76
102
  name: "talking_head_get",
77
103
  label: "Inspect talking-head edit",
78
- description: "Read one immutable talking-head revision and a bounded page of pause candidates. Optionally include the generic pi-media timeline operation for that revision.",
104
+ description: "Read one immutable talking-head revision plus bounded sentence, pause, filler-word, and repetition evidence for Agent editorial judgment. Optionally include the generic pi-media timeline operation.",
79
105
  parameters: Type.Object({
80
106
  projectId: stableId,
81
107
  revision: Type.Optional(Type.Integer({ minimum: 1 })),
108
+ sentenceOffset: Type.Optional(Type.Integer({ minimum: 0 })),
109
+ sentenceLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
82
110
  pauseOffset: Type.Optional(Type.Integer({ minimum: 0 })),
83
111
  pauseLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
112
+ fillerOffset: Type.Optional(Type.Integer({ minimum: 0 })),
113
+ fillerLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
114
+ repetitionOffset: Type.Optional(Type.Integer({ minimum: 0 })),
115
+ repetitionLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
116
+ includeTranscriptText: Type.Optional(Type.Boolean({ description: "Include the complete transcript text for passage-level Agent review." })),
84
117
  includeMediaOperation: Type.Optional(Type.Boolean()),
85
118
  }, { additionalProperties: false }),
86
119
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
87
120
  const { project, snapshot } = await getTalkingHeadProject(ctx.cwd, params.projectId, params.revision);
88
121
  const analysis = await getAnalysis(ctx.cwd, project);
89
- const offset = params.pauseOffset ?? 0;
90
- const limit = params.pauseLimit ?? 50;
122
+ const sentenceOffset = params.sentenceOffset ?? 0;
123
+ const sentenceLimit = params.sentenceLimit ?? 20;
124
+ const pauseOffset = params.pauseOffset ?? 0;
125
+ const pauseLimit = params.pauseLimit ?? 50;
126
+ const fillerOffset = params.fillerOffset ?? 0;
127
+ const fillerLimit = params.fillerLimit ?? 50;
128
+ const repetitionOffset = params.repetitionOffset ?? 0;
129
+ const repetitionLimit = params.repetitionLimit ?? 50;
130
+ const sentences = analysis.sentences ?? [];
131
+ const fillers = analysis.fillers ?? [];
132
+ const repetitions = analysis.repetitions ?? [];
91
133
  const details: Record<string, unknown> = {
92
134
  project,
93
135
  snapshot,
136
+ sentences: {
137
+ offset: sentenceOffset,
138
+ limit: sentenceLimit,
139
+ total: sentences.length,
140
+ items: sentences.slice(sentenceOffset, sentenceOffset + sentenceLimit),
141
+ },
94
142
  pauses: {
95
- offset,
96
- limit,
143
+ offset: pauseOffset,
144
+ limit: pauseLimit,
97
145
  total: analysis.candidates.length,
98
- items: analysis.candidates.slice(offset, offset + limit),
146
+ items: analysis.candidates.slice(pauseOffset, pauseOffset + pauseLimit),
147
+ },
148
+ fillers: {
149
+ offset: fillerOffset,
150
+ limit: fillerLimit,
151
+ total: fillers.length,
152
+ items: fillers.slice(fillerOffset, fillerOffset + fillerLimit),
153
+ },
154
+ repetitions: {
155
+ offset: repetitionOffset,
156
+ limit: repetitionLimit,
157
+ total: repetitions.length,
158
+ items: repetitions.slice(repetitionOffset, repetitionOffset + repetitionLimit),
99
159
  },
100
160
  };
161
+ if (params.includeTranscriptText) details.transcriptText = analysis.text;
101
162
  if (params.includeMediaOperation) {
102
163
  await assertProjectSourcesUnchanged(ctx.cwd, project);
103
164
  await assertSnapshotAssetsUnchanged(ctx.cwd, snapshot);
@@ -107,6 +168,77 @@ export default function talkingHead(pi: ExtensionAPI): void {
107
168
  },
108
169
  });
109
170
 
171
+ pi.registerTool({
172
+ name: "talking_head_broll_match",
173
+ label: "Match local B-roll assets",
174
+ description: "Filename-first B-roll matching for one approved output window. Scans only directory metadata, returns a bounded shortlist, and tells the Agent whether to inspect one selected asset, inspect an ambiguous shortlist, or fall back to low-cost visual screening. It never decodes, probes, or hashes video contents.",
175
+ parameters: Type.Object({
176
+ projectId: stableId,
177
+ revision: Type.Optional(Type.Integer({ minimum: 1 })),
178
+ assetDirectory: Type.String({ minLength: 1, description: "Workspace-relative directory containing candidate B-roll videos." }),
179
+ recursive: Type.Optional(Type.Boolean()),
180
+ maxFiles: Type.Optional(Type.Integer({ minimum: 1, maximum: 5_000 })),
181
+ maxEntries: Type.Optional(Type.Integer({ minimum: 1, maximum: 100_000 })),
182
+ maxDepth: Type.Optional(Type.Integer({ minimum: 0, maximum: 50 })),
183
+ maxCandidates: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
184
+ candidateOffset: Type.Optional(Type.Integer({ minimum: 0 })),
185
+ need: brollNeed,
186
+ }, { additionalProperties: false }),
187
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
188
+ const { snapshot } = await getTalkingHeadProject(ctx.cwd, params.projectId, params.revision);
189
+ if (params.need.outputEndMs > snapshot.outputDurationMs) {
190
+ throw new Error(`B-roll need ${params.need.id} exceeds output duration ${snapshot.outputDurationMs}ms`);
191
+ }
192
+ const match = await matchWorkspaceBrollAssets(ctx.cwd, {
193
+ assetDirectory: params.assetDirectory,
194
+ ...(params.recursive === undefined ? {} : { recursive: params.recursive }),
195
+ ...(params.maxFiles === undefined ? {} : { maxFiles: params.maxFiles }),
196
+ ...(params.maxEntries === undefined ? {} : { maxEntries: params.maxEntries }),
197
+ ...(params.maxDepth === undefined ? {} : { maxDepth: params.maxDepth }),
198
+ ...(params.maxCandidates === undefined ? {} : { maxCandidates: params.maxCandidates }),
199
+ ...(params.candidateOffset === undefined ? {} : { candidateOffset: params.candidateOffset }),
200
+ need: params.need,
201
+ ...(signal === undefined ? {} : { signal }),
202
+ });
203
+ return result({
204
+ projectId: params.projectId,
205
+ revision: snapshot.revision,
206
+ match,
207
+ ...(match.truncated ? { warning: `The bounded scan stopped after ${match.scannedEntryCount} entries and ${match.scannedFileCount} video filenames; filename-direct is disabled until the asset directory is narrowed or fully scanned.` } : {}),
208
+ });
209
+ },
210
+ });
211
+
212
+ pi.registerTool({
213
+ name: "talking_head_broll_select",
214
+ label: "Select B-roll source window",
215
+ description: "Validate one exact B-roll source window against a pi-media contact_sheet_manifest.json. Verifies the selected asset hash, analyzed range, and manifest-backed evidence timestamps, then returns a placement ready for talking_head_apply.",
216
+ parameters: Type.Object({
217
+ projectId: stableId,
218
+ revision: Type.Optional(Type.Integer({ minimum: 1 })),
219
+ need: brollNeed,
220
+ assetPath: Type.String({ minLength: 1 }),
221
+ manifestPath: Type.String({ minLength: 1 }),
222
+ selectedStartMs: Type.Number({ minimum: 0 }),
223
+ evidenceTimestampsMs: Type.Array(Type.Number({ minimum: 0 }), { minItems: 1, maxItems: 100 }),
224
+ fit: Type.Union([Type.Literal("cover"), Type.Literal("contain")]),
225
+ }, { additionalProperties: false }),
226
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
227
+ const { snapshot } = await getTalkingHeadProject(ctx.cwd, params.projectId, params.revision);
228
+ if (params.need.outputEndMs > snapshot.outputDurationMs) {
229
+ throw new Error(`B-roll need ${params.need.id} exceeds output duration ${snapshot.outputDurationMs}ms`);
230
+ }
231
+ return result({
232
+ projectId: params.projectId,
233
+ revision: snapshot.revision,
234
+ selection: await selectBrollWindow(ctx.cwd, {
235
+ ...params,
236
+ ...(signal === undefined ? {} : { signal }),
237
+ }),
238
+ });
239
+ },
240
+ });
241
+
110
242
  pi.registerTool({
111
243
  name: "talking_head_apply",
112
244
  label: "Apply talking-head timeline",
@@ -117,9 +249,9 @@ export default function talkingHead(pi: ExtensionAPI): void {
117
249
  aroll: Type.Array(arrollSegment, { minItems: 1, maxItems: 1_000 }),
118
250
  broll: Type.Array(brollPlacement, { maxItems: 500 }),
119
251
  }, { additionalProperties: false }),
120
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
252
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
121
253
  const current = await getTalkingHeadProject(ctx.cwd, params.projectId);
122
- const snapshot = await applyTimeline(ctx.cwd, params);
254
+ const snapshot = await applyTimeline(ctx.cwd, params, signal);
123
255
  return result({
124
256
  projectId: params.projectId,
125
257
  snapshot,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@speclip/pi-talking-head",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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 analyze word-level pauses, propose a conservative cut strategy, optionally place justified B-roll, 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, 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.
@@ -9,10 +9,19 @@ Use `pi-speech` for word evidence, this package for editorial decisions, and `pi
9
9
 
10
10
  1. Obtain a `pi-speech`-compatible transcript JSON containing word-level `beginMs` and `endMs`. Do not infer frame-accurate cuts from sentence text alone.
11
11
  2. Call `talking_head_create` with the source video and transcript. Keep the default 500ms cut threshold, 50ms head padding, and 80ms tail padding unless the user requests a different rhythm.
12
- 3. Inspect pause candidates with bounded `talking_head_get` pages. Treat `safe` as a candidate, not an instruction. Preserve pauses that carry emphasis, emotion, topic boundaries, or a deliberate breath.
13
- 4. Before changing the revision, summarize the proposed rhythm: what will be removed, which short pauses will remain, and why. Get the user's approval unless they explicitly delegated editorial judgment.
14
- 5. Call `talking_head_apply` using exact word-boundary A-roll ranges. Every B-roll window must have a concrete visual purpose in `reason`; keep `audio: keep-primary`.
15
- 6. 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`.
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
+ 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
+ 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_match` before generating contact sheets. Follow its bounded result:
17
+ - `filename-direct`: probe and visually inspect only the selected asset to choose its source window. Do not call `media_contact_sheet` for unselected assets.
18
+ - `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.
19
+ - `visual-fallback`: use low-cost contact sheets on the bounded shortlist because filenames supplied no useful evidence. Exhaust that batch before requesting a non-null `nextCandidateOffset`.
20
+ - If the scan is truncated, do not paginate it: narrow the asset directory and rescan so the candidate inventory is stable.
21
+ 8. 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 output window, preferably with 500ms handles on both sides.
22
+ 9. Call `talking_head_broll_select` with the selected asset, manifest path, exact source start, and the manifest timestamps actually reviewed. Use its complete returned `placement`, including `selectionReceipt`; do not hand-author or omit selection fields.
23
+ 10. Call `talking_head_apply` using exact word-boundary A-roll ranges and the validated B-roll placement. Every B-roll window must have a concrete visual purpose in `reason`; keep `audio: keep-primary`.
24
+ 11. 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`.
16
25
 
17
26
  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.
18
27
 
@@ -8,10 +8,21 @@
8
8
  - Prefer one clean removal over many micro-cuts. Dense sub-150ms cuts create robotic cadence and visible jump cuts.
9
9
  - `pi-media` adds a 30ms audio fade at every primary-segment edge to suppress clicks. This does not repair a semantically bad cut.
10
10
 
11
+ ## Editing spoken content
12
+
13
+ - Read the complete sentence before removing a filler or repeated word. `啊`, `嗯`, `那个`, and `就是` can carry hesitation, emphasis, transition, or actual meaning.
14
+ - Treat transcript punctuation as a low-confidence delivery hint, not acoustic emotion detection. Confirm expressive decisions against the source audio and picture.
15
+ - Prefer removing a complete false start at word boundaries over deleting an isolated sound that leaves an unnatural join.
16
+ - If a necessary content cut creates a hard join, preserve room tone and use a short transition or justified B-roll rather than compressing every gap.
17
+
11
18
  ## Using B-roll
12
19
 
13
20
  - Place B-roll on the output timeline after A-roll cuts have stabilized.
14
21
  - Use it to show the object, action, place, comparison, or evidence currently being discussed—not as random decoration.
22
+ - Treat a filename match as asset-level evidence, never as proof that a particular source window is usable. Inspect continuous motion before selecting `assetStartMs`.
23
+ - Start with filename and directory metadata. Generate contact sheets only for the selected asset, a bounded ambiguous shortlist, or a bounded fallback batch whose names carry no meaning.
24
+ - For a long selected asset, locate a broad range cheaply and then generate a denser contact sheet only for that range. Use manifest timestamps, not visual timestamp transcription.
25
+ - Ensure the selected source window covers the complete spoken beat. Prefer extra source handles so a later timing adjustment does not require rescanning.
15
26
  - Cover visible jump cuts when appropriate, but do not hide continuity errors that change meaning.
16
27
  - Keep the primary voice track. B-roll audio replacement is outside the current contract.
17
28
  - Record the search query and editorial reason so a later agent can replace the asset without guessing intent.