@speclip/pi-talking-head 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Speclip contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # @speclip/pi-talking-head
2
+
3
+ 给 [Pi](https://github.com/earendil-works/pi) 用的口播剪辑决策包。它读取 `pi-speech` 风格的词级时间戳,识别词间停顿,生成保守的 A-roll 剪辑方案,并可加入 B-roll 规划。
4
+
5
+ 它不直接调用 FFmpeg。最终输出是 `pi-media@0.3.1` 的通用 timeline EDL,由 `pi-media` 负责素材校验、不可变 revision、渲染和验收。
6
+
7
+ ## 为什么这样拆
8
+
9
+ - `pi-talking-head`:决定哪里该剪、气口留多少、B-roll 为什么出现。
10
+ - `pi-media`:执行任意来源的通用 EDL,保证路径安全、素材哈希、渲染和凭证。
11
+ - `pi-speech`:提供词级 `beginMs` / `endMs` 转录。
12
+
13
+ 以后增加删赘词、重复 take 选择、语义段落、B-roll 搜索或字幕,只需扩展口播决策层,不需要重写渲染器。
14
+
15
+ ## 环境要求
16
+
17
+ - Node.js 22.19+
18
+ - Pi 0.84.1–0.84.x
19
+ - `@speclip/pi-media` 0.3.1+(渲染时需要)
20
+ - 一份带词级时间戳的 JSON 转录;格式兼容 `@speclip/pi-speech`
21
+
22
+ ## 安装和验证
23
+
24
+ ```bash
25
+ npm install
26
+ npm run check
27
+ pi install ./
28
+ ```
29
+
30
+ ## 工作流
31
+
32
+ ### 1. 创建口播项目
33
+
34
+ ```js
35
+ talking_head_create {
36
+ projectId: "launch-video",
37
+ sourcePath: "raw/launch.mp4",
38
+ transcriptPath: "transcripts/launch.json",
39
+ cutThresholdMs: 500,
40
+ headPaddingMs: 50,
41
+ tailPaddingMs: 80
42
+ }
43
+ ```
44
+
45
+ 默认只自动移除至少 500ms 的词间停顿。每个保留片段前留 50ms、后留 80ms,避免切掉辅音、尾音和自然气口。停顿还会分为:
46
+
47
+ - `safe`:至少 400ms,通常可以切。
48
+ - `review`:150–399ms,必须结合语义和画面判断。
49
+ - `unsafe`:少于 150ms,默认不切。
50
+
51
+ 工具返回 revision 1、停顿摘要和可直接交给 `pi-media.edit_apply` 的 `mediaOperation`。
52
+
53
+ ### 2. 分页检查候选气口
54
+
55
+ ```js
56
+ talking_head_get {
57
+ projectId: "launch-video",
58
+ pauseOffset: 0,
59
+ pauseLimit: 50
60
+ }
61
+ ```
62
+
63
+ 只有调用这个工具时才会把候选停顿放进当前会话上下文;安装 package 不会把整份转录常驻注入上下文。
64
+
65
+ ### 3. 写入人工确认后的时间线
66
+
67
+ ```js
68
+ talking_head_apply {
69
+ projectId: "launch-video",
70
+ expectedRevision: 1,
71
+ arroll: [
72
+ { id: "hook", sourceStartMs: 50, sourceEndMs: 4120 },
73
+ { id: "answer", sourceStartMs: 4860, sourceEndMs: 13200 }
74
+ ],
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
+ }]
86
+ }
87
+ ```
88
+
89
+ B-roll 使用成片时间轴定位,永远保留主口播音轨。工具会固定素材字节数和 SHA-256,素材被替换后拒绝导出 EDL。
90
+
91
+ ### 4. 交给 pi-media 渲染
92
+
93
+ 先用同一个源文件创建 `pi-media` 项目,再把上一步的 `mediaOperation` 原样传入:
94
+
95
+ ```js
96
+ project_create { projectId: "launch-video-render", sourcePath: "raw/launch.mp4" }
97
+
98
+ edit_apply {
99
+ projectId: "launch-video-render",
100
+ expectedRevision: 1,
101
+ operations: [mediaOperation]
102
+ }
103
+
104
+ render {
105
+ projectId: "launch-video-render",
106
+ revision: 2,
107
+ outputPath: "out/launch-final.mp4"
108
+ }
109
+
110
+ review { path: "out/launch-final.mp4" }
111
+ ```
112
+
113
+ ## 工具
114
+
115
+ | 工具 | 作用 |
116
+ | --- | --- |
117
+ | `talking_head_create` | 从视频和词级转录建立 revision 1,分析停顿并生成默认 EDL |
118
+ | `talking_head_get` | 读取指定 revision,分页返回停顿候选,可选导出 pi-media EDL |
119
+ | `talking_head_apply` | 写入新的不可变口播 revision,并返回 pi-media EDL |
120
+
121
+ ## 当前边界
122
+
123
+ 0.1.0 不负责语音转录、素材搜索、字幕、画面理解或渲染。它只提供稳定的口播时间线决策层。B-roll 的搜索与视觉匹配可以后续新增为独立 Skill 或 Extension,最终仍写入同一份 `broll` 数据并由 `pi-media` 渲染。
@@ -0,0 +1,130 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { toMediaTimelineOperation } from "../../src/edl.ts";
4
+ import {
5
+ applyTimeline,
6
+ assertProjectSourcesUnchanged,
7
+ assertSnapshotAssetsUnchanged,
8
+ createTalkingHeadProject,
9
+ getAnalysis,
10
+ getTalkingHeadProject,
11
+ } from "../../src/project.ts";
12
+
13
+ function result(details: unknown) {
14
+ return {
15
+ content: [{ type: "text" as const, text: JSON.stringify(details, null, 2) }],
16
+ details,
17
+ };
18
+ }
19
+
20
+ const stableId = Type.String({
21
+ description: "Stable ID using lowercase letters, numbers, and interior hyphens (1-64 characters).",
22
+ pattern: "^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$",
23
+ });
24
+
25
+ const arrollSegment = Type.Object({
26
+ id: stableId,
27
+ sourceStartMs: Type.Number({ minimum: 0 }),
28
+ sourceEndMs: Type.Number({ exclusiveMinimum: 0 }),
29
+ }, { additionalProperties: false });
30
+
31
+ const brollPlacement = Type.Object({
32
+ id: stableId,
33
+ assetPath: Type.String({ description: "Workspace-relative B-roll video path." }),
34
+ outputStartMs: Type.Number({ minimum: 0 }),
35
+ outputEndMs: Type.Number({ exclusiveMinimum: 0 }),
36
+ assetStartMs: Type.Optional(Type.Number({ minimum: 0 })),
37
+ fit: Type.Union([Type.Literal("cover"), Type.Literal("contain")]),
38
+ audio: Type.Literal("keep-primary", { description: "B-roll never replaces the talking-head audio." }),
39
+ query: Type.Optional(Type.String({ maxLength: 500 })),
40
+ reason: Type.Optional(Type.String({ maxLength: 1_000 })),
41
+ }, { additionalProperties: false });
42
+
43
+ export default function talkingHead(pi: ExtensionAPI): void {
44
+ pi.registerTool({
45
+ name: "talking_head_create",
46
+ label: "Create talking-head edit",
47
+ description: "Create a workspace-local talking-head project from a source video and pi-speech-compatible word-timestamp transcript. Produces classified pause candidates and a conservative default A-roll EDL; it does not render media.",
48
+ parameters: Type.Object({
49
+ projectId: stableId,
50
+ sourcePath: Type.String({ description: "Workspace-relative source video path." }),
51
+ transcriptPath: Type.String({ description: "Workspace-relative pi-speech word-timestamp JSON path." }),
52
+ cutThresholdMs: Type.Optional(Type.Number({ minimum: 150, maximum: 10_000 })),
53
+ headPaddingMs: Type.Optional(Type.Number({ minimum: 30, maximum: 200 })),
54
+ tailPaddingMs: Type.Optional(Type.Number({ minimum: 30, maximum: 200 })),
55
+ }, { additionalProperties: false }),
56
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
57
+ const policy = {
58
+ ...(params.cutThresholdMs === undefined ? {} : { cutThresholdMs: params.cutThresholdMs }),
59
+ ...(params.headPaddingMs === undefined ? {} : { headPaddingMs: params.headPaddingMs }),
60
+ ...(params.tailPaddingMs === undefined ? {} : { tailPaddingMs: params.tailPaddingMs }),
61
+ };
62
+ const created = await createTalkingHeadProject(ctx.cwd, {
63
+ projectId: params.projectId,
64
+ sourcePath: params.sourcePath,
65
+ transcriptPath: params.transcriptPath,
66
+ policy,
67
+ });
68
+ return result({
69
+ ...created,
70
+ mediaOperation: toMediaTimelineOperation(created.project, created.snapshot),
71
+ });
72
+ },
73
+ });
74
+
75
+ pi.registerTool({
76
+ name: "talking_head_get",
77
+ 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.",
79
+ parameters: Type.Object({
80
+ projectId: stableId,
81
+ revision: Type.Optional(Type.Integer({ minimum: 1 })),
82
+ pauseOffset: Type.Optional(Type.Integer({ minimum: 0 })),
83
+ pauseLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
84
+ includeMediaOperation: Type.Optional(Type.Boolean()),
85
+ }, { additionalProperties: false }),
86
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
87
+ const { project, snapshot } = await getTalkingHeadProject(ctx.cwd, params.projectId, params.revision);
88
+ const analysis = await getAnalysis(ctx.cwd, project);
89
+ const offset = params.pauseOffset ?? 0;
90
+ const limit = params.pauseLimit ?? 50;
91
+ const details: Record<string, unknown> = {
92
+ project,
93
+ snapshot,
94
+ pauses: {
95
+ offset,
96
+ limit,
97
+ total: analysis.candidates.length,
98
+ items: analysis.candidates.slice(offset, offset + limit),
99
+ },
100
+ };
101
+ if (params.includeMediaOperation) {
102
+ await assertProjectSourcesUnchanged(ctx.cwd, project);
103
+ await assertSnapshotAssetsUnchanged(ctx.cwd, snapshot);
104
+ details.mediaOperation = toMediaTimelineOperation(project, snapshot);
105
+ }
106
+ return result(details);
107
+ },
108
+ });
109
+
110
+ pi.registerTool({
111
+ name: "talking_head_apply",
112
+ label: "Apply talking-head timeline",
113
+ description: "Create a new immutable talking-head revision from word-boundary A-roll ranges and optional B-roll placements. Returns a generic pi-media timeline operation ready for edit_apply.",
114
+ parameters: Type.Object({
115
+ projectId: stableId,
116
+ expectedRevision: Type.Integer({ minimum: 1 }),
117
+ aroll: Type.Array(arrollSegment, { minItems: 1, maxItems: 1_000 }),
118
+ broll: Type.Array(brollPlacement, { maxItems: 500 }),
119
+ }, { additionalProperties: false }),
120
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
121
+ const current = await getTalkingHeadProject(ctx.cwd, params.projectId);
122
+ const snapshot = await applyTimeline(ctx.cwd, params);
123
+ return result({
124
+ projectId: params.projectId,
125
+ snapshot,
126
+ mediaOperation: toMediaTimelineOperation(current.project, snapshot),
127
+ });
128
+ },
129
+ });
130
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@speclip/pi-talking-head",
3
+ "version": "0.1.0",
4
+ "description": "Pause-aware talking-head editing and B-roll planning for Pi, exported as generic pi-media EDLs",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/linyqh/pi-talking-head.git"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "registry": "https://registry.npmjs.org"
14
+ },
15
+ "engines": {
16
+ "node": ">=22.19"
17
+ },
18
+ "os": [
19
+ "darwin",
20
+ "linux"
21
+ ],
22
+ "files": [
23
+ "README.md",
24
+ "LICENSE",
25
+ "extensions",
26
+ "prompts",
27
+ "skills",
28
+ "src"
29
+ ],
30
+ "scripts": {
31
+ "typecheck": "tsc --noEmit",
32
+ "test": "node --experimental-strip-types --test tests/*.test.ts",
33
+ "check": "npm run typecheck && npm test"
34
+ },
35
+ "keywords": [
36
+ "pi-package",
37
+ "talking-head",
38
+ "video-editing",
39
+ "edl",
40
+ "b-roll",
41
+ "ffmpeg"
42
+ ],
43
+ "pi": {
44
+ "extensions": [
45
+ "./extensions/talking-head/index.ts"
46
+ ],
47
+ "skills": [
48
+ "./skills"
49
+ ],
50
+ "prompts": [
51
+ "./prompts"
52
+ ]
53
+ },
54
+ "peerDependencies": {
55
+ "@earendil-works/pi-coding-agent": ">=0.84.1 <0.85.0",
56
+ "typebox": "^1.3.7"
57
+ },
58
+ "devDependencies": {
59
+ "@earendil-works/pi-coding-agent": "0.84.2",
60
+ "@types/node": "22.20.1",
61
+ "typebox": "1.3.7",
62
+ "typescript": "7.0.2"
63
+ }
64
+ }
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: Tighten a talking-head video without destroying natural speech rhythm
3
+ ---
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: $@
6
+
7
+ Show the proposed rhythm before writing a new revision. Never overwrite the source or an existing render.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: talking-head-edit
3
+ description: Plan a polished talking-head edit from word timestamps, preserve natural breath and speech boundaries, add justified B-roll placements, and hand a generic EDL to pi-media. Use when the user asks to cut pauses, tighten spoken delivery, remove dead air, edit a monologue, or add B-roll to a talking-head video.
4
+ ---
5
+
6
+ # Talking-head edit
7
+
8
+ Use `pi-speech` for word evidence, this package for editorial decisions, and `pi-media` for deterministic rendering.
9
+
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
+ 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`.
16
+
17
+ 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
+
19
+ Read [cut craft](references/cut-craft.md) when deciding whether a pause is natural, abrupt, or suitable for B-roll.
@@ -0,0 +1,17 @@
1
+ # Cut craft
2
+
3
+ ## Cutting a breath
4
+
5
+ - Cut only between word timestamps. Never cut through a word or punctuation tail.
6
+ - Keep 30–200ms on both sides of a spoken range. The default 50ms before and 80ms after is intentionally asymmetric because endings often need more decay.
7
+ - A long waveform gap is evidence of silence, not proof that it should disappear. Keep pauses that signal emphasis, emotion, a paragraph boundary, or a visible gesture.
8
+ - Prefer one clean removal over many micro-cuts. Dense sub-150ms cuts create robotic cadence and visible jump cuts.
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
+
11
+ ## Using B-roll
12
+
13
+ - Place B-roll on the output timeline after A-roll cuts have stabilized.
14
+ - Use it to show the object, action, place, comparison, or evidence currently being discussed—not as random decoration.
15
+ - Cover visible jump cuts when appropriate, but do not hide continuity errors that change meaning.
16
+ - Keep the primary voice track. B-roll audio replacement is outside the current contract.
17
+ - Record the search query and editorial reason so a later agent can replace the asset without guessing intent.
@@ -0,0 +1,103 @@
1
+ export interface TranscriptWord {
2
+ text: string;
3
+ beginMs: number;
4
+ endMs: number;
5
+ punctuation: string;
6
+ }
7
+
8
+ export interface TranscriptSentence {
9
+ id: number;
10
+ beginMs: number;
11
+ endMs: number;
12
+ text: string;
13
+ words: TranscriptWord[];
14
+ }
15
+
16
+ export interface WordTranscript {
17
+ text: string;
18
+ sentences: TranscriptSentence[];
19
+ }
20
+
21
+ export type PauseClassification = "unsafe" | "review" | "safe";
22
+
23
+ export interface PauseCandidate {
24
+ id: string;
25
+ startMs: number;
26
+ endMs: number;
27
+ durationMs: number;
28
+ classification: PauseClassification;
29
+ beforeText: string;
30
+ afterText: string;
31
+ }
32
+
33
+ export interface ArollSegment {
34
+ id: string;
35
+ sourceStartMs: number;
36
+ sourceEndMs: number;
37
+ }
38
+
39
+ export interface BrollPlacement {
40
+ id: string;
41
+ assetPath: string;
42
+ assetBytes: number;
43
+ assetSha256: string;
44
+ outputStartMs: number;
45
+ outputEndMs: number;
46
+ assetStartMs?: number;
47
+ fit: "cover" | "contain";
48
+ audio: "keep-primary";
49
+ query?: string;
50
+ reason?: string;
51
+ }
52
+
53
+ export type BrollPlacementInput = Omit<BrollPlacement, "assetBytes" | "assetSha256">;
54
+
55
+ export interface TranscriptAnalysis {
56
+ words: TranscriptWord[];
57
+ candidates: PauseCandidate[];
58
+ segments: ArollSegment[];
59
+ outputDurationMs: number;
60
+ }
61
+
62
+ export interface TalkingHeadPolicy {
63
+ cutThresholdMs: number;
64
+ headPaddingMs: number;
65
+ tailPaddingMs: number;
66
+ }
67
+
68
+ export interface FileRef {
69
+ path: string;
70
+ bytes: number;
71
+ sha256: string;
72
+ }
73
+
74
+ export interface TalkingHeadProject {
75
+ schemaVersion: 1;
76
+ projectId: string;
77
+ currentRevision: number;
78
+ source: FileRef;
79
+ transcript: FileRef;
80
+ analysisPath: string;
81
+ createdAt: string;
82
+ updatedAt: string;
83
+ }
84
+
85
+ export interface TalkingHeadSnapshot {
86
+ schemaVersion: 1;
87
+ projectId: string;
88
+ revision: number;
89
+ parentRevision: number | null;
90
+ createdAt: string;
91
+ policy: TalkingHeadPolicy;
92
+ aroll: ArollSegment[];
93
+ broll: BrollPlacement[];
94
+ outputDurationMs: number;
95
+ }
96
+
97
+ export interface RenderArtifact extends FileRef {
98
+ projectId: string;
99
+ revision: number;
100
+ durationMs: number;
101
+ receiptPath: string;
102
+ createdAt: string;
103
+ }
package/src/edl.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { TalkingHeadProject, TalkingHeadSnapshot } from "./contracts.ts";
2
+
3
+ export interface MediaTimelineOperationInput {
4
+ kind: "timeline";
5
+ segments: Array<{
6
+ id: string;
7
+ sourcePath: string;
8
+ sourceStartSeconds: number;
9
+ sourceEndSeconds: number;
10
+ }>;
11
+ overlays: Array<{
12
+ id: string;
13
+ sourcePath: string;
14
+ outputStartSeconds: number;
15
+ outputEndSeconds: number;
16
+ sourceStartSeconds?: number;
17
+ fit: "cover" | "contain";
18
+ audio: "keep-primary";
19
+ }>;
20
+ }
21
+
22
+ function seconds(milliseconds: number): number {
23
+ return milliseconds / 1_000;
24
+ }
25
+
26
+ export function toMediaTimelineOperation(
27
+ project: TalkingHeadProject,
28
+ snapshot: TalkingHeadSnapshot,
29
+ ): MediaTimelineOperationInput {
30
+ return {
31
+ kind: "timeline",
32
+ segments: snapshot.aroll.map((segment) => ({
33
+ id: segment.id,
34
+ sourcePath: project.source.path,
35
+ sourceStartSeconds: seconds(segment.sourceStartMs),
36
+ sourceEndSeconds: seconds(segment.sourceEndMs),
37
+ })),
38
+ overlays: snapshot.broll.map((placement) => ({
39
+ id: placement.id,
40
+ sourcePath: placement.assetPath,
41
+ outputStartSeconds: seconds(placement.outputStartMs),
42
+ outputEndSeconds: seconds(placement.outputEndMs),
43
+ ...(placement.assetStartMs === undefined ? {} : { sourceStartSeconds: seconds(placement.assetStartMs) }),
44
+ fit: placement.fit,
45
+ audio: placement.audio,
46
+ })),
47
+ };
48
+ }
package/src/project.ts ADDED
@@ -0,0 +1,289 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import type {
5
+ ArollSegment,
6
+ BrollPlacement,
7
+ BrollPlacementInput,
8
+ TalkingHeadPolicy,
9
+ TalkingHeadProject,
10
+ TalkingHeadSnapshot,
11
+ TranscriptAnalysis,
12
+ WordTranscript,
13
+ } from "./contracts.ts";
14
+ import { analyzeTranscript, DEFAULT_POLICY, timelineDuration } from "./transcript.ts";
15
+ import { resolveExistingWorkspaceFile, resolveWorkspacePath, snapshotFile, workspaceRelativePath } from "./workspace.ts";
16
+
17
+ const PROJECT_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
18
+
19
+ export interface CreateTalkingHeadProjectInput {
20
+ projectId: string;
21
+ sourcePath: string;
22
+ transcriptPath: string;
23
+ policy?: Partial<TalkingHeadPolicy>;
24
+ }
25
+
26
+ export interface ApplyTimelineInput {
27
+ projectId: string;
28
+ expectedRevision: number;
29
+ aroll: ArollSegment[];
30
+ broll: BrollPlacementInput[];
31
+ }
32
+
33
+ function assertProjectId(projectId: string): void {
34
+ if (!PROJECT_ID.test(projectId)) {
35
+ throw new Error("Project ID must use 1-64 lowercase letters, numbers, or interior hyphens");
36
+ }
37
+ }
38
+
39
+ async function projectsRoot(cwd: string): Promise<string> {
40
+ return await resolveWorkspacePath(cwd, ".talking-head/projects/.keep").then(dirname);
41
+ }
42
+
43
+ async function projectDirectory(cwd: string, projectId: string): Promise<string> {
44
+ assertProjectId(projectId);
45
+ return join(await projectsRoot(cwd), projectId);
46
+ }
47
+
48
+ async function readJson<T>(path: string, label: string): Promise<T> {
49
+ try {
50
+ return JSON.parse(await readFile(path, "utf8")) as T;
51
+ } catch (error) {
52
+ throw new Error(`Invalid ${label}: ${(error as Error).message}`);
53
+ }
54
+ }
55
+
56
+ async function writeJson(path: string, value: unknown, exclusive = false): Promise<void> {
57
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, exclusive ? { flag: "wx" } : undefined);
58
+ }
59
+
60
+ function uniqueIds(values: Array<{ id: string }>, label: string): void {
61
+ const ids = new Set<string>();
62
+ for (const value of values) {
63
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(value.id)) {
64
+ throw new Error(`${label} ID must use lowercase letters, numbers, or interior hyphens: ${value.id}`);
65
+ }
66
+ if (ids.has(value.id)) throw new Error(`Duplicate ${label} ID: ${value.id}`);
67
+ ids.add(value.id);
68
+ }
69
+ }
70
+
71
+ async function validateTimeline(
72
+ cwd: string,
73
+ aroll: ArollSegment[],
74
+ broll: BrollPlacementInput[],
75
+ ): Promise<{ outputDurationMs: number; broll: BrollPlacement[] }> {
76
+ if (aroll.length === 0 || aroll.length > 1_000) throw new Error("A-roll must contain 1-1000 segments");
77
+ uniqueIds(aroll, "A-roll segment");
78
+ for (const segment of aroll) {
79
+ if (!Number.isFinite(segment.sourceStartMs) || !Number.isFinite(segment.sourceEndMs)
80
+ || segment.sourceStartMs < 0 || segment.sourceEndMs <= segment.sourceStartMs) {
81
+ throw new Error(`Invalid A-roll segment range: ${segment.id}`);
82
+ }
83
+ }
84
+ const outputDurationMs = timelineDuration(aroll);
85
+ if (broll.length > 500) throw new Error("B-roll must contain at most 500 placements");
86
+ uniqueIds(broll, "B-roll placement");
87
+ const normalizedBroll: BrollPlacement[] = [];
88
+ for (const placement of broll) {
89
+ if (!Number.isFinite(placement.outputStartMs) || !Number.isFinite(placement.outputEndMs)
90
+ || placement.outputStartMs < 0 || placement.outputEndMs <= placement.outputStartMs) {
91
+ throw new Error(`Invalid B-roll output range: ${placement.id}`);
92
+ }
93
+ if (placement.outputEndMs > outputDurationMs) {
94
+ throw new Error(`B-roll placement ${placement.id} exceeds output duration ${outputDurationMs}ms`);
95
+ }
96
+ if (placement.assetStartMs !== undefined && (!Number.isFinite(placement.assetStartMs) || placement.assetStartMs < 0)) {
97
+ throw new Error(`Invalid B-roll asset start: ${placement.id}`);
98
+ }
99
+ if (placement.audio !== "keep-primary") throw new Error("B-roll audio must keep the primary A-roll audio");
100
+ const asset = await snapshotFile(cwd, placement.assetPath);
101
+ normalizedBroll.push({
102
+ ...structuredClone(placement),
103
+ assetPath: asset.path,
104
+ assetBytes: asset.bytes,
105
+ assetSha256: asset.sha256,
106
+ });
107
+ }
108
+ return { outputDurationMs, broll: normalizedBroll };
109
+ }
110
+
111
+ function summary(analysis: TranscriptAnalysis) {
112
+ return {
113
+ wordCount: analysis.words.length,
114
+ pauseCount: analysis.candidates.length,
115
+ safePauses: analysis.candidates.filter((candidate) => candidate.classification === "safe").length,
116
+ reviewPauses: analysis.candidates.filter((candidate) => candidate.classification === "review").length,
117
+ unsafePauses: analysis.candidates.filter((candidate) => candidate.classification === "unsafe").length,
118
+ defaultSegmentCount: analysis.segments.length,
119
+ defaultOutputDurationMs: analysis.outputDurationMs,
120
+ };
121
+ }
122
+
123
+ function assertWordSafeSegments(aroll: ArollSegment[], analysis: TranscriptAnalysis): void {
124
+ for (const segment of aroll) {
125
+ for (const word of analysis.words) {
126
+ if (segment.sourceStartMs > word.beginMs && segment.sourceStartMs < word.endMs) {
127
+ throw new Error(`A-roll segment ${segment.id} starts inside word "${word.text}"`);
128
+ }
129
+ if (segment.sourceEndMs > word.beginMs && segment.sourceEndMs < word.endMs) {
130
+ throw new Error(`A-roll segment ${segment.id} ends inside word "${word.text}"`);
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ export async function createTalkingHeadProject(cwd: string, input: CreateTalkingHeadProjectInput) {
137
+ assertProjectId(input.projectId);
138
+ const source = await snapshotFile(cwd, input.sourcePath);
139
+ const transcript = await snapshotFile(cwd, input.transcriptPath);
140
+ const transcriptAbsolute = await resolveExistingWorkspaceFile(cwd, input.transcriptPath);
141
+ const transcriptPayload = await readJson<WordTranscript>(transcriptAbsolute, "word transcript");
142
+ const policy = { ...DEFAULT_POLICY, ...input.policy };
143
+ const analysis = analyzeTranscript(transcriptPayload, policy);
144
+ const root = await projectsRoot(cwd);
145
+ await mkdir(root, { recursive: true });
146
+ const target = await projectDirectory(cwd, input.projectId);
147
+ const temporary = join(root, `.${input.projectId}.${randomUUID()}.tmp`);
148
+ await mkdir(join(temporary, "snapshots"), { recursive: true });
149
+ const now = new Date().toISOString();
150
+ const analysisPath = `.talking-head/projects/${input.projectId}/analysis.json`;
151
+ const project: TalkingHeadProject = {
152
+ schemaVersion: 1,
153
+ projectId: input.projectId,
154
+ currentRevision: 1,
155
+ source,
156
+ transcript,
157
+ analysisPath,
158
+ createdAt: now,
159
+ updatedAt: now,
160
+ };
161
+ const snapshot: TalkingHeadSnapshot = {
162
+ schemaVersion: 1,
163
+ projectId: input.projectId,
164
+ revision: 1,
165
+ parentRevision: null,
166
+ createdAt: now,
167
+ policy,
168
+ aroll: analysis.segments,
169
+ broll: [],
170
+ outputDurationMs: analysis.outputDurationMs,
171
+ };
172
+ try {
173
+ await writeJson(join(temporary, "analysis.json"), analysis, true);
174
+ await writeJson(join(temporary, "snapshots", "1.json"), snapshot, true);
175
+ await writeJson(join(temporary, "project.json"), project, true);
176
+ try {
177
+ await rename(temporary, target);
178
+ } catch (error) {
179
+ if ((error as NodeJS.ErrnoException).code === "EEXIST" || (error as NodeJS.ErrnoException).code === "ENOTEMPTY") {
180
+ throw new Error(`Project already exists: ${input.projectId}`);
181
+ }
182
+ throw error;
183
+ }
184
+ } catch (error) {
185
+ await rm(temporary, { recursive: true, force: true });
186
+ throw error;
187
+ }
188
+ return { project, snapshot, summary: summary(analysis) };
189
+ }
190
+
191
+ export async function getTalkingHeadProject(cwd: string, projectId: string, revision?: number) {
192
+ const directory = await projectDirectory(cwd, projectId);
193
+ const project = await readJson<TalkingHeadProject>(join(directory, "project.json"), `talking-head project ${projectId}`);
194
+ if (project.schemaVersion !== 1 || project.projectId !== projectId || !Number.isInteger(project.currentRevision)) {
195
+ throw new Error(`Invalid talking-head project: ${projectId}`);
196
+ }
197
+ const selectedRevision = revision ?? project.currentRevision;
198
+ if (!Number.isInteger(selectedRevision) || selectedRevision < 1 || selectedRevision > project.currentRevision) {
199
+ throw new Error(`Invalid talking-head revision: ${projectId}@${selectedRevision}`);
200
+ }
201
+ const snapshot = await readJson<TalkingHeadSnapshot>(
202
+ join(directory, "snapshots", `${selectedRevision}.json`),
203
+ `talking-head snapshot ${projectId}@${selectedRevision}`,
204
+ );
205
+ if (snapshot.schemaVersion !== 1 || snapshot.projectId !== projectId || snapshot.revision !== selectedRevision) {
206
+ throw new Error(`Invalid talking-head snapshot: ${projectId}@${selectedRevision}`);
207
+ }
208
+ return { project, snapshot };
209
+ }
210
+
211
+ async function acquireLock(directory: string): Promise<() => Promise<void>> {
212
+ const lockPath = join(directory, ".write-lock");
213
+ let handle;
214
+ try {
215
+ handle = await open(lockPath, "wx");
216
+ await handle.writeFile(`${process.pid}\n`);
217
+ } catch (error) {
218
+ await handle?.close().catch(() => undefined);
219
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Project is busy: ${directory.split("/").at(-1)}`);
220
+ throw error;
221
+ }
222
+ await handle.close();
223
+ return async () => { await unlink(lockPath).catch(() => undefined); };
224
+ }
225
+
226
+ export async function applyTimeline(cwd: string, input: ApplyTimelineInput): Promise<TalkingHeadSnapshot> {
227
+ const directory = await projectDirectory(cwd, input.projectId);
228
+ const release = await acquireLock(directory);
229
+ try {
230
+ const { project, snapshot: current } = await getTalkingHeadProject(cwd, input.projectId);
231
+ if (project.currentRevision !== input.expectedRevision) {
232
+ throw new Error(`Project ${input.projectId} expected revision ${input.expectedRevision} but current revision is ${project.currentRevision}`);
233
+ }
234
+ await assertProjectSourcesUnchanged(cwd, project);
235
+ await assertSnapshotAssetsUnchanged(cwd, current);
236
+ assertWordSafeSegments(input.aroll, await getAnalysis(cwd, project));
237
+ const validated = await validateTimeline(cwd, input.aroll, input.broll);
238
+ const revision = project.currentRevision + 1;
239
+ const now = new Date().toISOString();
240
+ const snapshot: TalkingHeadSnapshot = {
241
+ schemaVersion: 1,
242
+ projectId: input.projectId,
243
+ revision,
244
+ parentRevision: current.revision,
245
+ createdAt: now,
246
+ policy: current.policy,
247
+ aroll: structuredClone(input.aroll),
248
+ broll: validated.broll,
249
+ outputDurationMs: validated.outputDurationMs,
250
+ };
251
+ await writeJson(join(directory, "snapshots", `${revision}.json`), snapshot, true);
252
+ const nextProject: TalkingHeadProject = { ...project, currentRevision: revision, updatedAt: now };
253
+ const temporary = join(directory, `.project.${randomUUID()}.tmp`);
254
+ await writeJson(temporary, nextProject, true);
255
+ await rename(temporary, join(directory, "project.json"));
256
+ return snapshot;
257
+ } finally {
258
+ await release();
259
+ }
260
+ }
261
+
262
+ export async function getAnalysis(cwd: string, project: TalkingHeadProject): Promise<TranscriptAnalysis> {
263
+ const absolute = await resolveExistingWorkspaceFile(cwd, project.analysisPath);
264
+ return await readJson<TranscriptAnalysis>(absolute, `talking-head analysis ${project.projectId}`);
265
+ }
266
+
267
+ export async function assertProjectSourcesUnchanged(cwd: string, project: TalkingHeadProject): Promise<void> {
268
+ const currentSource = await snapshotFile(cwd, project.source.path);
269
+ const currentTranscript = await snapshotFile(cwd, project.transcript.path);
270
+ if (currentSource.sha256 !== project.source.sha256 || currentSource.bytes !== project.source.bytes) {
271
+ throw new Error(`Source media changed after project creation: ${project.source.path}`);
272
+ }
273
+ if (currentTranscript.sha256 !== project.transcript.sha256 || currentTranscript.bytes !== project.transcript.bytes) {
274
+ throw new Error(`Transcript changed after project creation: ${project.transcript.path}`);
275
+ }
276
+ }
277
+
278
+ export async function assertSnapshotAssetsUnchanged(cwd: string, snapshot: TalkingHeadSnapshot): Promise<void> {
279
+ for (const placement of snapshot.broll) {
280
+ const current = await snapshotFile(cwd, placement.assetPath);
281
+ if (current.sha256 !== placement.assetSha256 || current.bytes !== placement.assetBytes) {
282
+ throw new Error(`B-roll asset changed after timeline revision ${snapshot.revision}: ${placement.assetPath}`);
283
+ }
284
+ }
285
+ }
286
+
287
+ export async function projectStatePath(cwd: string, projectId: string, suffix: string): Promise<string> {
288
+ return await workspaceRelativePath(cwd, join(await projectDirectory(cwd, projectId), suffix));
289
+ }
@@ -0,0 +1,121 @@
1
+ import type {
2
+ ArollSegment,
3
+ PauseCandidate,
4
+ PauseClassification,
5
+ TalkingHeadPolicy,
6
+ TranscriptAnalysis,
7
+ TranscriptWord,
8
+ WordTranscript,
9
+ } from "./contracts.ts";
10
+
11
+ export const DEFAULT_POLICY: TalkingHeadPolicy = {
12
+ cutThresholdMs: 500,
13
+ headPaddingMs: 50,
14
+ tailPaddingMs: 80,
15
+ };
16
+
17
+ function finiteNonNegative(value: unknown): value is number {
18
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
19
+ }
20
+
21
+ function classifyPause(durationMs: number): PauseClassification {
22
+ if (durationMs >= 400) return "safe";
23
+ if (durationMs >= 150) return "review";
24
+ return "unsafe";
25
+ }
26
+
27
+ function validatePolicy(policy: TalkingHeadPolicy): void {
28
+ if (!Number.isFinite(policy.cutThresholdMs) || policy.cutThresholdMs < 150) {
29
+ throw new Error("cutThresholdMs must be at least 150ms");
30
+ }
31
+ for (const [name, value] of [["headPaddingMs", policy.headPaddingMs], ["tailPaddingMs", policy.tailPaddingMs]] as const) {
32
+ if (!Number.isFinite(value) || value < 30 || value > 200) {
33
+ throw new Error(`${name} must be within the 30-200ms working window`);
34
+ }
35
+ }
36
+ }
37
+
38
+ function flattenWords(transcript: WordTranscript): TranscriptWord[] {
39
+ if (typeof transcript.text !== "string" || !Array.isArray(transcript.sentences)) {
40
+ throw new Error("Transcript must contain text and sentences");
41
+ }
42
+ const words = transcript.sentences.flatMap((sentence) => {
43
+ if (!sentence || !Array.isArray(sentence.words)) throw new Error("Transcript sentence is missing words");
44
+ return sentence.words;
45
+ });
46
+ if (words.length === 0) throw new Error("Transcript contains no word timestamps");
47
+ let previousEnd = -1;
48
+ for (const word of words) {
49
+ if (typeof word.text !== "string" || !word.text.trim()
50
+ || !finiteNonNegative(word.beginMs) || !finiteNonNegative(word.endMs)
51
+ || word.endMs <= word.beginMs) {
52
+ throw new Error("Transcript contains an invalid word timestamp");
53
+ }
54
+ if (word.beginMs < previousEnd) throw new Error("Transcript word timestamps overlap or are out of order");
55
+ previousEnd = word.endMs;
56
+ }
57
+ return words.map((word) => ({ ...word, punctuation: word.punctuation ?? "" }));
58
+ }
59
+
60
+ function candidatesFrom(words: TranscriptWord[]): PauseCandidate[] {
61
+ const candidates: PauseCandidate[] = [];
62
+ for (let index = 1; index < words.length; index += 1) {
63
+ const before = words[index - 1];
64
+ const after = words[index];
65
+ if (!before || !after) continue;
66
+ const durationMs = after.beginMs - before.endMs;
67
+ if (durationMs <= 0) continue;
68
+ candidates.push({
69
+ id: `pause-${String(candidates.length + 1).padStart(3, "0")}`,
70
+ startMs: before.endMs,
71
+ endMs: after.beginMs,
72
+ durationMs,
73
+ classification: classifyPause(durationMs),
74
+ beforeText: `${before.text}${before.punctuation}`,
75
+ afterText: after.text,
76
+ });
77
+ }
78
+ return candidates;
79
+ }
80
+
81
+ function defaultSegments(words: TranscriptWord[], policy: TalkingHeadPolicy): ArollSegment[] {
82
+ const segments: ArollSegment[] = [];
83
+ let segmentStart = Math.max(0, words[0]!.beginMs - policy.headPaddingMs);
84
+ for (let index = 1; index < words.length; index += 1) {
85
+ const before = words[index - 1]!;
86
+ const after = words[index]!;
87
+ if (after.beginMs - before.endMs < policy.cutThresholdMs) continue;
88
+ segments.push({
89
+ id: `a-${String(segments.length + 1).padStart(3, "0")}`,
90
+ sourceStartMs: segmentStart,
91
+ sourceEndMs: before.endMs + policy.tailPaddingMs,
92
+ });
93
+ segmentStart = Math.max(0, after.beginMs - policy.headPaddingMs);
94
+ }
95
+ segments.push({
96
+ id: `a-${String(segments.length + 1).padStart(3, "0")}`,
97
+ sourceStartMs: segmentStart,
98
+ sourceEndMs: words.at(-1)!.endMs + policy.tailPaddingMs,
99
+ });
100
+ return segments;
101
+ }
102
+
103
+ export function timelineDuration(segments: ArollSegment[]): number {
104
+ return segments.reduce((total, segment) => total + segment.sourceEndMs - segment.sourceStartMs, 0);
105
+ }
106
+
107
+ export function analyzeTranscript(
108
+ transcript: WordTranscript,
109
+ overrides: Partial<TalkingHeadPolicy> = {},
110
+ ): TranscriptAnalysis {
111
+ const policy = { ...DEFAULT_POLICY, ...overrides };
112
+ validatePolicy(policy);
113
+ const words = flattenWords(transcript);
114
+ const segments = defaultSegments(words, policy);
115
+ return {
116
+ words,
117
+ candidates: candidatesFrom(words),
118
+ segments,
119
+ outputDurationMs: timelineDuration(segments),
120
+ };
121
+ }
@@ -0,0 +1,71 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { lstat, realpath, stat } from "node:fs/promises";
4
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
+ import type { FileRef } from "./contracts.ts";
6
+
7
+ function isWithin(root: string, candidate: string): boolean {
8
+ const pathFromRoot = relative(root, candidate);
9
+ return pathFromRoot === "" || (!pathFromRoot.startsWith(`..${sep}`) && pathFromRoot !== ".." && !isAbsolute(pathFromRoot));
10
+ }
11
+
12
+ async function nearestExistingAncestor(path: string): Promise<string> {
13
+ let current = path;
14
+ while (true) {
15
+ try {
16
+ await stat(current);
17
+ return current;
18
+ } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
20
+ const parent = dirname(current);
21
+ if (parent === current) throw error;
22
+ current = parent;
23
+ }
24
+ }
25
+ }
26
+
27
+ export async function workspaceRoot(cwd: string): Promise<string> {
28
+ return await realpath(cwd);
29
+ }
30
+
31
+ export async function resolveExistingWorkspaceFile(cwd: string, inputPath: string): Promise<string> {
32
+ const root = await workspaceRoot(cwd);
33
+ const lexical = resolve(root, inputPath);
34
+ if (!isWithin(root, lexical)) throw new Error(`Path is outside the workspace: ${inputPath}`);
35
+ if ((await lstat(lexical)).isSymbolicLink()) throw new Error(`Unsafe workspace-file symlink: ${inputPath}`);
36
+ const canonical = await realpath(lexical);
37
+ if (!isWithin(root, canonical)) throw new Error(`Path resolves outside the workspace: ${inputPath}`);
38
+ if (!(await stat(canonical)).isFile()) throw new Error(`Path is not a file: ${inputPath}`);
39
+ return canonical;
40
+ }
41
+
42
+ export async function resolveWorkspacePath(cwd: string, inputPath: string): Promise<string> {
43
+ const root = await workspaceRoot(cwd);
44
+ const lexical = resolve(root, inputPath);
45
+ if (lexical === root || !isWithin(root, lexical)) throw new Error(`Path is outside the workspace: ${inputPath}`);
46
+ const ancestor = await nearestExistingAncestor(dirname(lexical));
47
+ const canonicalAncestor = await realpath(ancestor);
48
+ if (!isWithin(root, canonicalAncestor)) throw new Error(`Path resolves outside the workspace: ${inputPath}`);
49
+ return lexical;
50
+ }
51
+
52
+ export async function workspaceRelativePath(cwd: string, absolutePath: string): Promise<string> {
53
+ const root = await workspaceRoot(cwd);
54
+ if (!isWithin(root, absolutePath)) throw new Error(`Path is outside the workspace: ${absolutePath}`);
55
+ return relative(root, absolutePath).split(sep).join("/");
56
+ }
57
+
58
+ async function sha256File(path: string): Promise<string> {
59
+ const hash = createHash("sha256");
60
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
61
+ return hash.digest("hex");
62
+ }
63
+
64
+ export async function snapshotFile(cwd: string, inputPath: string): Promise<FileRef> {
65
+ const absolute = await resolveExistingWorkspaceFile(cwd, inputPath);
66
+ return {
67
+ path: await workspaceRelativePath(cwd, absolute),
68
+ bytes: (await stat(absolute)).size,
69
+ sha256: await sha256File(absolute),
70
+ };
71
+ }