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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The Rik Rok mark: an eighth rest whose stroke becomes the back of a Baskerville Bold
|
|
2
|
+
// Italic R, flattened to outline paths so no font is needed at render time. Cyan and
|
|
3
|
+
// red copies offset and screen-blended, white on top, on black.
|
|
4
|
+
// Geometry was measured from rendered pixels (stem back edge, rest stroke) and tuned by eye.
|
|
5
|
+
// Needs macOS for Baskerville (outline only; the font itself is never shipped).
|
|
6
|
+
// Usage: node scripts/gen-icon.mjs
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import * as fkmod from "fontkit";
|
|
12
|
+
|
|
13
|
+
const fk = fkmod.default ?? fkmod;
|
|
14
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
const CY = "#25F4EE", RD = "#FE2C55";
|
|
16
|
+
|
|
17
|
+
// Outlines
|
|
18
|
+
const bask = fk.openSync("/System/Library/Fonts/Supplemental/Baskerville.ttc").fonts.find((f) => f.postscriptName === "Baskerville-BoldItalic");
|
|
19
|
+
const noto = fk.openSync(path.join(root, "node_modules/@fontsource/noto-music/files/noto-music-music-400-normal.woff2"));
|
|
20
|
+
const rGlyph = bask.glyphForCodePoint("R".codePointAt(0));
|
|
21
|
+
const eGlyph = noto.glyphForCodePoint(0x1d13e);
|
|
22
|
+
const SIZE = 300, BASE = 450, X = 200; // the working canvas the measurements were taken on
|
|
23
|
+
const glyphT = (font) => `translate(${X} ${BASE}) scale(${SIZE / font.unitsPerEm} ${-SIZE / font.unitsPerEm})`;
|
|
24
|
+
|
|
25
|
+
// Measured on that canvas: R ink box, stem back edge (two points), rest ink box, rest stroke edge.
|
|
26
|
+
const rb = { x0: 197, y0: 251, x1: 423, y1: 449 };
|
|
27
|
+
const stem = { xa: 260, ya: 310, xb: 232, yb: 400 };
|
|
28
|
+
const eb = { x0: 215, y0: 231, x1: 292, y1: 368 };
|
|
29
|
+
const stroke = { xa: 264, ya: 306, xb: 251, yb: 354 };
|
|
30
|
+
const ang = (p) => Math.atan2(p.xb - p.xa, p.yb - p.ya);
|
|
31
|
+
const rot = ((ang(stem) - ang(stroke)) * 180) / Math.PI;
|
|
32
|
+
const xAt = (p, y) => p.xa + ((y - p.ya) * (p.xb - p.xa)) / (p.yb - p.ya);
|
|
33
|
+
const capH = BASE - rb.y0;
|
|
34
|
+
|
|
35
|
+
// Tuned: rest 1.12x the cap, tucked 8px into the stem, cut above the foot, R's top-left serif hidden, rest thickened +4.
|
|
36
|
+
const K = 1.12, TUCK = 8, THICK = 4;
|
|
37
|
+
const s = (capH * K) / (eb.y1 - eb.y0);
|
|
38
|
+
const ex = xAt(stroke, eb.y1), ey = eb.y1;
|
|
39
|
+
const tx = xAt(stem, BASE) + TUCK, ty = BASE;
|
|
40
|
+
const restT = `translate(${tx} ${ty}) rotate(${rot.toFixed(3)}) scale(${s.toFixed(4)}) translate(${-ex} ${-ey})`;
|
|
41
|
+
const cutY = BASE - capH * 0.12;
|
|
42
|
+
const midY = BASE - capH * 0.45;
|
|
43
|
+
const rClip = `M${xAt(stem, rb.y0 - 40) - 1} ${rb.y0 - 40} L800 ${rb.y0 - 40} L800 ${BASE + 40} L0 ${BASE + 40} L0 ${midY} L${xAt(stem, midY) - 1} ${midY} Z`;
|
|
44
|
+
const restStroke = (THICK / s) * (noto.unitsPerEm / SIZE); // in the rest's font units
|
|
45
|
+
|
|
46
|
+
const ux0 = Math.min(rb.x0, tx - 40 * s), ux1 = Math.max(rb.x1, tx + (eb.x1 - eb.x0) * s), uy0 = Math.min(rb.y0, ty - (eb.y1 - eb.y0) * s), uy1 = rb.y1;
|
|
47
|
+
const w = ux1 - ux0, h = uy1 - uy0;
|
|
48
|
+
|
|
49
|
+
const body = (fill, id) => `<g fill="${fill}"><g clip-path="url(#${id}r)"><path transform="${glyphT(bask)}" d="${rGlyph.path.toSVG()}"/></g><g clip-path="url(#${id}c)"><g transform="${restT}"><path transform="${glyphT(noto)}" d="${eGlyph.path.toSVG()}" stroke="${fill}" stroke-width="${restStroke.toFixed(2)}" stroke-linejoin="round" stroke-linecap="round" paint-order="stroke"/></g></g></g>`;
|
|
50
|
+
const defs = (id) => `<defs><clipPath id="${id}c"><rect x="0" y="0" width="800" height="${cutY}"/></clipPath><clipPath id="${id}r"><path d="${rClip}"/></clipPath></defs>`;
|
|
51
|
+
|
|
52
|
+
// The mark, fitted into a square of `size` with `fill` fraction, optional rounded background.
|
|
53
|
+
export function mark({ size = 512, fill = 0.8, pad = 96, bg = true, id = "m" } = {}) {
|
|
54
|
+
const f = Math.min((size * fill) / w, (size * fill) / h);
|
|
55
|
+
const fit = `translate(${size / 2} ${size / 2}) scale(${f.toFixed(5)}) translate(${-(ux0 + w / 2)} ${-(uy0 + h / 2)})`;
|
|
56
|
+
const off = (size / 512) * 12;
|
|
57
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">${defs(id)}${bg ? `<rect width="${size}" height="${size}" rx="${pad}" fill="#000"/>` : ""}<g transform="${fit}"><g style="mix-blend-mode:screen" transform="translate(${(-off / f).toFixed(3)} ${(-off * 0.7 / f).toFixed(3)})">${body(CY, id)}</g><g style="mix-blend-mode:screen" transform="translate(${(off / f).toFixed(3)} ${(off * 0.7 / f).toFixed(3)})">${body(RD, id)}</g>${body("#fff", id)}</g></svg>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Wordmark: "Rik Rok" set entirely in Baskerville Bold Italic outlines, where each R is the
|
|
61
|
+
// mark (rest + R). Letters are advanced by the font's own widths in the mark's coordinate
|
|
62
|
+
// system (font-size 300 at x=200, baseline 450), then the whole line is fitted.
|
|
63
|
+
const upem = bask.unitsPerEm, u = SIZE / upem;
|
|
64
|
+
const adv = (ch) => bask.glyphForCodePoint(ch.codePointAt(0)).advanceWidth * u;
|
|
65
|
+
const glyphPath = (ch) => bask.glyphForCodePoint(ch.codePointAt(0)).path.toSVG();
|
|
66
|
+
const KERN = -0.02 * SIZE; // a touch tighter than the font's default fit
|
|
67
|
+
const letters = [];
|
|
68
|
+
let x = 0;
|
|
69
|
+
for (const ch of "Rik Rok") {
|
|
70
|
+
letters.push({ ch, x });
|
|
71
|
+
x += (ch === " " ? adv(" ") * 1.15 : adv(ch)) + (ch === " " ? 0 : KERN);
|
|
72
|
+
}
|
|
73
|
+
const lineW = x - KERN;
|
|
74
|
+
const lineBody = (fill, id) =>
|
|
75
|
+
letters
|
|
76
|
+
.map(({ ch, x }, i) => {
|
|
77
|
+
if (ch === " ") return "";
|
|
78
|
+
if (ch === "R") return `<g transform="translate(${x.toFixed(2)} 0)">${body(fill, `${id}${i}`)}</g>`;
|
|
79
|
+
return `<path transform="translate(${x.toFixed(2)} 0) ${glyphT(bask)}" fill="${fill}" d="${glyphPath(ch)}"/>`;
|
|
80
|
+
})
|
|
81
|
+
.join("");
|
|
82
|
+
const lineDefs = () => ["wc", "wr", "ww"].map((id) => letters.map((l, i) => (l.ch === "R" ? defs(`${id}${i}`) : "")).join("")).join("");
|
|
83
|
+
// bounds of the line: from the first mark's left ink to the last k's right edge; height = mark's box
|
|
84
|
+
const lx0 = ux0, lx1 = letters[letters.length - 1].x + adv("k") + X, ly0 = uy0, ly1 = uy1;
|
|
85
|
+
const LW = lx1 - lx0, LH = ly1 - ly0;
|
|
86
|
+
const WM_H = 160, WM_PAD = 28;
|
|
87
|
+
const fw = (WM_H - WM_PAD * 2) / LH;
|
|
88
|
+
const WM_W = Math.round(LW * fw + WM_PAD * 2);
|
|
89
|
+
const wfit = `translate(${WM_PAD} ${WM_PAD}) scale(${fw.toFixed(5)}) translate(${-lx0} ${-ly0})`;
|
|
90
|
+
const woff = 5 / fw;
|
|
91
|
+
const wordmark = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WM_W} ${WM_H}" width="${WM_W}" height="${WM_H}">${lineDefs()}<rect width="${WM_W}" height="${WM_H}" rx="24" fill="#000"/><g transform="${wfit}"><g style="mix-blend-mode:screen" transform="translate(${-woff} ${-woff * 0.7})">${lineBody(CY, "wc")}</g><g style="mix-blend-mode:screen" transform="translate(${woff} ${woff * 0.7})">${lineBody(RD, "wr")}</g>${lineBody("#fff", "ww")}</g></svg>`;
|
|
92
|
+
|
|
93
|
+
fs.mkdirSync(path.join(root, "assets"), { recursive: true });
|
|
94
|
+
fs.writeFileSync(path.join(root, "assets", "icon.svg"), mark());
|
|
95
|
+
fs.writeFileSync(path.join(root, "server", "public", "icon.svg"), mark());
|
|
96
|
+
fs.writeFileSync(path.join(root, "assets", "wordmark.svg"), wordmark);
|
|
97
|
+
console.log("wrote assets/icon.svg, assets/wordmark.svg, server/public/icon.svg");
|
|
98
|
+
|
|
99
|
+
let chrome = process.env.RIKROK_BROWSER || "";
|
|
100
|
+
if (!chrome) { try { chrome = execSync("find node_modules/.remotion -name 'chrome-headless-shell' -type f | head -1", { cwd: root }).toString().trim(); } catch {} }
|
|
101
|
+
if (!chrome) { console.log("no headless Chrome yet; PNG icons skipped"); process.exit(0); }
|
|
102
|
+
const puppeteer = (await import("puppeteer-core")).default;
|
|
103
|
+
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ["--no-sandbox"] });
|
|
104
|
+
const page = await browser.newPage();
|
|
105
|
+
for (const size of [180, 512]) {
|
|
106
|
+
await page.setViewport({ width: size, height: size, deviceScaleFactor: 1 });
|
|
107
|
+
await page.setContent(`<html><body style="margin:0;background:#000">${mark({ size, pad: size === 180 ? 0 : 96 })}</body></html>`);
|
|
108
|
+
await page.screenshot({ path: path.join(root, "server", "public", `icon-${size}.png`), clip: { x: 0, y: 0, width: size, height: size } });
|
|
109
|
+
console.log(`wrote server/public/icon-${size}.png`);
|
|
110
|
+
}
|
|
111
|
+
const wmW = Number(wordmark.match(/width="(\d+)"/)[1]);
|
|
112
|
+
await page.setViewport({ width: wmW, height: 160, deviceScaleFactor: 2 });
|
|
113
|
+
await page.setContent(`<html><body style="margin:0;background:#000">${wordmark}</body></html>`);
|
|
114
|
+
await page.evaluateHandle("document.fonts.ready");
|
|
115
|
+
await page.screenshot({ path: path.join(root, "assets", "wordmark.png"), clip: { x: 0, y: 0, width: wmW, height: 160 } });
|
|
116
|
+
console.log("wrote assets/wordmark.png");
|
|
117
|
+
await browser.close();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Drives the feed UI at iPhone viewport in headless Chrome and screenshots it.
|
|
2
|
+
// Usage: node scripts/ui-check.mjs [outDir]
|
|
3
|
+
import puppeteer from "puppeteer-core";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const outDir = process.argv[2] || ".";
|
|
8
|
+
const PORT = process.env.RIKROK_PORT || 4870;
|
|
9
|
+
const chrome = execSync(
|
|
10
|
+
"find node_modules/.remotion -name 'chrome-headless-shell' -type f | head -1", { cwd: new URL("..", import.meta.url) },
|
|
11
|
+
).toString().trim();
|
|
12
|
+
|
|
13
|
+
const browser = await puppeteer.launch({
|
|
14
|
+
executablePath: chrome,
|
|
15
|
+
headless: true,
|
|
16
|
+
args: ["--no-sandbox", "--autoplay-policy=no-user-gesture-required", "--mute-audio"],
|
|
17
|
+
});
|
|
18
|
+
const page = await browser.newPage();
|
|
19
|
+
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
|
20
|
+
const errors = [];
|
|
21
|
+
page.on("pageerror", (e) => errors.push(String(e)));
|
|
22
|
+
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
|
|
23
|
+
|
|
24
|
+
await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: "networkidle2" });
|
|
25
|
+
await page.waitForSelector("#go", { timeout: 5000 });
|
|
26
|
+
await page.click("#go");
|
|
27
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
28
|
+
await page.screenshot({ path: path.join(outDir, "ui-feed.png") });
|
|
29
|
+
|
|
30
|
+
const state = await page.evaluate(() => {
|
|
31
|
+
const cells = [...document.querySelectorAll(".cell")];
|
|
32
|
+
const v = document.querySelector("video");
|
|
33
|
+
return {
|
|
34
|
+
cells: cells.length,
|
|
35
|
+
videoElements: document.querySelectorAll("video").length,
|
|
36
|
+
playerInFirstCell: cells[0]?.contains(v) ?? false,
|
|
37
|
+
player: v ? { paused: v.paused, muted: v.muted, currentTime: v.currentTime, src: v.src.split("/").pop() } : null,
|
|
38
|
+
overlayText: cells[0]?.querySelector(".headline")?.textContent,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
console.log(JSON.stringify(state, null, 1));
|
|
42
|
+
|
|
43
|
+
// hold-to-2x
|
|
44
|
+
await page.mouse.move(195, 300);
|
|
45
|
+
await page.mouse.down();
|
|
46
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
47
|
+
const rate = await page.evaluate(() => document.querySelector("video").playbackRate);
|
|
48
|
+
await page.mouse.up();
|
|
49
|
+
await new Promise((r) => setTimeout(r, 450));
|
|
50
|
+
const after = await page.evaluate(() => {
|
|
51
|
+
const v = document.querySelector("video");
|
|
52
|
+
return { rate: v.playbackRate, paused: v.paused };
|
|
53
|
+
});
|
|
54
|
+
console.log("hold rate:", rate, "after release:", JSON.stringify(after));
|
|
55
|
+
|
|
56
|
+
// swipe to second cell — the shared player must follow with a new src
|
|
57
|
+
await page.evaluate(() => document.getElementById("feed").scrollBy(0, window.innerHeight));
|
|
58
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
59
|
+
const s2 = await page.evaluate(() => {
|
|
60
|
+
const cells = [...document.querySelectorAll(".cell")];
|
|
61
|
+
const active = cells.find((c) => {
|
|
62
|
+
const r = c.getBoundingClientRect();
|
|
63
|
+
return r.top >= -10 && r.top < 100;
|
|
64
|
+
});
|
|
65
|
+
const v = document.querySelector("video");
|
|
66
|
+
return {
|
|
67
|
+
activeHeadline: active?.querySelector(".headline")?.textContent,
|
|
68
|
+
playerMoved: active?.contains(v) ?? false,
|
|
69
|
+
playing: v && !v.paused,
|
|
70
|
+
src: v?.src.split("/").pop(),
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
console.log(JSON.stringify(s2, null, 1));
|
|
74
|
+
await page.screenshot({ path: path.join(outDir, "ui-feed-2.png") });
|
|
75
|
+
console.log("page errors:", errors.length ? errors : "none");
|
|
76
|
+
await browser.close();
|
package/server/feed.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Rik Rok feed server: the vertical swipe feed plus reel MP4s.
|
|
2
|
+
// Binds 127.0.0.1 by default; set RIKROK_BIND=0.0.0.0 to reach it from your phone
|
|
3
|
+
// over Tailscale or your LAN (there is no auth, so keep it on a private network).
|
|
4
|
+
import express from "express";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { REELS_DIR, PUBLIC_DIR, FEED_PORT, FEED_BIND, ensureDirs } from "../src/lib/config.mjs";
|
|
8
|
+
import { routeComment, hookConfigured } from "../src/hooks/comment.mjs";
|
|
9
|
+
|
|
10
|
+
export function createApp() {
|
|
11
|
+
ensureDirs();
|
|
12
|
+
const app = express();
|
|
13
|
+
app.use(express.json());
|
|
14
|
+
|
|
15
|
+
function allSidecars() {
|
|
16
|
+
try {
|
|
17
|
+
return fs
|
|
18
|
+
.readdirSync(REELS_DIR)
|
|
19
|
+
.filter((f) => f.endsWith(".json"))
|
|
20
|
+
.map((f) => {
|
|
21
|
+
try { return JSON.parse(fs.readFileSync(path.join(REELS_DIR, f), "utf-8")); } catch { return null; }
|
|
22
|
+
})
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
} catch { return []; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readSidecar(id) {
|
|
28
|
+
const clean = id.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
29
|
+
const p = path.join(REELS_DIR, `${clean}.json`);
|
|
30
|
+
if (!fs.existsSync(p)) return null;
|
|
31
|
+
return { path: p, data: JSON.parse(fs.readFileSync(p, "utf-8")) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
app.get("/api/feed", (req, res) => {
|
|
35
|
+
let items = [];
|
|
36
|
+
try {
|
|
37
|
+
items = fs
|
|
38
|
+
.readdirSync(REELS_DIR)
|
|
39
|
+
.filter((f) => f.endsWith(".json"))
|
|
40
|
+
.map((f) => {
|
|
41
|
+
try {
|
|
42
|
+
const d = JSON.parse(fs.readFileSync(path.join(REELS_DIR, f), "utf-8"));
|
|
43
|
+
if (!fs.existsSync(path.join(REELS_DIR, `${d.id}.mp4`))) return null;
|
|
44
|
+
return d;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
// Auto-tidy: keep at most 3 unwatched reels per project; older unwatched
|
|
51
|
+
// ones are archived (recoverable via ?archived=1 or the sidecar flag).
|
|
52
|
+
const byProj = {};
|
|
53
|
+
for (const d of items) {
|
|
54
|
+
if (!d.archived && !d.watched) (byProj[d.project] = byProj[d.project] || []).push(d);
|
|
55
|
+
}
|
|
56
|
+
for (const arr of Object.values(byProj)) {
|
|
57
|
+
arr.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
58
|
+
for (const d of arr.slice(3)) {
|
|
59
|
+
d.archived = true;
|
|
60
|
+
fs.writeFileSync(path.join(REELS_DIR, `${d.id}.json`), JSON.stringify(d, null, 2));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
items = items.filter((d) => !d.archived || req.query.archived === "1");
|
|
64
|
+
} catch {}
|
|
65
|
+
// Session chains: tell each reel whether a newer recap of its session exists.
|
|
66
|
+
const latestBySession = {};
|
|
67
|
+
for (const d of items) {
|
|
68
|
+
if (!latestBySession[d.sessionId] || d.createdAt > latestBySession[d.sessionId].createdAt) latestBySession[d.sessionId] = d;
|
|
69
|
+
}
|
|
70
|
+
for (const d of items) {
|
|
71
|
+
const latest = latestBySession[d.sessionId];
|
|
72
|
+
d.newerId = latest && latest.id !== d.id ? latest.id : null;
|
|
73
|
+
d.chainCount = items.filter((x) => x.sessionId === d.sessionId).length;
|
|
74
|
+
}
|
|
75
|
+
// Unwatched first, newest first within each group.
|
|
76
|
+
items.sort((a, b) => {
|
|
77
|
+
if (a.watched !== b.watched) return a.watched ? 1 : -1;
|
|
78
|
+
return new Date(b.createdAt) - new Date(a.createdAt);
|
|
79
|
+
});
|
|
80
|
+
res.json(items);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.post("/api/watched/:id", (req, res) => {
|
|
84
|
+
const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
85
|
+
const p = path.join(REELS_DIR, `${id}.json`);
|
|
86
|
+
if (!fs.existsSync(p)) return res.status(404).json({ error: "not found" });
|
|
87
|
+
try {
|
|
88
|
+
const d = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
89
|
+
d.watched = req.body?.watched !== false;
|
|
90
|
+
fs.writeFileSync(p, JSON.stringify(d, null, 2));
|
|
91
|
+
res.json({ ok: true, watched: d.watched });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
res.status(500).json({ error: err.message });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
app.post("/api/archive/:id", (req, res) => {
|
|
98
|
+
const sc = readSidecar(req.params.id);
|
|
99
|
+
if (!sc) return res.status(404).json({ error: "not found" });
|
|
100
|
+
sc.data.archived = req.body?.archived !== false;
|
|
101
|
+
fs.writeFileSync(sc.path, JSON.stringify(sc.data, null, 2));
|
|
102
|
+
res.json({ ok: true, archived: sc.data.archived });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
app.post("/api/comment/:id", (req, res) => {
|
|
106
|
+
const sc = readSidecar(req.params.id);
|
|
107
|
+
if (!sc) return res.status(404).json({ error: "not found" });
|
|
108
|
+
const text = String(req.body?.text || "").trim().slice(0, 2000);
|
|
109
|
+
if (!text) return res.status(400).json({ error: "empty" });
|
|
110
|
+
const comment = { text, at: new Date().toISOString() };
|
|
111
|
+
sc.data.comments = [...(sc.data.comments || []), comment];
|
|
112
|
+
// Hand the comment to RIKROK_COMMENT_HOOK (if set) with enough context to act on:
|
|
113
|
+
// which slice this reel covered, and whether newer recaps of the same session exist.
|
|
114
|
+
let routedTo = null;
|
|
115
|
+
if (hookConfigured()) {
|
|
116
|
+
const newer = allSidecars()
|
|
117
|
+
.filter((d) => d.sessionId === sc.data.sessionId && d.id !== sc.data.id && d.createdAt > sc.data.createdAt)
|
|
118
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
119
|
+
routedTo = routeComment({
|
|
120
|
+
reelId: sc.data.id,
|
|
121
|
+
source: sc.data.source || "claude",
|
|
122
|
+
sessionId: sc.data.sessionId,
|
|
123
|
+
project: sc.data.project,
|
|
124
|
+
cwd: sc.data.sourcePath || null,
|
|
125
|
+
headline: sc.data.headline,
|
|
126
|
+
coveredLines: sc.data.coveredLines || null,
|
|
127
|
+
newerRecaps: newer.map((d) => ({ id: d.id, headline: d.headline, createdAt: d.createdAt })),
|
|
128
|
+
resumeHint: sc.data.resumeHint || null,
|
|
129
|
+
comment: text,
|
|
130
|
+
at: comment.at,
|
|
131
|
+
});
|
|
132
|
+
if (routedTo) comment.routedTo = routedTo;
|
|
133
|
+
}
|
|
134
|
+
fs.writeFileSync(sc.path, JSON.stringify(sc.data, null, 2));
|
|
135
|
+
res.json({ ok: true, routed: Boolean(routedTo), count: sc.data.comments.length });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// MP4s (express.static handles HTTP Range for video seeking/instant playback)
|
|
139
|
+
app.use("/reels", express.static(REELS_DIR, { setHeaders: (r) => r.setHeader("Cache-Control", "public, max-age=31536000, immutable") }));
|
|
140
|
+
app.use(express.static(PUBLIC_DIR));
|
|
141
|
+
return app;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function startFeed({ port = FEED_PORT, bind = FEED_BIND } = {}) {
|
|
145
|
+
const app = createApp();
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const server = app.listen(port, bind, () => {
|
|
148
|
+
console.log(`[feed] Rik Rok feed on http://${bind}:${port}`);
|
|
149
|
+
resolve(server);
|
|
150
|
+
});
|
|
151
|
+
server.on("error", reject);
|
|
152
|
+
});
|
|
153
|
+
}
|