@speclip/pi-talking-head 0.1.2 → 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 +57 -15
- package/extensions/talking-head/index.ts +101 -4
- package/package.json +1 -1
- package/prompts/edit-talking-head.md +1 -1
- package/skills/talking-head-edit/SKILL.md +10 -2
- package/skills/talking-head-edit/references/cut-craft.md +4 -0
- package/src/broll.ts +406 -0
- package/src/contracts.ts +63 -1
- package/src/project.ts +9 -5
- package/src/workspace.ts +18 -4
package/README.md
CHANGED
|
@@ -71,7 +71,57 @@ talking_head_get {
|
|
|
71
71
|
|
|
72
72
|
只有调用这个工具时才会把这些证据放进当前会话上下文;安装 package 不会把整份转录常驻注入上下文。
|
|
73
73
|
|
|
74
|
-
### 3.
|
|
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. 写入人工确认后的时间线
|
|
75
125
|
|
|
76
126
|
```js
|
|
77
127
|
talking_head_apply {
|
|
@@ -81,23 +131,13 @@ talking_head_apply {
|
|
|
81
131
|
{ id: "hook", sourceStartMs: 50, sourceEndMs: 4120 },
|
|
82
132
|
{ id: "answer", sourceStartMs: 4860, sourceEndMs: 13200 }
|
|
83
133
|
],
|
|
84
|
-
broll: [
|
|
85
|
-
id: "product-demo",
|
|
86
|
-
assetPath: "assets/product-demo.mp4",
|
|
87
|
-
outputStartMs: 5200,
|
|
88
|
-
outputEndMs: 7900,
|
|
89
|
-
assetStartMs: 0,
|
|
90
|
-
fit: "cover",
|
|
91
|
-
audio: "keep-primary",
|
|
92
|
-
query: "产品界面操作特写",
|
|
93
|
-
reason: "具体展示口播中提到的三步操作"
|
|
94
|
-
}]
|
|
134
|
+
broll: [selection.placement]
|
|
95
135
|
}
|
|
96
136
|
```
|
|
97
137
|
|
|
98
|
-
B-roll
|
|
138
|
+
B-roll 使用成片时间轴定位,`assetStartMs` 来自联络表 manifest,并永远保留主口播音轨。`talking_head_apply` 会重新校验选择凭证、素材与 manifest 的 SHA-256;时间点被手改或素材被替换都会拒绝写入新修订。
|
|
99
139
|
|
|
100
|
-
###
|
|
140
|
+
### 5. 交给 pi-media 渲染
|
|
101
141
|
|
|
102
142
|
先用同一个源文件创建 `pi-media` 项目,再把上一步的 `mediaOperation` 原样传入:
|
|
103
143
|
|
|
@@ -125,8 +165,10 @@ review { path: "out/launch-final.mp4" }
|
|
|
125
165
|
| --- | --- |
|
|
126
166
|
| `talking_head_create` | 从视频和词级转录建立 revision 1,分析句子、语气词、重复和停顿并生成默认 EDL |
|
|
127
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 |
|
|
128
170
|
| `talking_head_apply` | 写入新的不可变口播 revision,并返回 pi-media EDL |
|
|
129
171
|
|
|
130
172
|
## 当前边界
|
|
131
173
|
|
|
132
|
-
|
|
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.
|
|
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,
|
|
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 }),
|
|
@@ -142,6 +168,77 @@ export default function talkingHead(pi: ExtensionAPI): void {
|
|
|
142
168
|
},
|
|
143
169
|
});
|
|
144
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
|
+
|
|
145
242
|
pi.registerTool({
|
|
146
243
|
name: "talking_head_apply",
|
|
147
244
|
label: "Apply talking-head timeline",
|
|
@@ -152,9 +249,9 @@ export default function talkingHead(pi: ExtensionAPI): void {
|
|
|
152
249
|
aroll: Type.Array(arrollSegment, { minItems: 1, maxItems: 1_000 }),
|
|
153
250
|
broll: Type.Array(brollPlacement, { maxItems: 500 }),
|
|
154
251
|
}, { additionalProperties: false }),
|
|
155
|
-
async execute(_toolCallId, params,
|
|
252
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
156
253
|
const current = await getTalkingHeadProject(ctx.cwd, params.projectId);
|
|
157
|
-
const snapshot = await applyTimeline(ctx.cwd, params);
|
|
254
|
+
const snapshot = await applyTimeline(ctx.cwd, params, signal);
|
|
158
255
|
return result({
|
|
159
256
|
projectId: params.projectId,
|
|
160
257
|
snapshot,
|
package/package.json
CHANGED
|
@@ -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;
|
|
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.
|
|
@@ -12,8 +12,16 @@ 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.
|
|
16
|
-
7.
|
|
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`.
|
|
17
25
|
|
|
18
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.
|
|
19
27
|
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
|
|
20
20
|
- Place B-roll on the output timeline after A-roll cuts have stabilized.
|
|
21
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.
|
|
22
26
|
- Cover visible jump cuts when appropriate, but do not hide continuity errors that change meaning.
|
|
23
27
|
- Keep the primary voice track. B-roll audio replacement is outside the current contract.
|
|
24
28
|
- Record the search query and editorial reason so a later agent can replace the asset without guessing intent.
|
package/src/broll.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { opendir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, extname, join, relative, sep } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
BrollAssetCandidate,
|
|
5
|
+
BrollAssetMatch,
|
|
6
|
+
BrollNeed,
|
|
7
|
+
BrollPlacementInput,
|
|
8
|
+
BrollSelectionReceipt,
|
|
9
|
+
BrollWindowSelection,
|
|
10
|
+
FileRef,
|
|
11
|
+
} from "./contracts.ts";
|
|
12
|
+
import { resolveExistingWorkspaceDirectory, resolveExistingWorkspaceFile, snapshotFile, workspaceRoot } from "./workspace.ts";
|
|
13
|
+
|
|
14
|
+
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi"]);
|
|
15
|
+
|
|
16
|
+
export interface RankBrollAssetsInput {
|
|
17
|
+
need: BrollNeed;
|
|
18
|
+
assetPaths: string[];
|
|
19
|
+
maxCandidates?: number;
|
|
20
|
+
candidateOffset?: number;
|
|
21
|
+
completeInventory?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MatchWorkspaceBrollAssetsInput {
|
|
25
|
+
assetDirectory: string;
|
|
26
|
+
recursive?: boolean;
|
|
27
|
+
maxFiles?: number;
|
|
28
|
+
maxEntries?: number;
|
|
29
|
+
maxDepth?: number;
|
|
30
|
+
maxCandidates?: number;
|
|
31
|
+
candidateOffset?: number;
|
|
32
|
+
need: BrollNeed;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type WorkspaceBrollAssetMatch = BrollAssetMatch & {
|
|
37
|
+
assetDirectory: string;
|
|
38
|
+
scannedFileCount: number;
|
|
39
|
+
scannedEntryCount: number;
|
|
40
|
+
truncated: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface SelectBrollWindowInput {
|
|
44
|
+
need: BrollNeed;
|
|
45
|
+
assetPath: string;
|
|
46
|
+
manifestPath: string;
|
|
47
|
+
selectedStartMs: number;
|
|
48
|
+
evidenceTimestampsMs: number[];
|
|
49
|
+
fit: "cover" | "contain";
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ContactSheetManifest {
|
|
54
|
+
schemaVersion: number;
|
|
55
|
+
source?: { path?: string; bytes?: number; sha256?: string };
|
|
56
|
+
range?: { startSeconds?: number; endSeconds?: number };
|
|
57
|
+
artifacts?: Array<{ timestampsSeconds?: number[]; sourceSha256?: string }>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalized(value: string): string {
|
|
61
|
+
return value.normalize("NFKC").toLowerCase().replace(/[\p{P}\p{S}\s]+/gu, "");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizedTerms(terms: string[]): string[] {
|
|
65
|
+
const unique = new Set<string>();
|
|
66
|
+
for (const term of terms) {
|
|
67
|
+
const value = normalized(term);
|
|
68
|
+
if (value) unique.add(value);
|
|
69
|
+
}
|
|
70
|
+
if (unique.size === 0) throw new Error("B-roll searchTerms must contain at least one meaningful term");
|
|
71
|
+
return [...unique];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function candidateFor(assetPath: string, terms: string[]): BrollAssetCandidate {
|
|
75
|
+
const fileName = basename(assetPath);
|
|
76
|
+
const normalizedName = normalized(fileName.replace(/\.[^.]+$/u, ""));
|
|
77
|
+
const normalizedDirectory = normalized(dirname(assetPath));
|
|
78
|
+
const matchedTerms: string[] = [];
|
|
79
|
+
let score = 0;
|
|
80
|
+
for (const term of terms) {
|
|
81
|
+
if (normalizedName.includes(term)) {
|
|
82
|
+
matchedTerms.push(term);
|
|
83
|
+
score += 4;
|
|
84
|
+
} else if (normalizedDirectory.includes(term)) {
|
|
85
|
+
matchedTerms.push(term);
|
|
86
|
+
score += 2;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const highThreshold = Math.min(2, terms.length);
|
|
90
|
+
return {
|
|
91
|
+
assetPath,
|
|
92
|
+
fileName,
|
|
93
|
+
matchedTerms,
|
|
94
|
+
score,
|
|
95
|
+
confidence: matchedTerms.length >= highThreshold ? "high" : matchedTerms.length > 0 ? "medium" : "low",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateNeed(need: BrollNeed): void {
|
|
100
|
+
if (!Number.isFinite(need.outputStartMs) || !Number.isFinite(need.outputEndMs)
|
|
101
|
+
|| need.outputStartMs < 0 || need.outputEndMs <= need.outputStartMs) {
|
|
102
|
+
throw new Error("B-roll need must contain a valid output range");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function rankBrollAssets(input: RankBrollAssetsInput): BrollAssetMatch {
|
|
107
|
+
validateNeed(input.need);
|
|
108
|
+
if (input.assetPaths.length === 0) throw new Error("No candidate B-roll video assets were found");
|
|
109
|
+
const terms = normalizedTerms(input.need.searchTerms);
|
|
110
|
+
const maxCandidates = input.maxCandidates ?? 5;
|
|
111
|
+
if (!Number.isInteger(maxCandidates) || maxCandidates < 1 || maxCandidates > 20) {
|
|
112
|
+
throw new Error("maxCandidates must be within 1-20");
|
|
113
|
+
}
|
|
114
|
+
const ranked = [...new Set(input.assetPaths)].map((assetPath) => candidateFor(assetPath, terms)).sort((left, right) => (
|
|
115
|
+
right.score - left.score || (left.assetPath < right.assetPath ? -1 : left.assetPath > right.assetPath ? 1 : 0)
|
|
116
|
+
));
|
|
117
|
+
const candidateOffset = input.candidateOffset ?? 0;
|
|
118
|
+
if (!Number.isInteger(candidateOffset) || candidateOffset < 0 || candidateOffset >= ranked.length) {
|
|
119
|
+
throw new Error(`candidateOffset must be within 0-${ranked.length - 1}`);
|
|
120
|
+
}
|
|
121
|
+
const top = ranked[0];
|
|
122
|
+
const second = ranked[1];
|
|
123
|
+
const direct = (input.completeInventory ?? true) && candidateOffset === 0
|
|
124
|
+
&& top?.confidence === "high" && (second === undefined || top.score > second.score);
|
|
125
|
+
if (direct) {
|
|
126
|
+
return {
|
|
127
|
+
requiredDurationMs: input.need.outputEndMs - input.need.outputStartMs,
|
|
128
|
+
totalCandidates: ranked.length,
|
|
129
|
+
candidateOffset: 0,
|
|
130
|
+
nextCandidateOffset: null,
|
|
131
|
+
selectionMode: "filename-direct",
|
|
132
|
+
shortlist: [top],
|
|
133
|
+
nextStep: {
|
|
134
|
+
action: "inspect-selected-asset",
|
|
135
|
+
reason: "One filename is the unique high-confidence match; inspect only this asset to choose the source window.",
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const hasLexicalMatch = ranked.some((candidate) => candidate.confidence !== "low");
|
|
140
|
+
const shortlist = ranked.slice(candidateOffset, candidateOffset + maxCandidates);
|
|
141
|
+
const nextCandidateOffset = (input.completeInventory ?? true) && candidateOffset + shortlist.length < ranked.length
|
|
142
|
+
? candidateOffset + shortlist.length
|
|
143
|
+
: null;
|
|
144
|
+
const shared = {
|
|
145
|
+
requiredDurationMs: input.need.outputEndMs - input.need.outputStartMs,
|
|
146
|
+
totalCandidates: ranked.length,
|
|
147
|
+
candidateOffset,
|
|
148
|
+
nextCandidateOffset,
|
|
149
|
+
shortlist,
|
|
150
|
+
};
|
|
151
|
+
if (hasLexicalMatch) return {
|
|
152
|
+
...shared,
|
|
153
|
+
selectionMode: "filename-shortlist",
|
|
154
|
+
nextStep: {
|
|
155
|
+
action: "inspect-shortlist",
|
|
156
|
+
reason: "Filename evidence is ambiguous; visually inspect only the bounded shortlist.",
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
...shared,
|
|
161
|
+
selectionMode: "visual-fallback",
|
|
162
|
+
nextStep: {
|
|
163
|
+
action: "visual-fallback",
|
|
164
|
+
reason: "Filenames provide no useful evidence; use low-cost visual screening on the bounded shortlist.",
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface ScanState {
|
|
170
|
+
paths: string[];
|
|
171
|
+
visitedEntries: number;
|
|
172
|
+
truncated: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface ScanLimits {
|
|
176
|
+
recursive: boolean;
|
|
177
|
+
maxFiles: number;
|
|
178
|
+
maxEntries: number;
|
|
179
|
+
maxDepth: number;
|
|
180
|
+
signal?: AbortSignal;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function codePointCompare(left: string, right: string): number {
|
|
184
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function collectVideoPaths(directory: string, depth: number, limits: ScanLimits, state: ScanState): Promise<void> {
|
|
188
|
+
limits.signal?.throwIfAborted();
|
|
189
|
+
const entries = [];
|
|
190
|
+
const handle = await opendir(directory);
|
|
191
|
+
for await (const entry of handle) {
|
|
192
|
+
limits.signal?.throwIfAborted();
|
|
193
|
+
if (state.visitedEntries >= limits.maxEntries) {
|
|
194
|
+
state.truncated = true;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
state.visitedEntries += 1;
|
|
198
|
+
entries.push(entry);
|
|
199
|
+
}
|
|
200
|
+
entries.sort((left, right) => codePointCompare(left.name, right.name));
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
limits.signal?.throwIfAborted();
|
|
203
|
+
if (state.paths.length > limits.maxFiles) {
|
|
204
|
+
state.truncated = true;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (entry.isSymbolicLink()) continue;
|
|
208
|
+
const absolute = join(directory, entry.name);
|
|
209
|
+
if (entry.isDirectory()) {
|
|
210
|
+
if (limits.recursive) {
|
|
211
|
+
if (depth >= limits.maxDepth) state.truncated = true;
|
|
212
|
+
else await collectVideoPaths(absolute, depth + 1, limits, state);
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (entry.isFile() && VIDEO_EXTENSIONS.has(extname(entry.name).toLowerCase())) state.paths.push(absolute);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function matchWorkspaceBrollAssets(
|
|
221
|
+
cwd: string,
|
|
222
|
+
input: MatchWorkspaceBrollAssetsInput,
|
|
223
|
+
): Promise<WorkspaceBrollAssetMatch> {
|
|
224
|
+
const maxFiles = input.maxFiles ?? 1_000;
|
|
225
|
+
if (!Number.isInteger(maxFiles) || maxFiles < 1 || maxFiles > 5_000) {
|
|
226
|
+
throw new Error("maxFiles must be within 1-5000");
|
|
227
|
+
}
|
|
228
|
+
const maxEntries = input.maxEntries ?? 20_000;
|
|
229
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > 100_000) {
|
|
230
|
+
throw new Error("maxEntries must be within 1-100000");
|
|
231
|
+
}
|
|
232
|
+
const maxDepth = input.maxDepth ?? 12;
|
|
233
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 0 || maxDepth > 50) {
|
|
234
|
+
throw new Error("maxDepth must be within 0-50");
|
|
235
|
+
}
|
|
236
|
+
const directory = await resolveExistingWorkspaceDirectory(cwd, input.assetDirectory);
|
|
237
|
+
const state: ScanState = { paths: [], visitedEntries: 0, truncated: false };
|
|
238
|
+
await collectVideoPaths(directory, 0, {
|
|
239
|
+
recursive: input.recursive ?? true,
|
|
240
|
+
maxFiles,
|
|
241
|
+
maxEntries,
|
|
242
|
+
maxDepth,
|
|
243
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
244
|
+
}, state);
|
|
245
|
+
const truncated = state.truncated || state.paths.length > maxFiles;
|
|
246
|
+
if (truncated && (input.candidateOffset ?? 0) !== 0) {
|
|
247
|
+
throw new Error("candidateOffset cannot be reused after a truncated scan; narrow the asset directory and rescan");
|
|
248
|
+
}
|
|
249
|
+
const included = state.paths.slice(0, maxFiles);
|
|
250
|
+
const root = await workspaceRoot(cwd);
|
|
251
|
+
const toWorkspacePath = (path: string) => relative(root, path).split(sep).join("/");
|
|
252
|
+
const assetPaths = included.map(toWorkspacePath);
|
|
253
|
+
return {
|
|
254
|
+
assetDirectory: toWorkspacePath(directory),
|
|
255
|
+
scannedFileCount: included.length,
|
|
256
|
+
scannedEntryCount: state.visitedEntries,
|
|
257
|
+
truncated,
|
|
258
|
+
...rankBrollAssets({
|
|
259
|
+
need: input.need,
|
|
260
|
+
assetPaths,
|
|
261
|
+
...(input.maxCandidates === undefined ? {} : { maxCandidates: input.maxCandidates }),
|
|
262
|
+
...(input.candidateOffset === undefined ? {} : { candidateOffset: input.candidateOffset }),
|
|
263
|
+
completeInventory: !truncated,
|
|
264
|
+
}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseContactSheetManifest(payload: string, manifestPath: string): ContactSheetManifest {
|
|
269
|
+
try {
|
|
270
|
+
return JSON.parse(payload) as ContactSheetManifest;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
throw new Error(`Invalid contact-sheet manifest ${manifestPath}: ${(error as Error).message}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
interface SelectionWindow {
|
|
277
|
+
assetStartMs: number;
|
|
278
|
+
outputStartMs: number;
|
|
279
|
+
outputEndMs: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function validateManifestSelection(
|
|
283
|
+
asset: FileRef,
|
|
284
|
+
manifest: FileRef,
|
|
285
|
+
payload: ContactSheetManifest,
|
|
286
|
+
window: SelectionWindow,
|
|
287
|
+
receipt: BrollSelectionReceipt,
|
|
288
|
+
): void {
|
|
289
|
+
if (payload.schemaVersion !== 2 || !payload.source || !payload.range || !Array.isArray(payload.artifacts)) {
|
|
290
|
+
throw new Error("Contact-sheet manifest must use pi-media schemaVersion 2");
|
|
291
|
+
}
|
|
292
|
+
if (receipt.assetSha256 !== asset.sha256 || receipt.assetBytes !== asset.bytes) {
|
|
293
|
+
throw new Error("B-roll selection receipt does not match the current asset");
|
|
294
|
+
}
|
|
295
|
+
if (receipt.manifestPath !== manifest.path || receipt.manifestSha256 !== manifest.sha256) {
|
|
296
|
+
throw new Error("B-roll selection receipt does not match the current contact-sheet manifest");
|
|
297
|
+
}
|
|
298
|
+
if (payload.source.path !== asset.path || payload.source.sha256 !== asset.sha256 || payload.source.bytes !== asset.bytes) {
|
|
299
|
+
throw new Error("Contact-sheet manifest source does not match the selected B-roll asset");
|
|
300
|
+
}
|
|
301
|
+
const requiredDurationMs = window.outputEndMs - window.outputStartMs;
|
|
302
|
+
const selectedEndMs = window.assetStartMs + requiredDurationMs;
|
|
303
|
+
if (receipt.selectedEndMs !== selectedEndMs) {
|
|
304
|
+
throw new Error("B-roll selection receipt duration does not match the output placement");
|
|
305
|
+
}
|
|
306
|
+
const rangeStartMs = Number(payload.range.startSeconds) * 1_000;
|
|
307
|
+
const rangeEndMs = Number(payload.range.endSeconds) * 1_000;
|
|
308
|
+
if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs)
|
|
309
|
+
|| window.assetStartMs < rangeStartMs || selectedEndMs > rangeEndMs) {
|
|
310
|
+
throw new Error("Selected B-roll source window is outside the analyzed contact-sheet range");
|
|
311
|
+
}
|
|
312
|
+
const availableTimestamps = new Set<number>();
|
|
313
|
+
for (const artifact of payload.artifacts) {
|
|
314
|
+
if (artifact.sourceSha256 !== asset.sha256 || !Array.isArray(artifact.timestampsSeconds)) {
|
|
315
|
+
throw new Error("Contact-sheet artifact provenance does not match the selected B-roll asset");
|
|
316
|
+
}
|
|
317
|
+
for (const timestamp of artifact.timestampsSeconds) {
|
|
318
|
+
if (Number.isFinite(timestamp)) availableTimestamps.add(Math.round(timestamp * 1_000));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (!availableTimestamps.has(window.assetStartMs)) {
|
|
322
|
+
throw new Error("Selected B-roll source window must start on a manifest timestamp");
|
|
323
|
+
}
|
|
324
|
+
const evidenceTimestampsMs = [...new Set(receipt.evidenceTimestampsMs)];
|
|
325
|
+
const minimumEvidence = requiredDurationMs >= 1_000 ? 2 : 1;
|
|
326
|
+
if (evidenceTimestampsMs.length < minimumEvidence || !evidenceTimestampsMs.includes(window.assetStartMs)
|
|
327
|
+
|| evidenceTimestampsMs.some((timestamp) => (
|
|
328
|
+
!Number.isFinite(timestamp)
|
|
329
|
+
|| !availableTimestamps.has(timestamp)
|
|
330
|
+
|| timestamp < window.assetStartMs
|
|
331
|
+
|| timestamp > selectedEndMs
|
|
332
|
+
))) {
|
|
333
|
+
throw new Error(`B-roll selection requires ${minimumEvidence} or more manifest-backed evidence timestamps, including its source start`);
|
|
334
|
+
}
|
|
335
|
+
const tailThresholdMs = selectedEndMs - Math.max(500, requiredDurationMs * 0.25);
|
|
336
|
+
if (Math.max(...evidenceTimestampsMs) < tailThresholdMs) {
|
|
337
|
+
throw new Error("B-roll selection evidence must cover the end of the source window");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function readVerifiedManifest(
|
|
342
|
+
cwd: string,
|
|
343
|
+
manifestPath: string,
|
|
344
|
+
signal?: AbortSignal,
|
|
345
|
+
): Promise<{ manifest: FileRef; payload: ContactSheetManifest }> {
|
|
346
|
+
signal?.throwIfAborted();
|
|
347
|
+
const manifestAbsolute = await resolveExistingWorkspaceFile(cwd, manifestPath);
|
|
348
|
+
if ((await stat(manifestAbsolute)).size > 5 * 1024 * 1024) throw new Error("Contact-sheet manifest exceeds the 5MB limit");
|
|
349
|
+
const manifest = await snapshotFile(cwd, manifestPath, signal);
|
|
350
|
+
const payload = parseContactSheetManifest(
|
|
351
|
+
await readFile(manifestAbsolute, signal === undefined ? "utf8" : { encoding: "utf8", signal }),
|
|
352
|
+
manifest.path,
|
|
353
|
+
);
|
|
354
|
+
return { manifest, payload };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function verifyBrollPlacementSelection(
|
|
358
|
+
cwd: string,
|
|
359
|
+
placement: BrollPlacementInput,
|
|
360
|
+
signal?: AbortSignal,
|
|
361
|
+
): Promise<FileRef> {
|
|
362
|
+
if (!Number.isFinite(placement.assetStartMs) || placement.assetStartMs < 0) {
|
|
363
|
+
throw new Error(`B-roll assetStartMs is required and must be valid: ${placement.id}`);
|
|
364
|
+
}
|
|
365
|
+
if (!placement.selectionReceipt) {
|
|
366
|
+
throw new Error(`B-roll selectionReceipt is required: ${placement.id}`);
|
|
367
|
+
}
|
|
368
|
+
const asset = await snapshotFile(cwd, placement.assetPath, signal);
|
|
369
|
+
const { manifest, payload } = await readVerifiedManifest(cwd, placement.selectionReceipt.manifestPath, signal);
|
|
370
|
+
validateManifestSelection(asset, manifest, payload, placement, placement.selectionReceipt);
|
|
371
|
+
return asset;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function selectBrollWindow(cwd: string, input: SelectBrollWindowInput): Promise<BrollWindowSelection> {
|
|
375
|
+
validateNeed(input.need);
|
|
376
|
+
input.signal?.throwIfAborted();
|
|
377
|
+
if (!Number.isFinite(input.selectedStartMs) || input.selectedStartMs < 0) {
|
|
378
|
+
throw new Error("selectedStartMs must be a finite non-negative timestamp");
|
|
379
|
+
}
|
|
380
|
+
const requiredDurationMs = input.need.outputEndMs - input.need.outputStartMs;
|
|
381
|
+
const selectedEndMs = input.selectedStartMs + requiredDurationMs;
|
|
382
|
+
const evidenceTimestampsMs = [...new Set(input.evidenceTimestampsMs)];
|
|
383
|
+
const asset = await snapshotFile(cwd, input.assetPath, input.signal);
|
|
384
|
+
const { manifest, payload } = await readVerifiedManifest(cwd, input.manifestPath, input.signal);
|
|
385
|
+
const placement: BrollPlacementInput = {
|
|
386
|
+
id: input.need.id,
|
|
387
|
+
assetPath: asset.path,
|
|
388
|
+
outputStartMs: input.need.outputStartMs,
|
|
389
|
+
outputEndMs: input.need.outputEndMs,
|
|
390
|
+
assetStartMs: input.selectedStartMs,
|
|
391
|
+
fit: input.fit,
|
|
392
|
+
audio: "keep-primary",
|
|
393
|
+
query: input.need.searchTerms.join(" "),
|
|
394
|
+
reason: input.need.reason,
|
|
395
|
+
selectionReceipt: {
|
|
396
|
+
assetBytes: asset.bytes,
|
|
397
|
+
assetSha256: asset.sha256,
|
|
398
|
+
manifestPath: manifest.path,
|
|
399
|
+
manifestSha256: manifest.sha256,
|
|
400
|
+
selectedEndMs,
|
|
401
|
+
evidenceTimestampsMs,
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
validateManifestSelection(asset, manifest, payload, placement, placement.selectionReceipt);
|
|
405
|
+
return { placement };
|
|
406
|
+
}
|
package/src/contracts.ts
CHANGED
|
@@ -95,13 +95,75 @@ export interface BrollPlacement {
|
|
|
95
95
|
outputStartMs: number;
|
|
96
96
|
outputEndMs: number;
|
|
97
97
|
assetStartMs?: number;
|
|
98
|
+
selectionReceipt?: BrollSelectionReceipt;
|
|
98
99
|
fit: "cover" | "contain";
|
|
99
100
|
audio: "keep-primary";
|
|
100
101
|
query?: string;
|
|
101
102
|
reason?: string;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
export
|
|
105
|
+
export interface BrollSelectionReceipt {
|
|
106
|
+
assetBytes: number;
|
|
107
|
+
assetSha256: string;
|
|
108
|
+
manifestPath: string;
|
|
109
|
+
manifestSha256: string;
|
|
110
|
+
selectedEndMs: number;
|
|
111
|
+
evidenceTimestampsMs: number[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type BrollPlacementInput = Omit<
|
|
115
|
+
BrollPlacement,
|
|
116
|
+
"assetBytes" | "assetSha256" | "assetStartMs" | "selectionReceipt"
|
|
117
|
+
> & {
|
|
118
|
+
assetStartMs: number;
|
|
119
|
+
selectionReceipt: BrollSelectionReceipt;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export type BrollPurpose = "demonstrate" | "explain" | "evidence" | "establish" | "transition" | "mask-cut";
|
|
123
|
+
|
|
124
|
+
export interface BrollNeed {
|
|
125
|
+
id: string;
|
|
126
|
+
outputStartMs: number;
|
|
127
|
+
outputEndMs: number;
|
|
128
|
+
speechText: string;
|
|
129
|
+
purpose: BrollPurpose;
|
|
130
|
+
searchTerms: string[];
|
|
131
|
+
reason: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface BrollAssetCandidate {
|
|
135
|
+
assetPath: string;
|
|
136
|
+
fileName: string;
|
|
137
|
+
matchedTerms: string[];
|
|
138
|
+
score: number;
|
|
139
|
+
confidence: "high" | "medium" | "low";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface BrollAssetMatchBase {
|
|
143
|
+
requiredDurationMs: number;
|
|
144
|
+
totalCandidates: number;
|
|
145
|
+
candidateOffset: number;
|
|
146
|
+
nextCandidateOffset: number | null;
|
|
147
|
+
shortlist: BrollAssetCandidate[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type BrollAssetMatch =
|
|
151
|
+
| BrollAssetMatchBase & {
|
|
152
|
+
selectionMode: "filename-direct";
|
|
153
|
+
nextStep: { action: "inspect-selected-asset"; reason: string };
|
|
154
|
+
}
|
|
155
|
+
| BrollAssetMatchBase & {
|
|
156
|
+
selectionMode: "filename-shortlist";
|
|
157
|
+
nextStep: { action: "inspect-shortlist"; reason: string };
|
|
158
|
+
}
|
|
159
|
+
| BrollAssetMatchBase & {
|
|
160
|
+
selectionMode: "visual-fallback";
|
|
161
|
+
nextStep: { action: "visual-fallback"; reason: string };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export interface BrollWindowSelection {
|
|
165
|
+
placement: BrollPlacementInput;
|
|
166
|
+
}
|
|
105
167
|
|
|
106
168
|
export interface TranscriptAnalysis {
|
|
107
169
|
schemaVersion: 2;
|
package/src/project.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
TranscriptWord,
|
|
17
17
|
WordTranscript,
|
|
18
18
|
} from "./contracts.ts";
|
|
19
|
+
import { verifyBrollPlacementSelection } from "./broll.ts";
|
|
19
20
|
import { analyzeTranscript, DEFAULT_POLICY, timelineDuration } from "./transcript.ts";
|
|
20
21
|
import { resolveExistingWorkspaceFile, resolveWorkspacePath, snapshotFile, workspaceRelativePath } from "./workspace.ts";
|
|
21
22
|
|
|
@@ -77,6 +78,7 @@ async function validateTimeline(
|
|
|
77
78
|
cwd: string,
|
|
78
79
|
aroll: ArollSegment[],
|
|
79
80
|
broll: BrollPlacementInput[],
|
|
81
|
+
signal?: AbortSignal,
|
|
80
82
|
): Promise<{ outputDurationMs: number; broll: BrollPlacement[] }> {
|
|
81
83
|
if (aroll.length === 0 || aroll.length > 1_000) throw new Error("A-roll must contain 1-1000 segments");
|
|
82
84
|
uniqueIds(aroll, "A-roll segment");
|
|
@@ -91,6 +93,7 @@ async function validateTimeline(
|
|
|
91
93
|
uniqueIds(broll, "B-roll placement");
|
|
92
94
|
const normalizedBroll: BrollPlacement[] = [];
|
|
93
95
|
for (const placement of broll) {
|
|
96
|
+
signal?.throwIfAborted();
|
|
94
97
|
if (!Number.isFinite(placement.outputStartMs) || !Number.isFinite(placement.outputEndMs)
|
|
95
98
|
|| placement.outputStartMs < 0 || placement.outputEndMs <= placement.outputStartMs) {
|
|
96
99
|
throw new Error(`Invalid B-roll output range: ${placement.id}`);
|
|
@@ -98,11 +101,12 @@ async function validateTimeline(
|
|
|
98
101
|
if (placement.outputEndMs > outputDurationMs) {
|
|
99
102
|
throw new Error(`B-roll placement ${placement.id} exceeds output duration ${outputDurationMs}ms`);
|
|
100
103
|
}
|
|
101
|
-
if (
|
|
102
|
-
throw new Error(`
|
|
104
|
+
if (!Number.isFinite(placement.assetStartMs) || placement.assetStartMs < 0) {
|
|
105
|
+
throw new Error(`B-roll assetStartMs is required and must be valid: ${placement.id}`);
|
|
103
106
|
}
|
|
107
|
+
if (!placement.selectionReceipt) throw new Error(`B-roll selectionReceipt is required: ${placement.id}`);
|
|
104
108
|
if (placement.audio !== "keep-primary") throw new Error("B-roll audio must keep the primary A-roll audio");
|
|
105
|
-
const asset = await
|
|
109
|
+
const asset = await verifyBrollPlacementSelection(cwd, placement, signal);
|
|
106
110
|
normalizedBroll.push({
|
|
107
111
|
...structuredClone(placement),
|
|
108
112
|
assetPath: asset.path,
|
|
@@ -233,7 +237,7 @@ async function acquireLock(directory: string): Promise<() => Promise<void>> {
|
|
|
233
237
|
return async () => { await unlink(lockPath).catch(() => undefined); };
|
|
234
238
|
}
|
|
235
239
|
|
|
236
|
-
export async function applyTimeline(cwd: string, input: ApplyTimelineInput): Promise<TalkingHeadSnapshot> {
|
|
240
|
+
export async function applyTimeline(cwd: string, input: ApplyTimelineInput, signal?: AbortSignal): Promise<TalkingHeadSnapshot> {
|
|
237
241
|
const directory = await projectDirectory(cwd, input.projectId);
|
|
238
242
|
const release = await acquireLock(directory);
|
|
239
243
|
try {
|
|
@@ -244,7 +248,7 @@ export async function applyTimeline(cwd: string, input: ApplyTimelineInput): Pro
|
|
|
244
248
|
await assertProjectSourcesUnchanged(cwd, project);
|
|
245
249
|
await assertSnapshotAssetsUnchanged(cwd, current);
|
|
246
250
|
assertWordSafeSegments(input.aroll, await getAnalysis(cwd, project));
|
|
247
|
-
const validated = await validateTimeline(cwd, input.aroll, input.broll);
|
|
251
|
+
const validated = await validateTimeline(cwd, input.aroll, input.broll, signal);
|
|
248
252
|
const revision = project.currentRevision + 1;
|
|
249
253
|
const now = new Date().toISOString();
|
|
250
254
|
const snapshot: TalkingHeadSnapshot = {
|
package/src/workspace.ts
CHANGED
|
@@ -39,6 +39,17 @@ export async function resolveExistingWorkspaceFile(cwd: string, inputPath: strin
|
|
|
39
39
|
return canonical;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export async function resolveExistingWorkspaceDirectory(cwd: string, inputPath: string): Promise<string> {
|
|
43
|
+
const root = await workspaceRoot(cwd);
|
|
44
|
+
const lexical = resolve(root, inputPath);
|
|
45
|
+
if (!isWithin(root, lexical)) throw new Error(`Path is outside the workspace: ${inputPath}`);
|
|
46
|
+
if ((await lstat(lexical)).isSymbolicLink()) throw new Error(`Unsafe workspace-directory symlink: ${inputPath}`);
|
|
47
|
+
const canonical = await realpath(lexical);
|
|
48
|
+
if (!isWithin(root, canonical)) throw new Error(`Path resolves outside the workspace: ${inputPath}`);
|
|
49
|
+
if (!(await stat(canonical)).isDirectory()) throw new Error(`Path is not a directory: ${inputPath}`);
|
|
50
|
+
return canonical;
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
export async function resolveWorkspacePath(cwd: string, inputPath: string): Promise<string> {
|
|
43
54
|
const root = await workspaceRoot(cwd);
|
|
44
55
|
const lexical = resolve(root, inputPath);
|
|
@@ -55,17 +66,20 @@ export async function workspaceRelativePath(cwd: string, absolutePath: string):
|
|
|
55
66
|
return relative(root, absolutePath).split(sep).join("/");
|
|
56
67
|
}
|
|
57
68
|
|
|
58
|
-
async function sha256File(path: string): Promise<string> {
|
|
69
|
+
async function sha256File(path: string, signal?: AbortSignal): Promise<string> {
|
|
70
|
+
signal?.throwIfAborted();
|
|
59
71
|
const hash = createHash("sha256");
|
|
60
|
-
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
72
|
+
for await (const chunk of createReadStream(path, signal === undefined ? {} : { signal })) hash.update(chunk);
|
|
73
|
+
signal?.throwIfAborted();
|
|
61
74
|
return hash.digest("hex");
|
|
62
75
|
}
|
|
63
76
|
|
|
64
|
-
export async function snapshotFile(cwd: string, inputPath: string): Promise<FileRef> {
|
|
77
|
+
export async function snapshotFile(cwd: string, inputPath: string, signal?: AbortSignal): Promise<FileRef> {
|
|
78
|
+
signal?.throwIfAborted();
|
|
65
79
|
const absolute = await resolveExistingWorkspaceFile(cwd, inputPath);
|
|
66
80
|
return {
|
|
67
81
|
path: await workspaceRelativePath(cwd, absolute),
|
|
68
82
|
bytes: (await stat(absolute)).size,
|
|
69
|
-
sha256: await sha256File(absolute),
|
|
83
|
+
sha256: await sha256File(absolute, signal),
|
|
70
84
|
};
|
|
71
85
|
}
|