rikrok 0.5.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 +21 -0
- package/README.md +255 -0
- package/assets/icon.svg +1 -0
- package/assets/readme/beat-flow.jpg +0 -0
- package/assets/readme/beat-headline.jpg +0 -0
- package/assets/readme/beat-next.jpg +0 -0
- package/assets/readme/beat-play.jpg +0 -0
- package/assets/readme/beat-status.jpg +0 -0
- package/assets/readme/beats.jpg +0 -0
- package/assets/wordmark.png +0 -0
- package/assets/wordmark.svg +1 -0
- package/bin/rikrok.mjs +63 -0
- package/launchd/com.rikrok.plist.tmpl +25 -0
- package/package.json +70 -0
- package/remotion/Reel.tsx +513 -0
- package/remotion/Root.tsx +71 -0
- package/remotion/index.ts +4 -0
- package/remotion/public/silence.wav +0 -0
- package/remotion/theme.ts +52 -0
- package/scripts/gen-icon.mjs +117 -0
- package/scripts/ui-check.mjs +76 -0
- package/server/feed.mjs +153 -0
- package/server/public/app.js +342 -0
- package/server/public/icon-180.png +0 -0
- package/server/public/icon-512.png +0 -0
- package/server/public/icon.svg +1 -0
- package/server/public/index.html +112 -0
- package/server/public/manifest.webmanifest +14 -0
- package/server/public/sw.js +22 -0
- package/src/cli/backfill.mjs +13 -0
- package/src/cli/config.mjs +29 -0
- package/src/cli/demo.mjs +14 -0
- package/src/cli/doctor.mjs +152 -0
- package/src/cli/feed.mjs +7 -0
- package/src/cli/hook.mjs +77 -0
- package/src/cli/install.mjs +66 -0
- package/src/cli/recap.mjs +66 -0
- package/src/cli/setup.mjs +162 -0
- package/src/cli/voice.mjs +214 -0
- package/src/cli/watch.mjs +5 -0
- package/src/hooks/comment.mjs +21 -0
- package/src/lib/backfill.mjs +40 -0
- package/src/lib/config.mjs +89 -0
- package/src/lib/evidence.mjs +21 -0
- package/src/lib/gitinfo.mjs +40 -0
- package/src/lib/llm.mjs +258 -0
- package/src/lib/narrate.mjs +148 -0
- package/src/lib/palette.mjs +27 -0
- package/src/lib/paths.mjs +29 -0
- package/src/lib/pipeline.mjs +180 -0
- package/src/lib/render-job.mjs +76 -0
- package/src/lib/script-claude.mjs +48 -0
- package/src/lib/state.mjs +19 -0
- package/src/lib/stt.mjs +26 -0
- package/src/lib/watcher.mjs +102 -0
- package/src/sources/claude.mjs +168 -0
- package/src/sources/index.mjs +26 -0
- package/src/voices/clone.mjs +66 -0
- package/src/voices/fx.mjs +22 -0
- package/src/voices/index.mjs +46 -0
- package/src/voices/module.mjs +18 -0
- package/src/voices/none.mjs +23 -0
- package/src/voices/openai-speech.mjs +34 -0
- package/src/voices/say.mjs +32 -0
- package/test/fixtures/claude-session.jsonl +23 -0
package/src/lib/llm.mjs
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Script generation through any OpenAI-compatible chat endpoint (Ollama, LM Studio, oMLX, ...).
|
|
2
|
+
// Strict JSON out; one retry; deterministic fallback so a reel ALWAYS ships.
|
|
3
|
+
import { LLM_URL, LLM_MODEL, LLM_KEY, LLM_EXTRA, SCRIPT_BACKEND, authHeaders } from "./config.mjs";
|
|
4
|
+
import { claudeChat, claudeAvailable } from "./script-claude.mjs";
|
|
5
|
+
|
|
6
|
+
const SYSTEM = `You write scripts for 30-45 second vertical recap reels about finished coding sessions.
|
|
7
|
+
Fixed daily-news / sports-recap grammar. Brisk, neutral news-anchor tone. No hooks, no teasing, no hype words.
|
|
8
|
+
State the outcome up front. Keep total narration 75-110 words.
|
|
9
|
+
Respond with ONLY a JSON object, no markdown fences, matching exactly:
|
|
10
|
+
{
|
|
11
|
+
"headline": "ProjectName: one-line outcome (max 60 chars)",
|
|
12
|
+
"done_count": <int, meaningful things completed>,
|
|
13
|
+
"open_count": <int, things still open>,
|
|
14
|
+
"plays": [
|
|
15
|
+
{ "caption": "short declarative caption, max 52 chars",
|
|
16
|
+
"narration": "1-2 spoken sentences for this beat",
|
|
17
|
+
"visual": { "type": "files" | "commits" | "commands" | "text", "lines": ["up to 4 short lines of supporting evidence"] } }
|
|
18
|
+
],
|
|
19
|
+
"status": { "caption": "outcome summary, max 52 chars", "narration": "1-2 spoken sentences stating concretely WHAT was achieved and WHERE it now lives (deployed, committed, live URL, file)", "achieved": ["max 4 concrete artifacts: things that now exist/work, each naming the thing (e.g. 'Feed live at app.example.com', '7 commits on build branch')"], "open": ["max 3 short items still unresolved"] },
|
|
20
|
+
"next_step": { "caption": "THE single next action, max 60 chars", "narration": "1 spoken sentence stating the next step" },
|
|
21
|
+
"headline_narration": "1-2 spoken sentences opening the reel with the outcome",
|
|
22
|
+
"flow": null | {
|
|
23
|
+
"title": "what moves, max 44 chars (e.g. 'Signup submit, guarded')",
|
|
24
|
+
"narration": "1-2 spoken sentences walking the viewer along the arrows in order",
|
|
25
|
+
"nodes": [ { "id": "a", "label": "max 24 chars, a noun (Sign up button, POST /signup, users table)", "kind": "ui" | "api" | "data" | "service" | "job" | "external" } ],
|
|
26
|
+
"edges": [ { "from": "a", "to": "b", "label": "verb, max 16 chars (submits, writes, calls, returns)" } ],
|
|
27
|
+
"changed": ["ids of the 1 or 2 nodes this session actually added or altered; the rest are context"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
Rules: 2-4 plays, each about one real thing that happened (feature built, bug fixed, decision made).
|
|
31
|
+
Evidence lines must come from the provided session data (file names, commit lines, commands, urls_mentioned) — never invent.
|
|
32
|
+
Be informative over punchy: the viewer wants to know WHAT was achieved and WHERE it is.
|
|
33
|
+
If urls_mentioned contains a live/deployed URL, it belongs in status.achieved.
|
|
34
|
+
flow: include it whenever the session built or changed how something moves: a user action (tap, submit, click) reaching a handler, hook, API route, database, queue, cron, webhook or redirect. Most sessions that edited a component, handler, route or model have one; prefer a small 2-4 node flow over null. Nodes and edges in the order things happen, starting from the user's action when there is one, each backed by the session evidence. Only when nothing moved (pure docs, config, research) is flow null.
|
|
35
|
+
If previous_next_step is present, say early in headline_narration or status.narration whether that step was done this session, still open, or replaced.
|
|
36
|
+
Captions, achieved items and evidence lines are ON-SCREEN TEXT: write URLs and paths literally (e.g. "app.example.com").
|
|
37
|
+
Narration is SPOKEN: no file paths or code symbols; a domain may be spoken with the dots said aloud ("app dot example dot com").`;
|
|
38
|
+
|
|
39
|
+
function deepMerge(a, b) {
|
|
40
|
+
const out = { ...a };
|
|
41
|
+
for (const [k, v] of Object.entries(b || {})) {
|
|
42
|
+
out[k] = v && typeof v === "object" && !Array.isArray(v) && out[k] && typeof out[k] === "object" ? deepMerge(out[k], v) : v;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Reasoning models that ignore RIKROK_LLM_EXTRA (Ollama drops chat_template_kwargs) leak
|
|
48
|
+
// their thinking into the content; strip it before looking for JSON.
|
|
49
|
+
export function stripThinking(t) {
|
|
50
|
+
return String(t).replace(/<think>[\s\S]*?<\/think>/g, "").replace(/^[\s\S]*?<\/think>/, "").trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// One chat completion. RIKROK_LLM_EXTRA is deep-merged into the request body so
|
|
54
|
+
// server-specific fields (chat_template_kwargs, think, reasoning_effort) stay possible.
|
|
55
|
+
export async function chat(messages, { temperature = 0.4, max_tokens = 1600, timeoutMs = 180_000 } = {}) {
|
|
56
|
+
if (!LLM_MODEL) throw new Error("RIKROK_LLM_MODEL is not set");
|
|
57
|
+
const body = deepMerge({ model: LLM_MODEL, messages, temperature, max_tokens }, LLM_EXTRA);
|
|
58
|
+
const resp = await fetch(`${LLM_URL}/v1/chat/completions`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { ...authHeaders(LLM_KEY), "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify(body),
|
|
62
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
63
|
+
});
|
|
64
|
+
if (!resp.ok) throw new Error(`LLM HTTP ${resp.status} from ${LLM_URL}`);
|
|
65
|
+
const j = await resp.json();
|
|
66
|
+
return stripThinking(j.choices?.[0]?.message?.content || "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let resolvedBackend = null;
|
|
70
|
+
// "claude" | "local" | "none", decided once per process.
|
|
71
|
+
export async function scriptBackend() {
|
|
72
|
+
if (resolvedBackend) return resolvedBackend;
|
|
73
|
+
if (SCRIPT_BACKEND === "claude") resolvedBackend = "claude";
|
|
74
|
+
else if (SCRIPT_BACKEND === "local") resolvedBackend = LLM_MODEL ? "local" : "none";
|
|
75
|
+
else resolvedBackend = LLM_MODEL ? "local" : (await claudeAvailable()) ? "claude" : "none";
|
|
76
|
+
return resolvedBackend;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let warnedNoModel = false;
|
|
80
|
+
export async function generateScript(evidence, projectName) {
|
|
81
|
+
const backend = await scriptBackend();
|
|
82
|
+
if (backend === "none") {
|
|
83
|
+
if (!warnedNoModel) console.error("[llm] no script writer: set RIKROK_LLM_MODEL for a local model, or install Claude Code for RIKROK_SCRIPT=claude. Reels use the template script.");
|
|
84
|
+
warnedNoModel = true;
|
|
85
|
+
return { script: fallbackScript(evidence, projectName), source: "fallback" };
|
|
86
|
+
}
|
|
87
|
+
const user = `Project: ${projectName}\nSession evidence (JSON):\n${JSON.stringify(evidence, null, 1)}\n\nWrite the reel script JSON now.`;
|
|
88
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
89
|
+
try {
|
|
90
|
+
const messages = [{ role: "system", content: SYSTEM }, { role: "user", content: user }];
|
|
91
|
+
const text = backend === "claude" ? await claudeChat(messages) : await chat(messages, { temperature: attempt === 0 ? 0.4 : 0.1 });
|
|
92
|
+
const script = extractJson(text);
|
|
93
|
+
const valid = validateScript(script);
|
|
94
|
+
if (valid.ok) return { script: normalise(script, projectName), source: backend === "claude" ? "claude" : "llm" };
|
|
95
|
+
console.error(`[llm] attempt ${attempt + 1} invalid: ${valid.error}`);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error(`[llm] attempt ${attempt + 1} failed: ${err.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { script: fallbackScript(evidence, projectName), source: "fallback" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function extractJson(text) {
|
|
104
|
+
text = text.replace(/^[\s\S]*?(?=\{)/, "").trim();
|
|
105
|
+
// trim trailing junk after the last closing brace
|
|
106
|
+
const last = text.lastIndexOf("}");
|
|
107
|
+
if (last >= 0) text = text.slice(0, last + 1);
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(text);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function validateScript(s) {
|
|
116
|
+
if (!s || typeof s !== "object") return { ok: false, error: "not an object" };
|
|
117
|
+
if (typeof s.headline !== "string" || !s.headline) return { ok: false, error: "headline" };
|
|
118
|
+
if (!Array.isArray(s.plays) || s.plays.length < 1 || s.plays.length > 6)
|
|
119
|
+
return { ok: false, error: "plays" };
|
|
120
|
+
for (const p of s.plays) {
|
|
121
|
+
if (typeof p.caption !== "string" || typeof p.narration !== "string")
|
|
122
|
+
return { ok: false, error: "play fields" };
|
|
123
|
+
if (!p.visual || !Array.isArray(p.visual.lines)) return { ok: false, error: "play visual" };
|
|
124
|
+
}
|
|
125
|
+
if (!s.status || typeof s.status.narration !== "string") return { ok: false, error: "status" };
|
|
126
|
+
if (!s.next_step || typeof s.next_step.caption !== "string" || typeof s.next_step.narration !== "string")
|
|
127
|
+
return { ok: false, error: "next_step" };
|
|
128
|
+
if (typeof s.headline_narration !== "string") return { ok: false, error: "headline_narration" };
|
|
129
|
+
if (s.flow != null) {
|
|
130
|
+
const v = validateFlow(s.flow);
|
|
131
|
+
if (!v.ok) {
|
|
132
|
+
console.error(`[llm] flow dropped: ${v.error}`);
|
|
133
|
+
s.flow = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { ok: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const FLOW_KINDS = ["ui", "api", "data", "service", "job", "external"];
|
|
140
|
+
export function validateFlow(f) {
|
|
141
|
+
if (!f || typeof f !== "object") return { ok: false, error: "not an object" };
|
|
142
|
+
if (!Array.isArray(f.nodes) || f.nodes.length < 2 || f.nodes.length > 6) return { ok: false, error: "nodes" };
|
|
143
|
+
if (!Array.isArray(f.edges) || f.edges.length < 1 || f.edges.length > 7) return { ok: false, error: "edges" };
|
|
144
|
+
const ids = new Set();
|
|
145
|
+
for (const n of f.nodes) {
|
|
146
|
+
if (!n || typeof n.id !== "string" || typeof n.label !== "string" || ids.has(n.id)) return { ok: false, error: "node" };
|
|
147
|
+
ids.add(n.id);
|
|
148
|
+
}
|
|
149
|
+
for (const e of f.edges) if (!e || !ids.has(e.from) || !ids.has(e.to) || e.from === e.to) return { ok: false, error: "edge" };
|
|
150
|
+
if (typeof f.narration !== "string" || !f.narration.trim()) return { ok: false, error: "narration" };
|
|
151
|
+
return { ok: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Models sometimes write the spoken form of a URL into on-screen text. Undo it.
|
|
155
|
+
export function unspeakUrl(x) {
|
|
156
|
+
const t = String(x);
|
|
157
|
+
if (!/\s(dot|slash)\s/i.test(t)) return t;
|
|
158
|
+
return t.replace(/\s+dot\s+/gi, ".").replace(/\s+slash\s+/gi, "/").replace(/\s+colon\s+/gi, ":");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function normalise(s, projectName) {
|
|
162
|
+
// Prepend the project name unless the headline already leads with it
|
|
163
|
+
// (compare ignoring case and punctuation: "My App" matches "my-app").
|
|
164
|
+
const canon = (x) => String(x).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
165
|
+
if (projectName && !canon(s.headline).startsWith(canon(projectName))) {
|
|
166
|
+
s.headline = `${projectName}: ${s.headline}`;
|
|
167
|
+
}
|
|
168
|
+
s.plays = s.plays.slice(0, 4).map((p) => ({
|
|
169
|
+
caption: String(p.caption).slice(0, 60),
|
|
170
|
+
narration: String(p.narration),
|
|
171
|
+
visual: {
|
|
172
|
+
type: ["files", "commits", "commands", "text"].includes(p.visual?.type) ? p.visual.type : "text",
|
|
173
|
+
lines: (p.visual?.lines || []).slice(0, 4).map((l) => unspeakUrl(l).slice(0, 70)),
|
|
174
|
+
},
|
|
175
|
+
}));
|
|
176
|
+
s.headline = String(s.headline).slice(0, 70);
|
|
177
|
+
s.done_count = Number.isFinite(+s.done_count) ? Math.max(0, Math.round(+s.done_count)) : s.plays.length;
|
|
178
|
+
s.open_count = Number.isFinite(+s.open_count) ? Math.max(0, Math.round(+s.open_count)) : 0;
|
|
179
|
+
s.status.achieved = (s.status.achieved || s.status.done || []).slice(0, 4).map((x) => unspeakUrl(x).slice(0, 60));
|
|
180
|
+
s.status.open = (s.status.open || []).slice(0, 3).map((x) => unspeakUrl(x).slice(0, 55));
|
|
181
|
+
s.status.caption = String(s.status.caption || "Where it stands").slice(0, 60);
|
|
182
|
+
s.next_step.caption = String(s.next_step.caption).slice(0, 70);
|
|
183
|
+
if (s.flow) {
|
|
184
|
+
const f = s.flow;
|
|
185
|
+
s.flow = {
|
|
186
|
+
title: String(f.title || "How it moves").slice(0, 44),
|
|
187
|
+
narration: String(f.narration),
|
|
188
|
+
nodes: f.nodes.slice(0, 6).map((n) => ({ id: String(n.id), label: String(n.label).slice(0, 24), kind: FLOW_KINDS.includes(n.kind) ? n.kind : "service" })),
|
|
189
|
+
edges: f.edges.slice(0, 7).map((e) => ({ from: String(e.from), to: String(e.to), label: String(e.label || "").slice(0, 16) })),
|
|
190
|
+
changed: (Array.isArray(f.changed) ? f.changed : []).map(String).filter((id) => f.nodes.some((n) => String(n.id) === id)),
|
|
191
|
+
};
|
|
192
|
+
} else s.flow = null;
|
|
193
|
+
return s;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Deterministic template from raw metadata — used when the LLM is down or invalid twice.
|
|
197
|
+
export function fallbackScript(ev, projectName) {
|
|
198
|
+
const commits = ev.git_commits || [];
|
|
199
|
+
const files = ev.files_touched || [];
|
|
200
|
+
const doneTodos = (ev.todos || []).filter((t) => t.status === "completed");
|
|
201
|
+
const openTodos = (ev.todos || []).filter((t) => t.status !== "completed");
|
|
202
|
+
const mins = ev.duration_minutes || 0;
|
|
203
|
+
|
|
204
|
+
const plays = [];
|
|
205
|
+
if (commits.length) {
|
|
206
|
+
plays.push({
|
|
207
|
+
caption: `${commits.length} commit${commits.length > 1 ? "s" : ""} landed`,
|
|
208
|
+
narration: `The session landed ${commits.length} ${commits.length > 1 ? "commits" : "commit"}.`,
|
|
209
|
+
visual: { type: "commits", lines: commits.slice(0, 4).map((c) => c.slice(0, 70)) },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (files.length) {
|
|
213
|
+
plays.push({
|
|
214
|
+
caption: `${files.length} file${files.length > 1 ? "s" : ""} changed`,
|
|
215
|
+
narration: `Work touched ${files.length} ${files.length > 1 ? "files" : "file"} across the project.`,
|
|
216
|
+
visual: { type: "files", lines: files.slice(0, 4).map((f) => f.split("/").slice(-2).join("/")) },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (ev.commands_run?.length) {
|
|
220
|
+
plays.push({
|
|
221
|
+
caption: `${ev.tool_uses} tool calls this session`,
|
|
222
|
+
narration: `${ev.tool_uses} tool calls ran, including builds and checks.`,
|
|
223
|
+
visual: { type: "commands", lines: ev.commands_run.slice(-4).map((c) => String(c).slice(0, 70)) },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (plays.length === 0) {
|
|
227
|
+
plays.push({
|
|
228
|
+
caption: `${ev.assistant_turns} working turns`,
|
|
229
|
+
narration: `The assistant worked through ${ev.assistant_turns} turns this session.`,
|
|
230
|
+
visual: { type: "text", lines: (ev.user_prompts || []).slice(0, 3).map((p) => p.slice(0, 70)) },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const openLine = openTodos[0]?.content || "Review the session output";
|
|
235
|
+
const achieved = [
|
|
236
|
+
...doneTodos.slice(0, 2).map((t) => t.content.slice(0, 60)),
|
|
237
|
+
...(commits.length ? [`${commits.length} commit${commits.length > 1 ? "s" : ""} landed`] : []),
|
|
238
|
+
...(ev.urls_mentioned || []).slice(0, 1).map((u) => `Live: ${u.replace(/^https?:\/\//, "").slice(0, 50)}`),
|
|
239
|
+
].slice(0, 4);
|
|
240
|
+
return {
|
|
241
|
+
headline: `${projectName}: session recap`,
|
|
242
|
+
done_count: doneTodos.length || commits.length || plays.length,
|
|
243
|
+
open_count: openTodos.length,
|
|
244
|
+
plays: plays.slice(0, 4),
|
|
245
|
+
status: {
|
|
246
|
+
caption: `${doneTodos.length || commits.length} done, ${openTodos.length} open`,
|
|
247
|
+
narration: `Overall, ${doneTodos.length || commits.length} items are done and ${openTodos.length} remain open.`,
|
|
248
|
+
achieved,
|
|
249
|
+
open: openTodos.slice(0, 3).map((t) => t.content.slice(0, 55)),
|
|
250
|
+
},
|
|
251
|
+
next_step: {
|
|
252
|
+
caption: openLine.slice(0, 70),
|
|
253
|
+
narration: `Next step: ${openLine}.`,
|
|
254
|
+
},
|
|
255
|
+
headline_narration: `${projectName}. A ${mins || "short"}-minute session wrapped up. Here's the recap.`,
|
|
256
|
+
flow: null,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Narration: one WAV per beat through the configured voice, loudness-normalised,
|
|
2
|
+
// optionally transcribed back and re-taken when words go missing (RIKROK_STT_URL),
|
|
3
|
+
// then joined with breath gaps. Measured durations drive the render timing.
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { VOICE_FX } from "./config.mjs";
|
|
8
|
+
import { transcribeWords, sttEnabled } from "./stt.mjs";
|
|
9
|
+
import { resolveVoice } from "../voices/index.mjs";
|
|
10
|
+
import { silentVoice } from "../voices/none.mjs";
|
|
11
|
+
import { applyFx } from "../voices/fx.mjs";
|
|
12
|
+
|
|
13
|
+
const GAP_SECONDS = 0.35; // breath between beats
|
|
14
|
+
const TAIL_PAD = 0.25; // silence after each beat so the last phoneme is never clipped
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
|
|
17
|
+
export function wavDuration(p) {
|
|
18
|
+
const out = execFileSync("ffprobe", ["-i", p, "-show_entries", "format=duration", "-v", "quiet", "-of", "csv=p=0"]).toString().trim();
|
|
19
|
+
return parseFloat(out) || 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeVolume(p) {
|
|
23
|
+
const out = p.replace(/\.wav$/, "_norm.wav");
|
|
24
|
+
execFileSync("ffmpeg", ["-y", "-i", p, "-af", "loudnorm=I=-23:TP=-1.5:LRA=11", "-ar", "24000", "-ac", "1", out], { stdio: "ignore" });
|
|
25
|
+
fs.renameSync(out, p);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const norm = (w) => String(w).toLowerCase().replace(/[^a-z0-9']/g, "");
|
|
29
|
+
const SPELL = { 0: "zero", 1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six", 7: "seven", 8: "eight", 9: "nine", 10: "ten", 11: "eleven", 12: "twelve", 13: "thirteen", 14: "fourteen", 15: "fifteen", 16: "sixteen", 17: "seventeen", 18: "eighteen", 19: "nineteen", 20: "twenty", 30: "thirty", 40: "forty", 50: "fifty", 60: "sixty", 70: "seventy", 80: "eighty", 90: "ninety", 100: "hundred" };
|
|
30
|
+
const UNSPELL = Object.fromEntries(Object.entries(SPELL).map(([k, v]) => [v, k]));
|
|
31
|
+
|
|
32
|
+
// Compare script words to heard words. Returns {coverage, tailOk, missing}.
|
|
33
|
+
export function compareWords(text, heard) {
|
|
34
|
+
const want = text.split(/\s+/).map(norm).filter(Boolean);
|
|
35
|
+
const got = new Set(heard.map((w) => norm(w.w)).filter(Boolean));
|
|
36
|
+
const ok = (w) => got.has(w) || (SPELL[+w] && got.has(SPELL[+w])) || (UNSPELL[w] && got.has(UNSPELL[w])) || [...got].some((g) => g.length > 3 && (g.startsWith(w) || w.startsWith(g)));
|
|
37
|
+
const hits = want.filter(ok).length;
|
|
38
|
+
const coverage = want.length ? hits / want.length : 1;
|
|
39
|
+
const tail = want.slice(-3);
|
|
40
|
+
const tailOk = tail.filter(ok).length >= Math.min(2, tail.length);
|
|
41
|
+
return { coverage, tailOk, missing: want.filter((w) => !ok(w)) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function synthOnce(voice, text, outPath, attempt) {
|
|
45
|
+
// Deterministic engines give identical (bad) audio for identical text; nudge it per attempt.
|
|
46
|
+
const variants = [text, text.replace(/\s+$/, "") + " ", text.replace(/[.!?]$/, "") + "."];
|
|
47
|
+
const t = variants[Math.min(attempt, variants.length - 1)];
|
|
48
|
+
let res;
|
|
49
|
+
for (let wait = 0; wait < 8; wait++) {
|
|
50
|
+
res = await voice.synth(t, outPath);
|
|
51
|
+
// A shared local inference box under memory pressure answers 500; wait it out.
|
|
52
|
+
if (res.ok || !/memory ceiling|Cannot load|exceed/i.test(res.error || "")) break;
|
|
53
|
+
console.log(`[narrate] voice backend under memory pressure, waiting 60s (${wait + 1}/8)`);
|
|
54
|
+
await sleep(60_000);
|
|
55
|
+
}
|
|
56
|
+
if (res.ok && !res.silent) {
|
|
57
|
+
normalizeVolume(outPath);
|
|
58
|
+
applyFx(outPath, VOICE_FX);
|
|
59
|
+
}
|
|
60
|
+
return res;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function qaLoop(voice, text, p, i, qa) {
|
|
64
|
+
let best = null;
|
|
65
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
66
|
+
if (attempt > 0) {
|
|
67
|
+
const r = await synthOnce(voice, text, p, attempt);
|
|
68
|
+
if (!r.ok) break;
|
|
69
|
+
}
|
|
70
|
+
let heard = [];
|
|
71
|
+
for (let t = 0; t < 3 && heard.length === 0; t++) {
|
|
72
|
+
heard = await transcribeWords(p);
|
|
73
|
+
if (heard.length === 0) await sleep(5000);
|
|
74
|
+
}
|
|
75
|
+
if (heard.length === 0) {
|
|
76
|
+
console.log(`[narrate] beat ${i}: transcriber returned nothing, keeping audio unverified`);
|
|
77
|
+
qa.push({ beat: i, unverified: true, attempts: attempt + 1 });
|
|
78
|
+
best = null;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
const r = compareWords(text, heard);
|
|
82
|
+
const score = r.coverage + (r.tailOk ? 0.5 : 0);
|
|
83
|
+
if (!best || score > best.score) {
|
|
84
|
+
best = { ...r, attempt, score };
|
|
85
|
+
fs.copyFileSync(p, p + ".best");
|
|
86
|
+
}
|
|
87
|
+
if (r.coverage >= 0.92 && r.tailOk) break;
|
|
88
|
+
console.log(`[narrate] beat ${i} attempt ${attempt + 1}: coverage ${(r.coverage * 100).toFixed(0)}% tail=${r.tailOk} missing=[${r.missing.slice(0, 6).join(",")}], retaking`);
|
|
89
|
+
}
|
|
90
|
+
if (best) {
|
|
91
|
+
fs.copyFileSync(p + ".best", p);
|
|
92
|
+
fs.unlinkSync(p + ".best");
|
|
93
|
+
qa.push({ beat: i, coverage: +best.coverage.toFixed(2), tailOk: best.tailOk, attempts: best.attempt + 1, missing: best.missing.slice(0, 8) });
|
|
94
|
+
} else {
|
|
95
|
+
try {
|
|
96
|
+
fs.unlinkSync(p + ".best");
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// texts: one narration string per beat.
|
|
102
|
+
// Returns { audioPath, beats: [{start, duration}], totalDuration, qa, voice, silent }.
|
|
103
|
+
export async function narrateBeats(texts, workDir, opts = {}) {
|
|
104
|
+
const voice = opts.voice || (await resolveVoice());
|
|
105
|
+
const silent = voice.name === "none";
|
|
106
|
+
const qaOn = sttEnabled() && !silent;
|
|
107
|
+
const parts = [];
|
|
108
|
+
const qa = [];
|
|
109
|
+
let anySilent = silent;
|
|
110
|
+
for (let i = 0; i < texts.length; i++) {
|
|
111
|
+
const p = path.join(workDir, `beat-${i}.wav`);
|
|
112
|
+
let res = await synthOnce(voice, texts[i], p, 0);
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
console.error(`[narrate] beat ${i}: ${voice.name} failed (${res.error}); using silence for this beat`);
|
|
115
|
+
res = await silentVoice().synth(texts[i], p);
|
|
116
|
+
if (!res.ok) throw new Error(`narration failed: ${res.error}`);
|
|
117
|
+
qa.push({ beat: i, silent: true });
|
|
118
|
+
anySilent = true;
|
|
119
|
+
} else if (qaOn) {
|
|
120
|
+
await qaLoop(voice, texts[i], p, i, qa);
|
|
121
|
+
}
|
|
122
|
+
parts.push({ path: p, duration: wavDuration(p) + TAIL_PAD });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Concat: resample, pad each beat (tail pad + breath gap), join.
|
|
126
|
+
const audioPath = path.join(workDir, "narration.wav");
|
|
127
|
+
const inputs = [];
|
|
128
|
+
const filters = [];
|
|
129
|
+
for (let i = 0; i < parts.length; i++) {
|
|
130
|
+
inputs.push("-i", parts[i].path);
|
|
131
|
+
filters.push(`[${i}:a]aresample=24000,aformat=channel_layouts=mono[a${i}]`);
|
|
132
|
+
}
|
|
133
|
+
const padded = parts.map((_, i) => `[a${i}]apad=pad_dur=${(TAIL_PAD + (i < parts.length - 1 ? GAP_SECONDS : 0)).toFixed(2)}[p${i}]`).join(";");
|
|
134
|
+
const concatIn = parts.map((_, i) => `[p${i}]`).join("");
|
|
135
|
+
const filter = `${filters.join(";")};${padded};${concatIn}concat=n=${parts.length}:v=0:a=1[out]`;
|
|
136
|
+
execFileSync("ffmpeg", ["-y", ...inputs, "-filter_complex", filter, "-map", "[out]", audioPath], { stdio: "ignore" });
|
|
137
|
+
|
|
138
|
+
const beats = [];
|
|
139
|
+
let t = 0;
|
|
140
|
+
for (let i = 0; i < parts.length; i++) {
|
|
141
|
+
const gap = i < parts.length - 1 ? GAP_SECONDS : 0;
|
|
142
|
+
beats.push({ start: t, duration: parts[i].duration + gap });
|
|
143
|
+
t += parts[i].duration + gap;
|
|
144
|
+
}
|
|
145
|
+
const totalDuration = wavDuration(audioPath);
|
|
146
|
+
for (const p of parts) fs.unlinkSync(p.path);
|
|
147
|
+
return { audioPath, beats, totalDuration, qa, voice: voice.name, silent: anySilent };
|
|
148
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// One fixed, deterministic accent colour per project, chosen to read on a black
|
|
2
|
+
// canvas so a project is identifiable by colour alone while scrolling.
|
|
3
|
+
export const ACCENTS = [
|
|
4
|
+
"#69C9D0", // cyan (brand)
|
|
5
|
+
"#EE1D52", // red (brand)
|
|
6
|
+
"#F5D547", // yellow
|
|
7
|
+
"#7CFC9A", // mint
|
|
8
|
+
"#FF8C42", // orange
|
|
9
|
+
"#B98CFF", // lavender
|
|
10
|
+
"#4DA3FF", // sky
|
|
11
|
+
"#FF6BC1", // pink
|
|
12
|
+
"#A8E34D", // lime
|
|
13
|
+
"#FFB86B", // apricot
|
|
14
|
+
"#5EEAD4", // teal
|
|
15
|
+
"#E0E0E0", // silver
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function accentFor(projectName) {
|
|
19
|
+
// FNV-1a spreads short similar names far better than djb2 mod n
|
|
20
|
+
let h = 0x811c9dc5;
|
|
21
|
+
const s = String(projectName).toLowerCase();
|
|
22
|
+
for (let i = 0; i < s.length; i++) {
|
|
23
|
+
h ^= s.charCodeAt(i);
|
|
24
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
25
|
+
}
|
|
26
|
+
return ACCENTS[h % ACCENTS.length];
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Where Rik Rok keeps its data (reels, state, render bundle, logs).
|
|
2
|
+
// Default ~/.rikrok, override with RIKROK_HOME. Package-relative dirs resolve from this file.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
export const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
9
|
+
export const PKG = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
|
|
10
|
+
export const PKG_VERSION = PKG.version;
|
|
11
|
+
export const REMOTION_DIR = path.join(PKG_ROOT, "remotion");
|
|
12
|
+
export const PUBLIC_DIR = path.join(PKG_ROOT, "server", "public");
|
|
13
|
+
export const BIN = path.join(PKG_ROOT, "bin", "rikrok.mjs");
|
|
14
|
+
|
|
15
|
+
export function expandHome(p) {
|
|
16
|
+
return String(p).replace(/^~(?=$|\/)/, os.homedir());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const RIKROK_HOME = path.resolve(expandHome(process.env.RIKROK_HOME || "~/.rikrok"));
|
|
20
|
+
export const REELS_DIR = path.join(RIKROK_HOME, "reels");
|
|
21
|
+
export const WORK_DIR = path.join(RIKROK_HOME, "work");
|
|
22
|
+
export const BUNDLE_DIR = path.join(RIKROK_HOME, "bundle");
|
|
23
|
+
export const LOG_DIR = path.join(RIKROK_HOME, "logs");
|
|
24
|
+
export const STATE_FILE = path.join(RIKROK_HOME, "state.json");
|
|
25
|
+
export const CONFIG_FILE = path.join(RIKROK_HOME, "config.json");
|
|
26
|
+
|
|
27
|
+
export function ensureDirs() {
|
|
28
|
+
for (const d of [RIKROK_HOME, REELS_DIR, WORK_DIR, LOG_DIR]) fs.mkdirSync(d, { recursive: true });
|
|
29
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// One session slice -> one reel. Steps: parse -> evidence -> script -> narration -> render -> sidecar.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { REELS_DIR, WORK_DIR, PKG_ROOT, HANDLE, FLOW_BEAT } from "./config.mjs";
|
|
7
|
+
import { sourceById } from "../sources/index.mjs";
|
|
8
|
+
import { condense } from "./evidence.mjs";
|
|
9
|
+
import { gitCommitsFor, gitDiffStat } from "./gitinfo.mjs";
|
|
10
|
+
import { generateScript } from "./llm.mjs";
|
|
11
|
+
import { narrateBeats } from "./narrate.mjs";
|
|
12
|
+
import { accentFor } from "./palette.mjs";
|
|
13
|
+
|
|
14
|
+
export const FPS = 30;
|
|
15
|
+
export const MIN_BEAT_SEC = 2.2; // a card must be readable even if its narration is short
|
|
16
|
+
export const TAIL_SEC = 1.2; // final card holds past the end of audio
|
|
17
|
+
|
|
18
|
+
export function shortPath(cwd) {
|
|
19
|
+
if (!cwd) return null;
|
|
20
|
+
const h = os.homedir();
|
|
21
|
+
return cwd === h || cwd.startsWith(h + "/") ? "~" + cwd.slice(h.length) : cwd;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Beat order is the fixed grammar: headline -> plays -> (flow) -> status -> next step.
|
|
25
|
+
export const hasFlow = (script) => Boolean(FLOW_BEAT && script.flow);
|
|
26
|
+
|
|
27
|
+
export function beatDefsFor(script) {
|
|
28
|
+
return [
|
|
29
|
+
{ kind: "headline", caption: script.headline },
|
|
30
|
+
...script.plays.map((p) => ({ kind: "play", caption: p.caption, visual: p.visual })),
|
|
31
|
+
...(hasFlow(script) ? [{ kind: "flow", caption: script.flow.title, flow: { nodes: script.flow.nodes, edges: script.flow.edges, changed: script.flow.changed } }] : []),
|
|
32
|
+
{ kind: "status", caption: script.status.caption, achieved: script.status.achieved, open: script.status.open },
|
|
33
|
+
{ kind: "next", caption: script.next_step.caption },
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function narrationTextsFor(script) {
|
|
38
|
+
return [script.headline_narration, ...script.plays.map((p) => p.narration), ...(hasFlow(script) ? [script.flow.narration] : []), script.status.narration, script.next_step.narration];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The most recent earlier reel for the same session (or, failing that, the same project),
|
|
42
|
+
// so the script can say whether last time's next step happened.
|
|
43
|
+
export function previousRecap(sessionId, projectName, reelsDir = REELS_DIR) {
|
|
44
|
+
let best = null;
|
|
45
|
+
try {
|
|
46
|
+
for (const f of fs.readdirSync(reelsDir)) {
|
|
47
|
+
if (!f.endsWith(".json")) continue;
|
|
48
|
+
let d;
|
|
49
|
+
try {
|
|
50
|
+
d = JSON.parse(fs.readFileSync(path.join(reelsDir, f), "utf-8"));
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!d.next_step || !d.createdAt) continue;
|
|
55
|
+
const sameSession = d.sessionId === sessionId, sameProject = d.project === projectName;
|
|
56
|
+
if (!sameSession && !sameProject) continue;
|
|
57
|
+
const rank = sameSession ? 2 : 1;
|
|
58
|
+
if (!best || rank > best.rank || (rank === best.rank && d.createdAt > best.createdAt)) best = { rank, createdAt: d.createdAt, next_step: d.next_step, headline: d.headline };
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
61
|
+
return best;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Distribute frames per measured narration timing; pad short beats to stay readable,
|
|
65
|
+
// and let the final card hold to the end of audio plus a settle tail. Pure.
|
|
66
|
+
export function buildBeats(beatDefs, beatTimes, totalDuration) {
|
|
67
|
+
const beats = beatDefs.map((b, i) => {
|
|
68
|
+
const start = Math.round(beatTimes[i].start * FPS);
|
|
69
|
+
let end = i < beatDefs.length - 1 ? Math.round((beatTimes[i + 1]?.start ?? totalDuration) * FPS) : Math.round((totalDuration + TAIL_SEC) * FPS);
|
|
70
|
+
if (end - start < MIN_BEAT_SEC * FPS) end = start + Math.round(MIN_BEAT_SEC * FPS);
|
|
71
|
+
return { ...b, startFrame: start, durationFrames: end - start };
|
|
72
|
+
});
|
|
73
|
+
for (let i = 0; i < beats.length - 1; i++) {
|
|
74
|
+
beats[i].durationFrames = Math.max(Math.round(MIN_BEAT_SEC * FPS), beats[i + 1].startFrame - beats[i].startFrame);
|
|
75
|
+
}
|
|
76
|
+
const last = beats[beats.length - 1];
|
|
77
|
+
return { beats, totalFrames: last.startFrame + last.durationFrames };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Remotion input props for a reel. Pure, so tests can check the shape without rendering.
|
|
81
|
+
export function buildProps({ script, projectName, accent, where, durationMin, beatTimes, totalDuration }) {
|
|
82
|
+
const { beats, totalFrames } = buildBeats(beatDefsFor(script), beatTimes, totalDuration);
|
|
83
|
+
const props = {
|
|
84
|
+
projectName,
|
|
85
|
+
accent,
|
|
86
|
+
headline: script.headline,
|
|
87
|
+
where,
|
|
88
|
+
scoreBug: { durationMin, done: script.done_count, open: script.open_count },
|
|
89
|
+
beats,
|
|
90
|
+
totalFrames,
|
|
91
|
+
};
|
|
92
|
+
if (HANDLE) props.handle = HANDLE;
|
|
93
|
+
return props;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// sessionFile: { source, path, sessionId, projectDir }. opts: { outPath, projectName, voice }.
|
|
97
|
+
export async function buildReel(sessionFile, fromLine = 0, opts = {}) {
|
|
98
|
+
const src = sourceById(sessionFile.source || "claude");
|
|
99
|
+
const { path: filePath, sessionId, projectDir } = sessionFile;
|
|
100
|
+
const act = await src.parseSession(filePath, fromLine);
|
|
101
|
+
const projectName = opts.projectName || src.projectName(act, projectDir);
|
|
102
|
+
const accent = accentFor(projectName);
|
|
103
|
+
|
|
104
|
+
const gitCommits = act.firstTs ? gitCommitsFor(act.cwd, act.firstTs, act.lastTs) : [];
|
|
105
|
+
const evidence = condense(act, gitCommits);
|
|
106
|
+
const shortstat = act.firstTs ? gitDiffStat(act.cwd, act.firstTs) : null;
|
|
107
|
+
if (shortstat) evidence.diff_shortstat = shortstat;
|
|
108
|
+
const prev = previousRecap(sessionId, projectName);
|
|
109
|
+
if (prev) evidence.previous_next_step = prev.next_step;
|
|
110
|
+
|
|
111
|
+
console.log(`[pipeline] ${projectName}/${sessionId.slice(0, 8)}: generating script...`);
|
|
112
|
+
const { script, source } = await generateScript(evidence, projectName);
|
|
113
|
+
console.log(`[pipeline] script source: ${source}${hasFlow(script) ? `, flow: ${script.flow.nodes.map((n) => n.label).join(" > ")}` : ", no flow"}`);
|
|
114
|
+
|
|
115
|
+
const id = `${Date.now()}-${projectName.replace(/[^a-zA-Z0-9_-]/g, "_")}-${sessionId.slice(0, 8)}`;
|
|
116
|
+
const workDir = path.join(WORK_DIR, id);
|
|
117
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
118
|
+
|
|
119
|
+
const texts = narrationTextsFor(script);
|
|
120
|
+
console.log(`[pipeline] narrating ${texts.length} beats...`);
|
|
121
|
+
const narration = await narrateBeats(texts, workDir, { voice: opts.voice });
|
|
122
|
+
|
|
123
|
+
const branch = act.gitBranch && act.gitBranch !== "HEAD" ? act.gitBranch : null;
|
|
124
|
+
const cwdShort = shortPath(act.cwd);
|
|
125
|
+
const where = cwdShort ? `${cwdShort}${branch ? ` · ${branch}` : ""}` : null;
|
|
126
|
+
const durationMin = Math.max(1, Math.round(act.activeMs / 60000));
|
|
127
|
+
const props = buildProps({ script, projectName, accent, where, durationMin, beatTimes: narration.beats, totalDuration: narration.totalDuration });
|
|
128
|
+
|
|
129
|
+
const outPath = opts.outPath || path.join(REELS_DIR, `${id}.mp4`);
|
|
130
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
131
|
+
fs.writeFileSync(path.join(workDir, "job.json"), JSON.stringify({ id, props, audioPath: narration.audioPath, outPath }, null, 2));
|
|
132
|
+
|
|
133
|
+
console.log(`[pipeline] rendering...`);
|
|
134
|
+
await runRender(workDir);
|
|
135
|
+
|
|
136
|
+
const sidecar = {
|
|
137
|
+
id,
|
|
138
|
+
source: src.id,
|
|
139
|
+
project: projectName,
|
|
140
|
+
sessionId,
|
|
141
|
+
createdAt: new Date().toISOString(),
|
|
142
|
+
durationSec: Math.round(props.totalFrames / FPS),
|
|
143
|
+
headline: script.headline,
|
|
144
|
+
next_step: script.next_step.caption,
|
|
145
|
+
accentColor: accent,
|
|
146
|
+
watched: false,
|
|
147
|
+
sourcePath: act.cwd || null,
|
|
148
|
+
where,
|
|
149
|
+
achieved: script.status.achieved || [],
|
|
150
|
+
open: script.status.open || [],
|
|
151
|
+
commits: gitCommits.slice(0, 8),
|
|
152
|
+
urls: act.urls.slice(0, 5),
|
|
153
|
+
scriptSource: source,
|
|
154
|
+
voice: narration.voice,
|
|
155
|
+
silent: narration.silent,
|
|
156
|
+
audioQA: narration.qa,
|
|
157
|
+
sessionFile: filePath,
|
|
158
|
+
resumeHint: src.resumeHint(sessionId),
|
|
159
|
+
flow: hasFlow(script) ? script.flow : null,
|
|
160
|
+
previousNextStep: prev ? prev.next_step : null,
|
|
161
|
+
coveredLines: { from: fromLine, to: act.totalLines },
|
|
162
|
+
};
|
|
163
|
+
fs.writeFileSync(outPath.replace(/\.mp4$/, ".json"), JSON.stringify(sidecar, null, 2));
|
|
164
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
165
|
+
console.log(`[pipeline] done: ${outPath}`);
|
|
166
|
+
return { id, totalLines: act.totalLines, outPath, sidecar };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Render in a child process at low priority so a render never starves live work.
|
|
170
|
+
function runRender(workDir) {
|
|
171
|
+
const job = path.join(PKG_ROOT, "src", "lib", "render-job.mjs");
|
|
172
|
+
const useNice = process.platform !== "win32";
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const proc = useNice
|
|
175
|
+
? spawn("nice", ["-n", "19", process.execPath, job, workDir], { stdio: ["ignore", "inherit", "inherit"] })
|
|
176
|
+
: spawn(process.execPath, [job, workDir], { stdio: ["ignore", "inherit", "inherit"] });
|
|
177
|
+
proc.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`render exited ${code}`))));
|
|
178
|
+
proc.on("error", reject);
|
|
179
|
+
});
|
|
180
|
+
}
|