gitreel 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 +21 -0
- package/README.md +78 -0
- package/assets/music/drive.wav +0 -0
- package/assets/sfx/pop.wav +0 -0
- package/assets/sfx/sting.wav +0 -0
- package/assets/sfx/success.wav +0 -0
- package/assets/sfx/thud.wav +0 -0
- package/assets/sfx/tick.wav +0 -0
- package/assets/sfx/whoosh.wav +0 -0
- package/dist/cli/bundling.d.ts +3 -0
- package/dist/cli/bundling.js +76 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +145 -0
- package/dist/cli/narrate.d.ts +1 -0
- package/dist/cli/narrate.js +41 -0
- package/dist/cli/workspace.d.ts +7 -0
- package/dist/cli/workspace.js +56 -0
- package/dist/engine/code/CodeBlock.d.ts +16 -0
- package/dist/engine/code/CodeBlock.js +41 -0
- package/dist/engine/code/highlight.d.ts +6 -0
- package/dist/engine/code/highlight.js +30 -0
- package/dist/engine/episode-definition.d.ts +8 -0
- package/dist/engine/episode-definition.js +1 -0
- package/dist/engine/index.d.ts +16 -0
- package/dist/engine/index.js +15 -0
- package/dist/engine/scenes/BigText.d.ts +7 -0
- package/dist/engine/scenes/BigText.js +18 -0
- package/dist/engine/scenes/CalloutOverlay.d.ts +11 -0
- package/dist/engine/scenes/CalloutOverlay.js +34 -0
- package/dist/engine/scenes/CodeScene.d.ts +33 -0
- package/dist/engine/scenes/CodeScene.js +41 -0
- package/dist/engine/scenes/DiagramScene.d.ts +31 -0
- package/dist/engine/scenes/DiagramScene.js +147 -0
- package/dist/engine/scenes/DiffMorph.d.ts +12 -0
- package/dist/engine/scenes/DiffMorph.js +69 -0
- package/dist/engine/scenes/FileTreeScene.d.ts +23 -0
- package/dist/engine/scenes/FileTreeScene.js +59 -0
- package/dist/engine/scenes/MontageBeat.d.ts +13 -0
- package/dist/engine/scenes/MontageBeat.js +24 -0
- package/dist/engine/scenes/SceneShell.d.ts +19 -0
- package/dist/engine/scenes/SceneShell.js +96 -0
- package/dist/engine/scenes/TerminalScene.d.ts +10 -0
- package/dist/engine/scenes/TerminalScene.js +32 -0
- package/dist/engine/scenes/TitleCard.d.ts +10 -0
- package/dist/engine/scenes/TitleCard.js +34 -0
- package/dist/engine/scenes/VerdictCard.d.ts +13 -0
- package/dist/engine/scenes/VerdictCard.js +44 -0
- package/dist/engine/theme.d.ts +20 -0
- package/dist/engine/theme.js +27 -0
- package/dist/engine/timeline.d.ts +25 -0
- package/dist/engine/timeline.js +27 -0
- package/package.json +47 -0
- package/skills/gitreel/SKILL.md +99 -0
- package/templates/episode/episode.tsx +32 -0
- package/templates/episode/narration.json +8 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohammed S. Yaseen
|
|
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,78 @@
|
|
|
1
|
+
# gitreel 🎬
|
|
2
|
+
|
|
3
|
+
**Turn your pull requests into movie trailers.**
|
|
4
|
+
|
|
5
|
+
gitreel makes narrated, fast-paced, Fireship-style review videos out of GitHub PRs: animated
|
|
6
|
+
architecture diagrams, syntax-highlighted diff morphs, spotlit code close-ups, a local TTS
|
|
7
|
+
narrator with opinions, and a verdict card at the end. Your coding agent writes the screenplay;
|
|
8
|
+
gitreel renders the movie.
|
|
9
|
+
|
|
10
|
+
<!-- DEMO-VIDEO: drag-drop out/zod-5898.mp4 here in the GitHub editor so it gets a hosted player URL -->
|
|
11
|
+
> 🎥 demo: gitreel reviewing [zod#5898](https://github.com/colinhacks/zod/pull/5898) — a prototype-pollution fix ([video](assets/demo/zod-5898.mp4))
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
gitreel is two pieces:
|
|
16
|
+
|
|
17
|
+
1. **The `gitreel` CLI** (this package) — the render engine. Remotion-based scene primitives,
|
|
18
|
+
Kokoro-82M local TTS, synthesized music/SFX, and a workspace where episodes live. No cloud,
|
|
19
|
+
no API keys: narration, rendering, everything runs on your machine.
|
|
20
|
+
2. **The `gitreel` agent skill** — instructions that teach any coding agent (Claude Code, Cursor,
|
|
21
|
+
Codex, opencode, …) to read a PR, write the screenplay (narration + scene composition), and
|
|
22
|
+
drive the CLI. The agent is the writer-director; the CLI is the camera crew.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
# 1. the engine
|
|
28
|
+
npm i -g gitreel
|
|
29
|
+
gitreel doctor # checks node ≥ 20, gh auth, render browser
|
|
30
|
+
|
|
31
|
+
# 2. the skill (via skills.sh, into your agent's skills)
|
|
32
|
+
npx skills add mohasarc/gitreel
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then, in your agent of choice, from any repo:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
/gitreel 5898 # short video for PR #5898
|
|
39
|
+
/gitreel 5898 long # full walkthrough
|
|
40
|
+
/gitreel 5898 --review review.md # weave your review's findings into the video
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The agent checks for the engine and installs it if missing — so honestly, step 2 alone works.
|
|
44
|
+
|
|
45
|
+
## CLI
|
|
46
|
+
|
|
47
|
+
The skill drives these, but they're yours too:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
gitreel new <id> # scaffold an episode (episode.tsx + narration.json)
|
|
51
|
+
gitreel narrate <id> # local TTS → per-scene WAVs + duration manifest
|
|
52
|
+
gitreel still <id> --frame 120 # render one frame (cheap visual check)
|
|
53
|
+
gitreel render <id> [--draft] # render the MP4
|
|
54
|
+
gitreel preview <id> # open Remotion Studio for live editing
|
|
55
|
+
gitreel where # print the workspace (default ~/gitreel, GITREEL_HOME to move)
|
|
56
|
+
gitreel doctor # environment checks
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Episodes are plain TSX files in your workspace that import scene primitives from
|
|
60
|
+
`gitreel/engine` — `TitleCard`, `DiagramScene`, `DiffMorph`, `CodeScene`, `TerminalScene`,
|
|
61
|
+
`FileTreeScene`, `VerdictCard`, and friends. The narration manifest drives scene durations, so
|
|
62
|
+
audio and visuals never drift. Keep your workspace under git; episodes are re-renderable forever.
|
|
63
|
+
|
|
64
|
+
A complete real episode lives in [`examples/zod-5898/`](examples/zod-5898/) — the screenplay
|
|
65
|
+
behind the demo video above. To re-render it:
|
|
66
|
+
`cp -r examples/zod-5898 "$(gitreel where)/episodes/" && gitreel narrate zod-5898 && gitreel render zod-5898`.
|
|
67
|
+
|
|
68
|
+
## Licensing notes
|
|
69
|
+
|
|
70
|
+
- **gitreel is MIT.**
|
|
71
|
+
- **Remotion** (the render engine underneath) is source-available, **not** MIT: free for
|
|
72
|
+
individuals and companies of up to 3 people; larger companies need a
|
|
73
|
+
[Remotion company license](https://www.remotion.dev/license) to render. You render locally,
|
|
74
|
+
so you are the licensee — check your situation.
|
|
75
|
+
- **Kokoro-82M** (the narrator) is Apache-2.0; the ~90MB model downloads from Hugging Face on
|
|
76
|
+
first `gitreel narrate`.
|
|
77
|
+
- Music and SFX are synthesized by scripts in this repo — no licensed assets anywhere.
|
|
78
|
+
- Requires **Node ≥ 20** and the **`gh` CLI** (authenticated) for PR fetching.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { bundle } from "@remotion/bundler";
|
|
5
|
+
import { manifestPath, packageRoot, requireEpisode } from "./workspace.js";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
export function writeEntry(workspace, id) {
|
|
8
|
+
const episodeFile = requireEpisode(workspace, id);
|
|
9
|
+
const manifest = manifestPath(workspace, id);
|
|
10
|
+
if (!existsSync(manifest)) {
|
|
11
|
+
throw new Error(`no narration manifest for "${id}" — run \`gitreel narrate ${id}\` first`);
|
|
12
|
+
}
|
|
13
|
+
const entry = `import React from "react";
|
|
14
|
+
import { Composition, registerRoot } from "remotion";
|
|
15
|
+
import { FPS, Timeline, totalDurationInFrames } from "gitreel/engine";
|
|
16
|
+
import episode from ${JSON.stringify(episodeFile)};
|
|
17
|
+
import manifest from ${JSON.stringify(manifest)};
|
|
18
|
+
|
|
19
|
+
const Root = () => (
|
|
20
|
+
<Composition
|
|
21
|
+
id={episode.id}
|
|
22
|
+
component={() => (
|
|
23
|
+
<Timeline
|
|
24
|
+
episodeId={episode.id}
|
|
25
|
+
scenes={episode.scenes}
|
|
26
|
+
manifest={manifest}
|
|
27
|
+
music={episode.music}
|
|
28
|
+
musicVolume={episode.musicVolume}
|
|
29
|
+
/>
|
|
30
|
+
)}
|
|
31
|
+
durationInFrames={totalDurationInFrames(episode.scenes, manifest)}
|
|
32
|
+
fps={FPS}
|
|
33
|
+
width={1920}
|
|
34
|
+
height={1080}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
registerRoot(Root);
|
|
39
|
+
`;
|
|
40
|
+
const entryFile = path.join(workspace, ".cache", `entry-${id}.tsx`);
|
|
41
|
+
writeFileSync(entryFile, entry);
|
|
42
|
+
return entryFile;
|
|
43
|
+
}
|
|
44
|
+
const packageDir = (name) => path.dirname(require.resolve(`${name}/package.json`));
|
|
45
|
+
export function gitreelWebpackOverride(config) {
|
|
46
|
+
const resolve = (config.resolve ?? {});
|
|
47
|
+
const module = (config.module ?? {});
|
|
48
|
+
const rules = (module.rules ?? []);
|
|
49
|
+
return {
|
|
50
|
+
...config,
|
|
51
|
+
resolve: {
|
|
52
|
+
...resolve,
|
|
53
|
+
alias: {
|
|
54
|
+
...(resolve.alias ?? {}),
|
|
55
|
+
"gitreel/engine": path.join(packageRoot, "dist", "engine", "index.js"),
|
|
56
|
+
react: packageDir("react"),
|
|
57
|
+
"react-dom": packageDir("react-dom"),
|
|
58
|
+
remotion: packageDir("remotion"),
|
|
59
|
+
},
|
|
60
|
+
modules: [path.join(packageRoot, "node_modules"), "node_modules"],
|
|
61
|
+
},
|
|
62
|
+
module: {
|
|
63
|
+
...module,
|
|
64
|
+
rules: [...rules, { test: /\.js$/, resolve: { fullySpecified: false } }],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export async function bundleEpisode(workspace, id) {
|
|
69
|
+
const entryFile = writeEntry(workspace, id);
|
|
70
|
+
return bundle({
|
|
71
|
+
entryPoint: entryFile,
|
|
72
|
+
publicDir: path.join(workspace, "public"),
|
|
73
|
+
outDir: path.join(workspace, ".cache", `bundle-${id}`),
|
|
74
|
+
webpackOverride: gitreelWebpackOverride,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import { bundleEpisode, writeEntry } from "./bundling.js";
|
|
8
|
+
import { narrateEpisode } from "./narrate.js";
|
|
9
|
+
import { ensureWorkspace, episodeDir, packageRoot } from "./workspace.js";
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const program = new Command();
|
|
12
|
+
const version = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8"))
|
|
13
|
+
.version;
|
|
14
|
+
program.name("gitreel").description("Turn your pull requests into movie trailers.").version(version);
|
|
15
|
+
program
|
|
16
|
+
.command("where")
|
|
17
|
+
.description("print the gitreel workspace path")
|
|
18
|
+
.action(() => {
|
|
19
|
+
console.log(ensureWorkspace());
|
|
20
|
+
});
|
|
21
|
+
program
|
|
22
|
+
.command("new <id>")
|
|
23
|
+
.description("scaffold a new episode (episode.tsx + narration.json)")
|
|
24
|
+
.action((id) => {
|
|
25
|
+
const workspace = ensureWorkspace();
|
|
26
|
+
const target = episodeDir(workspace, id);
|
|
27
|
+
if (existsSync(path.join(target, "episode.tsx"))) {
|
|
28
|
+
console.error(`episode "${id}" already exists at ${target}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
mkdirSync(target, { recursive: true });
|
|
32
|
+
cpSync(path.join(packageRoot, "templates", "episode"), target, { recursive: true });
|
|
33
|
+
const episodeFile = path.join(target, "episode.tsx");
|
|
34
|
+
const stamped = readFileSync(episodeFile, "utf8").replace(/EPISODE_ID/g, id);
|
|
35
|
+
writeFileSync(episodeFile, stamped);
|
|
36
|
+
console.log(`scaffolded ${target}`);
|
|
37
|
+
console.log(`next: edit episode.tsx + narration.json, then \`gitreel narrate ${id}\``);
|
|
38
|
+
});
|
|
39
|
+
program
|
|
40
|
+
.command("narrate <id>")
|
|
41
|
+
.description("generate narration audio + duration manifest (Kokoro TTS, local)")
|
|
42
|
+
.option("--force", "regenerate all scenes, ignore cache", false)
|
|
43
|
+
.action(async (id, options) => {
|
|
44
|
+
const workspace = ensureWorkspace();
|
|
45
|
+
await narrateEpisode(workspace, id, options.force);
|
|
46
|
+
});
|
|
47
|
+
program
|
|
48
|
+
.command("still <id>")
|
|
49
|
+
.description("render a single frame as PNG (cheap visual verification)")
|
|
50
|
+
.requiredOption("--frame <n>", "frame number")
|
|
51
|
+
.option("--out <path>", "output path")
|
|
52
|
+
.action(async (id, options) => {
|
|
53
|
+
const workspace = ensureWorkspace();
|
|
54
|
+
const { renderStill, selectComposition } = await import("@remotion/renderer");
|
|
55
|
+
const serveUrl = await bundleEpisode(workspace, id);
|
|
56
|
+
const composition = await selectComposition({ serveUrl, id, inputProps: {} });
|
|
57
|
+
const frame = Number(options.frame);
|
|
58
|
+
const output = options.out ?? path.join(workspace, "out", `${id}-f${frame}.png`);
|
|
59
|
+
await renderStill({ composition, serveUrl, frame, output });
|
|
60
|
+
console.log(output);
|
|
61
|
+
});
|
|
62
|
+
program
|
|
63
|
+
.command("render <id>")
|
|
64
|
+
.description("render the episode to MP4")
|
|
65
|
+
.option("--out <path>", "output path")
|
|
66
|
+
.option("--draft", "fast low-quality render for iteration", false)
|
|
67
|
+
.action(async (id, options) => {
|
|
68
|
+
const workspace = ensureWorkspace();
|
|
69
|
+
const { renderMedia, selectComposition } = await import("@remotion/renderer");
|
|
70
|
+
const serveUrl = await bundleEpisode(workspace, id);
|
|
71
|
+
const composition = await selectComposition({ serveUrl, id, inputProps: {} });
|
|
72
|
+
const outputLocation = options.out ?? path.join(workspace, "out", `${id}.mp4`);
|
|
73
|
+
let lastShown = -1;
|
|
74
|
+
await renderMedia({
|
|
75
|
+
composition,
|
|
76
|
+
serveUrl,
|
|
77
|
+
codec: "h264",
|
|
78
|
+
outputLocation,
|
|
79
|
+
scale: options.draft ? 0.5 : 1,
|
|
80
|
+
jpegQuality: options.draft ? 60 : 80,
|
|
81
|
+
onProgress: ({ progress }) => {
|
|
82
|
+
const percent = Math.floor(progress * 10) * 10;
|
|
83
|
+
if (percent > lastShown) {
|
|
84
|
+
lastShown = percent;
|
|
85
|
+
console.log(`render ${percent}%`);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
console.log(outputLocation);
|
|
90
|
+
});
|
|
91
|
+
program
|
|
92
|
+
.command("preview <id>")
|
|
93
|
+
.description("open Remotion Studio for live-editing the episode")
|
|
94
|
+
.action((id) => {
|
|
95
|
+
const workspace = ensureWorkspace();
|
|
96
|
+
const entryFile = writeEntry(workspace, id);
|
|
97
|
+
const remotionBin = path.join(path.dirname(require.resolve("@remotion/cli/package.json")), "remotion-cli.js");
|
|
98
|
+
const child = spawn(process.execPath, [remotionBin, "studio", entryFile, "--public-dir", path.join(workspace, "public")], { cwd: packageRoot, stdio: "inherit" });
|
|
99
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
100
|
+
});
|
|
101
|
+
program
|
|
102
|
+
.command("doctor")
|
|
103
|
+
.description("check the environment: node, gh, browser, workspace")
|
|
104
|
+
.action(async () => {
|
|
105
|
+
const checks = [
|
|
106
|
+
["node", () => {
|
|
107
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
108
|
+
if (major < 20)
|
|
109
|
+
throw new Error(`node ${process.versions.node} — gitreel needs >= 20`);
|
|
110
|
+
return process.versions.node;
|
|
111
|
+
}],
|
|
112
|
+
["gh CLI", () => {
|
|
113
|
+
execFileSync("gh", ["auth", "status"], { stdio: "pipe" });
|
|
114
|
+
return "installed + authenticated";
|
|
115
|
+
}],
|
|
116
|
+
["workspace", () => ensureWorkspace()],
|
|
117
|
+
["engine", () => {
|
|
118
|
+
const engine = path.join(packageRoot, "dist", "engine", "index.js");
|
|
119
|
+
if (!existsSync(engine))
|
|
120
|
+
throw new Error("engine build missing — reinstall gitreel");
|
|
121
|
+
return engine;
|
|
122
|
+
}],
|
|
123
|
+
["browser", async () => {
|
|
124
|
+
const { ensureBrowser } = await import("@remotion/renderer");
|
|
125
|
+
await ensureBrowser();
|
|
126
|
+
return "headless shell ready";
|
|
127
|
+
}],
|
|
128
|
+
];
|
|
129
|
+
let failed = false;
|
|
130
|
+
for (const [label, check] of checks) {
|
|
131
|
+
try {
|
|
132
|
+
const detail = await check();
|
|
133
|
+
console.log(` ok ${label}: ${detail}`);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
failed = true;
|
|
137
|
+
console.log(` FAIL ${label}: ${error.message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
process.exit(failed ? 1 : 0);
|
|
141
|
+
});
|
|
142
|
+
program.parseAsync().catch((error) => {
|
|
143
|
+
console.error(error.message);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function narrateEpisode(workspace: string, id: string, force: boolean): Promise<void>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { episodeDir, manifestPath } from "./workspace.js";
|
|
4
|
+
export async function narrateEpisode(workspace, id, force) {
|
|
5
|
+
const scriptFile = path.join(episodeDir(workspace, id), "narration.json");
|
|
6
|
+
if (!existsSync(scriptFile)) {
|
|
7
|
+
throw new Error(`no narration script at ${scriptFile} — run \`gitreel new ${id}\` first`);
|
|
8
|
+
}
|
|
9
|
+
const script = JSON.parse(readFileSync(scriptFile, "utf8"));
|
|
10
|
+
const voice = script.voice ?? "am_michael";
|
|
11
|
+
const speed = script.speed ?? 1.1;
|
|
12
|
+
const outDir = path.join(workspace, "public", "audio", id);
|
|
13
|
+
mkdirSync(outDir, { recursive: true });
|
|
14
|
+
const manifestFile = manifestPath(workspace, id);
|
|
15
|
+
const previous = existsSync(manifestFile)
|
|
16
|
+
? JSON.parse(readFileSync(manifestFile, "utf8"))
|
|
17
|
+
: null;
|
|
18
|
+
const manifest = { voice, scenes: {} };
|
|
19
|
+
console.log("loading Kokoro-82M (first run downloads the model, ~90MB)...");
|
|
20
|
+
const { KokoroTTS } = await import("kokoro-js");
|
|
21
|
+
const tts = await KokoroTTS.from_pretrained("onnx-community/Kokoro-82M-v1.0-ONNX", {
|
|
22
|
+
dtype: "q8",
|
|
23
|
+
device: "cpu",
|
|
24
|
+
});
|
|
25
|
+
for (const scene of script.scenes) {
|
|
26
|
+
const wavPath = path.join(outDir, `${scene.id}.wav`);
|
|
27
|
+
const cached = previous?.scenes[scene.id];
|
|
28
|
+
if (!force && cached && cached.text === scene.text && previous?.voice === voice && existsSync(wavPath)) {
|
|
29
|
+
manifest.scenes[scene.id] = cached;
|
|
30
|
+
console.log(` ${scene.id}: cached (${cached.seconds.toFixed(2)}s)`);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const audio = await tts.generate(scene.text, { voice: voice, speed });
|
|
34
|
+
await audio.save(wavPath);
|
|
35
|
+
const seconds = audio.audio.length / audio.sampling_rate;
|
|
36
|
+
manifest.scenes[scene.id] = { seconds, text: scene.text };
|
|
37
|
+
console.log(` ${scene.id}: ${seconds.toFixed(2)}s`);
|
|
38
|
+
}
|
|
39
|
+
writeFileSync(manifestFile, JSON.stringify(manifest, null, 2));
|
|
40
|
+
console.log(`wrote ${manifestFile}`);
|
|
41
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const packageRoot: string;
|
|
2
|
+
export declare function configFilePath(): string;
|
|
3
|
+
export declare function resolveWorkspace(): string;
|
|
4
|
+
export declare function ensureWorkspace(): string;
|
|
5
|
+
export declare function episodeDir(workspace: string, id: string): string;
|
|
6
|
+
export declare function manifestPath(workspace: string, id: string): string;
|
|
7
|
+
export declare function requireEpisode(workspace: string, id: string): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
export const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
6
|
+
export function configFilePath() {
|
|
7
|
+
return path.join(homedir(), ".config", "gitreel", "config.json");
|
|
8
|
+
}
|
|
9
|
+
export function resolveWorkspace() {
|
|
10
|
+
const fromEnv = process.env.GITREEL_HOME;
|
|
11
|
+
if (fromEnv)
|
|
12
|
+
return path.resolve(fromEnv);
|
|
13
|
+
const configFile = configFilePath();
|
|
14
|
+
if (existsSync(configFile)) {
|
|
15
|
+
const config = JSON.parse(readFileSync(configFile, "utf8"));
|
|
16
|
+
if (config.workspace)
|
|
17
|
+
return config.workspace;
|
|
18
|
+
}
|
|
19
|
+
return path.join(homedir(), "gitreel");
|
|
20
|
+
}
|
|
21
|
+
export function ensureWorkspace() {
|
|
22
|
+
const workspace = resolveWorkspace();
|
|
23
|
+
for (const dir of ["episodes", "out", ".cache", "public/audio", "public/music", "public/sfx"]) {
|
|
24
|
+
mkdirSync(path.join(workspace, dir), { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
seedAssets(workspace);
|
|
27
|
+
const configFile = configFilePath();
|
|
28
|
+
if (!existsSync(configFile)) {
|
|
29
|
+
mkdirSync(path.dirname(configFile), { recursive: true });
|
|
30
|
+
writeFileSync(configFile, JSON.stringify({ workspace }, null, 2));
|
|
31
|
+
}
|
|
32
|
+
return workspace;
|
|
33
|
+
}
|
|
34
|
+
function seedAssets(workspace) {
|
|
35
|
+
const assetsDir = path.join(packageRoot, "assets");
|
|
36
|
+
for (const kind of ["music", "sfx"]) {
|
|
37
|
+
const target = path.join(workspace, "public", kind);
|
|
38
|
+
const source = path.join(assetsDir, kind);
|
|
39
|
+
if (!existsSync(source))
|
|
40
|
+
continue;
|
|
41
|
+
cpSync(source, target, { recursive: true, force: false, errorOnExist: false });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function episodeDir(workspace, id) {
|
|
45
|
+
return path.join(workspace, "episodes", id);
|
|
46
|
+
}
|
|
47
|
+
export function manifestPath(workspace, id) {
|
|
48
|
+
return path.join(workspace, "public", "audio", id, "manifest.json");
|
|
49
|
+
}
|
|
50
|
+
export function requireEpisode(workspace, id) {
|
|
51
|
+
const file = path.join(episodeDir(workspace, id), "episode.tsx");
|
|
52
|
+
if (!existsSync(file)) {
|
|
53
|
+
throw new Error(`episode "${id}" not found at ${file} — run \`gitreel new ${id}\` first`);
|
|
54
|
+
}
|
|
55
|
+
return file;
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type CodeLang, type TokenLine } from "./highlight";
|
|
3
|
+
export type LineRange = readonly [number, number];
|
|
4
|
+
export declare const CodeBlock: React.FC<{
|
|
5
|
+
code: string;
|
|
6
|
+
lang?: CodeLang;
|
|
7
|
+
fontSize?: number;
|
|
8
|
+
startLineNumber?: number;
|
|
9
|
+
showLineNumbers?: boolean;
|
|
10
|
+
spotlight?: readonly LineRange[];
|
|
11
|
+
revealAtSecond?: number;
|
|
12
|
+
highlightColor?: string;
|
|
13
|
+
}>;
|
|
14
|
+
export declare const CodeLine: React.FC<{
|
|
15
|
+
tokens: TokenLine;
|
|
16
|
+
}>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { interpolate, useCurrentFrame } from "remotion";
|
|
3
|
+
import { FPS, theme } from "../theme";
|
|
4
|
+
import { useTokenLines } from "./highlight";
|
|
5
|
+
const lineInRanges = (lineNumber, ranges) => ranges.some(([start, end]) => lineNumber >= start && lineNumber <= end);
|
|
6
|
+
export const CodeBlock = ({ code, lang = "typescript", fontSize = 30, startLineNumber = 1, showLineNumbers = true, spotlight = [], revealAtSecond = 0, highlightColor = theme.accent, }) => {
|
|
7
|
+
const frame = useCurrentFrame();
|
|
8
|
+
const lines = useTokenLines(code.replace(/\n$/, ""), lang);
|
|
9
|
+
if (!lines)
|
|
10
|
+
return null;
|
|
11
|
+
const lineHeight = fontSize * 1.55;
|
|
12
|
+
const hasSpotlight = spotlight.length > 0;
|
|
13
|
+
return (_jsx("div", { style: { fontFamily: theme.fontMono, fontSize, lineHeight: `${lineHeight}px` }, children: lines.map((tokens, i) => {
|
|
14
|
+
const lineNumber = startLineNumber + i;
|
|
15
|
+
const revealFrame = revealAtSecond * FPS + i * 1.2;
|
|
16
|
+
const appear = interpolate(frame, [revealFrame, revealFrame + 6], [0, 1], {
|
|
17
|
+
extrapolateLeft: "clamp",
|
|
18
|
+
extrapolateRight: "clamp",
|
|
19
|
+
});
|
|
20
|
+
const lit = !hasSpotlight || lineInRanges(lineNumber, spotlight);
|
|
21
|
+
const opacity = appear * (lit ? 1 : 0.22);
|
|
22
|
+
return (_jsxs("div", { style: {
|
|
23
|
+
display: "flex",
|
|
24
|
+
opacity,
|
|
25
|
+
transform: `translateX(${(1 - appear) * 24}px)`,
|
|
26
|
+
backgroundColor: lit && hasSpotlight ? `${highlightColor}1f` : "transparent",
|
|
27
|
+
borderLeft: lit && hasSpotlight ? `4px solid ${highlightColor}` : "4px solid transparent",
|
|
28
|
+
paddingLeft: 14,
|
|
29
|
+
borderRadius: 4,
|
|
30
|
+
}, children: [showLineNumbers && (_jsx("span", { style: {
|
|
31
|
+
width: fontSize * 1.8,
|
|
32
|
+
flexShrink: 0,
|
|
33
|
+
textAlign: "right",
|
|
34
|
+
marginRight: fontSize * 0.9,
|
|
35
|
+
color: theme.textDim,
|
|
36
|
+
opacity: 0.55,
|
|
37
|
+
userSelect: "none",
|
|
38
|
+
}, children: lineNumber })), _jsx(CodeLine, { tokens: tokens })] }, i));
|
|
39
|
+
}) }));
|
|
40
|
+
};
|
|
41
|
+
export const CodeLine = ({ tokens }) => (_jsx("span", { style: { whiteSpace: "pre" }, children: tokens.length === 0 ? " " : tokens.map((token, i) => (_jsx("span", { style: { color: token.color ?? theme.text }, children: token.content }, i))) }));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ThemedToken } from "shiki";
|
|
2
|
+
declare const LANGS: readonly ["typescript", "tsx", "json", "bash", "diff", "markdown", "text"];
|
|
3
|
+
export type CodeLang = (typeof LANGS)[number];
|
|
4
|
+
export type TokenLine = ThemedToken[];
|
|
5
|
+
export declare function useTokenLines(code: string, lang: CodeLang): TokenLine[] | null;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { continueRender, delayRender } from "remotion";
|
|
3
|
+
import { createHighlighter } from "shiki";
|
|
4
|
+
const LANGS = ["typescript", "tsx", "json", "bash", "diff", "markdown", "text"];
|
|
5
|
+
let highlighterPromise = null;
|
|
6
|
+
function getHighlighter() {
|
|
7
|
+
highlighterPromise ??= createHighlighter({
|
|
8
|
+
themes: ["one-dark-pro"],
|
|
9
|
+
langs: [...LANGS],
|
|
10
|
+
});
|
|
11
|
+
return highlighterPromise;
|
|
12
|
+
}
|
|
13
|
+
export function useTokenLines(code, lang) {
|
|
14
|
+
const [lines, setLines] = useState(null);
|
|
15
|
+
const [handle] = useState(() => delayRender(`shiki:${lang}`));
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
getHighlighter().then((highlighter) => {
|
|
19
|
+
if (cancelled)
|
|
20
|
+
return;
|
|
21
|
+
const tokens = highlighter.codeToTokensBase(code, { lang, theme: "one-dark-pro" });
|
|
22
|
+
setLines(tokens);
|
|
23
|
+
continueRender(handle);
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
cancelled = true;
|
|
27
|
+
};
|
|
28
|
+
}, [code, lang, handle]);
|
|
29
|
+
return lines;
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { FPS, theme, severityColor, severityLabel, type Severity } from "./theme";
|
|
2
|
+
export { Timeline, sceneDurationInFrames, totalDurationInFrames, type SceneSpec, type NarrationManifest, } from "./timeline";
|
|
3
|
+
export type { EpisodeDefinition } from "./episode-definition";
|
|
4
|
+
export { CodeBlock, CodeLine, type LineRange } from "./code/CodeBlock";
|
|
5
|
+
export { useTokenLines, type CodeLang, type TokenLine } from "./code/highlight";
|
|
6
|
+
export { SceneShell, Panel, FileChip, ContextTag } from "./scenes/SceneShell";
|
|
7
|
+
export { TitleCard, Pop } from "./scenes/TitleCard";
|
|
8
|
+
export { BigText } from "./scenes/BigText";
|
|
9
|
+
export { CodeScene, type SpotlightBeat, type TreeLine } from "./scenes/CodeScene";
|
|
10
|
+
export { CalloutOverlay, type Finding } from "./scenes/CalloutOverlay";
|
|
11
|
+
export { DiffMorph } from "./scenes/DiffMorph";
|
|
12
|
+
export { DiagramScene, type DiagramNode, type DiagramArrow, type ArrowSide, } from "./scenes/DiagramScene";
|
|
13
|
+
export { FileTreeScene, type FileGroup, type FileEntry, type GroupSpotlight } from "./scenes/FileTreeScene";
|
|
14
|
+
export { MontageBeat, type MontageItem } from "./scenes/MontageBeat";
|
|
15
|
+
export { TerminalScene } from "./scenes/TerminalScene";
|
|
16
|
+
export { VerdictCard, type VerdictCount } from "./scenes/VerdictCard";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { FPS, theme, severityColor, severityLabel } from "./theme";
|
|
2
|
+
export { Timeline, sceneDurationInFrames, totalDurationInFrames, } from "./timeline";
|
|
3
|
+
export { CodeBlock, CodeLine } from "./code/CodeBlock";
|
|
4
|
+
export { useTokenLines } from "./code/highlight";
|
|
5
|
+
export { SceneShell, Panel, FileChip, ContextTag } from "./scenes/SceneShell";
|
|
6
|
+
export { TitleCard, Pop } from "./scenes/TitleCard";
|
|
7
|
+
export { BigText } from "./scenes/BigText";
|
|
8
|
+
export { CodeScene } from "./scenes/CodeScene";
|
|
9
|
+
export { CalloutOverlay } from "./scenes/CalloutOverlay";
|
|
10
|
+
export { DiffMorph } from "./scenes/DiffMorph";
|
|
11
|
+
export { DiagramScene, } from "./scenes/DiagramScene";
|
|
12
|
+
export { FileTreeScene } from "./scenes/FileTreeScene";
|
|
13
|
+
export { MontageBeat } from "./scenes/MontageBeat";
|
|
14
|
+
export { TerminalScene } from "./scenes/TerminalScene";
|
|
15
|
+
export { VerdictCard } from "./scenes/VerdictCard";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { theme } from "../theme";
|
|
4
|
+
import { SceneShell } from "./SceneShell";
|
|
5
|
+
export const BigText = ({ emoji, text, sub, color = theme.text }) => {
|
|
6
|
+
const frame = useCurrentFrame();
|
|
7
|
+
const { fps } = useVideoConfig();
|
|
8
|
+
const pop = spring({ frame, fps, config: { damping: 12, stiffness: 180, mass: 0.7 } });
|
|
9
|
+
const subPop = spring({ frame: frame - 12, fps, config: { damping: 15, stiffness: 150, mass: 0.6 } });
|
|
10
|
+
return (_jsxs(SceneShell, { center: true, children: [_jsxs("div", { style: { textAlign: "center", transform: `scale(${pop}) rotate(${(1 - pop) * -3}deg)` }, children: [emoji && _jsx("div", { style: { fontSize: 170, marginBottom: 24 }, children: emoji }), _jsx("div", { style: { fontFamily: theme.fontDisplay, fontWeight: 900, fontSize: 96, color, maxWidth: 1500 }, children: text })] }), sub && (_jsx("div", { style: {
|
|
11
|
+
fontFamily: theme.fontMono,
|
|
12
|
+
fontSize: 36,
|
|
13
|
+
color: theme.textDim,
|
|
14
|
+
marginTop: 44,
|
|
15
|
+
opacity: subPop,
|
|
16
|
+
transform: `translateY(${(1 - subPop) * 30}px)`,
|
|
17
|
+
}, children: sub }))] }));
|
|
18
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Severity } from "../theme";
|
|
3
|
+
export type Finding = {
|
|
4
|
+
readonly severity: Severity;
|
|
5
|
+
readonly title: string;
|
|
6
|
+
readonly detail?: string;
|
|
7
|
+
readonly atSecond: number;
|
|
8
|
+
};
|
|
9
|
+
export declare const CalloutOverlay: React.FC<{
|
|
10
|
+
finding: Finding;
|
|
11
|
+
}>;
|