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
|
@@ -0,0 +1,32 @@
|
|
|
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 { Panel, SceneShell } from "./SceneShell";
|
|
5
|
+
export const TerminalScene = ({ kicker, title, command, output, fontSize = 30, outputAtSecond = 0.8, highlightPattern }) => {
|
|
6
|
+
const frame = useCurrentFrame();
|
|
7
|
+
const typedChars = Math.floor(interpolate(frame, [4, outputAtSecond * FPS - 4], [0, command.length], {
|
|
8
|
+
extrapolateLeft: "clamp",
|
|
9
|
+
extrapolateRight: "clamp",
|
|
10
|
+
}));
|
|
11
|
+
const outputLines = output.replace(/\n$/, "").split("\n");
|
|
12
|
+
return (_jsx(SceneShell, { kicker: kicker, title: title, center: true, children: _jsxs(Panel, { padding: 0, children: [_jsxs("div", { style: {
|
|
13
|
+
display: "flex",
|
|
14
|
+
gap: 10,
|
|
15
|
+
padding: "18px 24px",
|
|
16
|
+
borderBottom: `1px solid ${theme.stroke}`,
|
|
17
|
+
alignItems: "center",
|
|
18
|
+
}, children: [[theme.red, theme.amber, theme.green].map((color, i) => (_jsx("span", { style: { width: 18, height: 18, borderRadius: "50%", backgroundColor: color } }, i))), _jsx("span", { style: { fontFamily: theme.fontMono, fontSize: 22, color: theme.textDim, marginLeft: 14 }, children: "terminal" })] }), _jsxs("div", { style: { padding: 36, fontFamily: theme.fontMono, fontSize, lineHeight: 1.6 }, children: [_jsxs("div", { style: { color: theme.text }, children: [_jsx("span", { style: { color: theme.green }, children: "\u276F " }), command.slice(0, typedChars), typedChars < command.length && _jsx("span", { style: { color: theme.accent2 }, children: "\u258C" })] }), _jsx("div", { style: { marginTop: 16 }, children: outputLines.map((line, i) => {
|
|
19
|
+
const at = outputAtSecond * FPS + i * 2;
|
|
20
|
+
const appear = interpolate(frame, [at, at + 5], [0, 1], {
|
|
21
|
+
extrapolateLeft: "clamp",
|
|
22
|
+
extrapolateRight: "clamp",
|
|
23
|
+
});
|
|
24
|
+
const highlighted = highlightPattern?.test(line) ?? false;
|
|
25
|
+
return (_jsx("div", { style: {
|
|
26
|
+
opacity: appear,
|
|
27
|
+
whiteSpace: "pre",
|
|
28
|
+
color: highlighted ? theme.accent2 : theme.text,
|
|
29
|
+
fontWeight: highlighted ? 700 : 400,
|
|
30
|
+
}, children: line.length === 0 ? " " : line }, i));
|
|
31
|
+
}) })] })] }) }));
|
|
32
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { theme } from "../theme";
|
|
4
|
+
import { SceneShell } from "./SceneShell";
|
|
5
|
+
export const TitleCard = ({ chip, words, subtitle }) => {
|
|
6
|
+
const frame = useCurrentFrame();
|
|
7
|
+
const { fps } = useVideoConfig();
|
|
8
|
+
return (_jsx(SceneShell, { center: true, children: _jsxs(AbsoluteFill, { style: { display: "flex", justifyContent: "center", alignItems: "center", flexDirection: "column" }, children: [chip && (_jsx(Pop, { at: 0, children: _jsx("span", { style: {
|
|
9
|
+
fontFamily: theme.fontMono,
|
|
10
|
+
fontSize: 30,
|
|
11
|
+
color: theme.accent2,
|
|
12
|
+
border: `2px solid ${theme.accent2}55`,
|
|
13
|
+
borderRadius: 999,
|
|
14
|
+
padding: "8px 28px",
|
|
15
|
+
marginBottom: 40,
|
|
16
|
+
display: "inline-block",
|
|
17
|
+
}, children: chip }) })), _jsx("div", { style: { display: "flex", gap: 28, flexWrap: "wrap", justifyContent: "center", maxWidth: 1500 }, children: words.map((word, i) => {
|
|
18
|
+
const pop = spring({ frame: frame - 6 - i * 5, fps, config: { damping: 13, stiffness: 160, mass: 0.6 } });
|
|
19
|
+
return (_jsx("span", { style: {
|
|
20
|
+
fontFamily: theme.fontDisplay,
|
|
21
|
+
fontWeight: 800,
|
|
22
|
+
fontSize: 120,
|
|
23
|
+
color: i === words.length - 1 ? theme.accent : theme.text,
|
|
24
|
+
transform: `scale(${pop}) rotate(${(1 - pop) * (i % 2 === 0 ? -4 : 4)}deg)`,
|
|
25
|
+
display: "inline-block",
|
|
26
|
+
}, children: word }, i));
|
|
27
|
+
}) }), subtitle && (_jsx(Pop, { at: 14 + words.length * 5, children: _jsx("div", { style: { fontFamily: theme.fontDisplay, fontSize: 38, color: theme.textDim, marginTop: 36 }, children: subtitle }) }))] }) }));
|
|
28
|
+
};
|
|
29
|
+
export const Pop = ({ at, children }) => {
|
|
30
|
+
const frame = useCurrentFrame();
|
|
31
|
+
const { fps } = useVideoConfig();
|
|
32
|
+
const pop = spring({ frame: frame - at, fps, config: { damping: 14, stiffness: 150, mass: 0.6 } });
|
|
33
|
+
return _jsx("div", { style: { transform: `scale(${pop})`, opacity: Math.min(1, pop * 1.4) }, children: children });
|
|
34
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Severity } from "../theme";
|
|
3
|
+
export type VerdictCount = {
|
|
4
|
+
readonly severity: Severity;
|
|
5
|
+
readonly count: number;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const VerdictCard: React.FC<{
|
|
9
|
+
counts: readonly VerdictCount[];
|
|
10
|
+
stamp: string;
|
|
11
|
+
stampColor?: string;
|
|
12
|
+
stampAtSecond?: number;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Audio, Sequence, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { FPS, severityColor, theme } from "../theme";
|
|
4
|
+
import { SceneShell } from "./SceneShell";
|
|
5
|
+
export const VerdictCard = ({ counts, stamp, stampColor = theme.green, stampAtSecond = 1.6 }) => {
|
|
6
|
+
const frame = useCurrentFrame();
|
|
7
|
+
const { fps } = useVideoConfig();
|
|
8
|
+
const stampFrame = stampAtSecond * FPS;
|
|
9
|
+
const stampPop = spring({ frame: frame - stampFrame, fps, config: { damping: 11, stiffness: 230, mass: 0.8 } });
|
|
10
|
+
return (_jsxs(SceneShell, { kicker: "the verdict", center: true, children: [_jsx("div", { style: { display: "flex", gap: 40, marginBottom: 80 }, children: counts.map((entry, i) => {
|
|
11
|
+
const pop = spring({ frame: frame - 6 - i * 7, fps, config: { damping: 14, stiffness: 170, mass: 0.6 } });
|
|
12
|
+
const color = severityColor[entry.severity];
|
|
13
|
+
return (_jsxs("div", { style: {
|
|
14
|
+
width: 330,
|
|
15
|
+
backgroundColor: theme.bgPanel,
|
|
16
|
+
border: `2px solid ${color}55`,
|
|
17
|
+
borderTop: `8px solid ${color}`,
|
|
18
|
+
borderRadius: 18,
|
|
19
|
+
padding: "34px 30px",
|
|
20
|
+
textAlign: "center",
|
|
21
|
+
transform: `scale(${pop})`,
|
|
22
|
+
opacity: pop,
|
|
23
|
+
}, children: [_jsx("div", { style: { fontFamily: theme.fontDisplay, fontWeight: 800, fontSize: 96, color }, children: entry.count }), _jsx("div", { style: {
|
|
24
|
+
fontFamily: theme.fontDisplay,
|
|
25
|
+
fontWeight: 600,
|
|
26
|
+
fontSize: 28,
|
|
27
|
+
letterSpacing: 2,
|
|
28
|
+
color: theme.textDim,
|
|
29
|
+
textTransform: "uppercase",
|
|
30
|
+
}, children: entry.label })] }, i));
|
|
31
|
+
}) }), frame >= stampFrame && (_jsx(Sequence, { from: Math.round(stampFrame), name: "stamp-thud", children: _jsx(Audio, { src: staticFile("sfx/thud.wav"), volume: 0.6 }) })), _jsx("div", { style: {
|
|
32
|
+
fontFamily: theme.fontDisplay,
|
|
33
|
+
fontWeight: 900,
|
|
34
|
+
fontSize: 110,
|
|
35
|
+
letterSpacing: 6,
|
|
36
|
+
color: stampColor,
|
|
37
|
+
border: `10px solid ${stampColor}`,
|
|
38
|
+
borderRadius: 24,
|
|
39
|
+
padding: "10px 60px",
|
|
40
|
+
transform: `rotate(-7deg) scale(${0.6 + stampPop * 0.4})`,
|
|
41
|
+
opacity: stampPop,
|
|
42
|
+
textShadow: `0 0 60px ${stampColor}66`,
|
|
43
|
+
}, children: stamp })] }));
|
|
44
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const FPS = 30;
|
|
2
|
+
export declare const theme: {
|
|
3
|
+
readonly bg: "#0b0d12";
|
|
4
|
+
readonly bgPanel: "#11141d";
|
|
5
|
+
readonly bgCode: "#0d1017";
|
|
6
|
+
readonly stroke: "#252b3d";
|
|
7
|
+
readonly text: "#e9edf5";
|
|
8
|
+
readonly textDim: "#8b94aa";
|
|
9
|
+
readonly accent: "#8b5cf6";
|
|
10
|
+
readonly accent2: "#22d3ee";
|
|
11
|
+
readonly green: "#34d399";
|
|
12
|
+
readonly red: "#fb7185";
|
|
13
|
+
readonly amber: "#fbbf24";
|
|
14
|
+
readonly grey: "#7a8194";
|
|
15
|
+
readonly fontDisplay: "'Avenir Next', 'Helvetica Neue', Helvetica, Arial, sans-serif";
|
|
16
|
+
readonly fontMono: "'SF Mono', Menlo, 'Cascadia Code', Consolas, monospace";
|
|
17
|
+
};
|
|
18
|
+
export type Severity = "blocking" | "warn" | "nit";
|
|
19
|
+
export declare const severityColor: Record<Severity, string>;
|
|
20
|
+
export declare const severityLabel: Record<Severity, string>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const FPS = 30;
|
|
2
|
+
export const theme = {
|
|
3
|
+
bg: "#0b0d12",
|
|
4
|
+
bgPanel: "#11141d",
|
|
5
|
+
bgCode: "#0d1017",
|
|
6
|
+
stroke: "#252b3d",
|
|
7
|
+
text: "#e9edf5",
|
|
8
|
+
textDim: "#8b94aa",
|
|
9
|
+
accent: "#8b5cf6",
|
|
10
|
+
accent2: "#22d3ee",
|
|
11
|
+
green: "#34d399",
|
|
12
|
+
red: "#fb7185",
|
|
13
|
+
amber: "#fbbf24",
|
|
14
|
+
grey: "#7a8194",
|
|
15
|
+
fontDisplay: "'Avenir Next', 'Helvetica Neue', Helvetica, Arial, sans-serif",
|
|
16
|
+
fontMono: "'SF Mono', Menlo, 'Cascadia Code', Consolas, monospace",
|
|
17
|
+
};
|
|
18
|
+
export const severityColor = {
|
|
19
|
+
blocking: theme.red,
|
|
20
|
+
warn: theme.amber,
|
|
21
|
+
nit: theme.grey,
|
|
22
|
+
};
|
|
23
|
+
export const severityLabel = {
|
|
24
|
+
blocking: "BLOCKING",
|
|
25
|
+
warn: "WORTH A LOOK",
|
|
26
|
+
nit: "NIT",
|
|
27
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export type SceneSpec = {
|
|
3
|
+
id: string;
|
|
4
|
+
element: React.ReactNode;
|
|
5
|
+
extraSeconds?: number;
|
|
6
|
+
fixedSeconds?: number;
|
|
7
|
+
sfx?: string;
|
|
8
|
+
sfxVolume?: number;
|
|
9
|
+
};
|
|
10
|
+
export type NarrationManifest = {
|
|
11
|
+
voice: string;
|
|
12
|
+
scenes: Record<string, {
|
|
13
|
+
seconds: number;
|
|
14
|
+
text: string;
|
|
15
|
+
}>;
|
|
16
|
+
};
|
|
17
|
+
export declare function sceneDurationInFrames(spec: SceneSpec, manifest: NarrationManifest): number;
|
|
18
|
+
export declare function totalDurationInFrames(specs: readonly SceneSpec[], manifest: NarrationManifest): number;
|
|
19
|
+
export declare const Timeline: React.FC<{
|
|
20
|
+
episodeId: string;
|
|
21
|
+
scenes: readonly SceneSpec[];
|
|
22
|
+
manifest: NarrationManifest;
|
|
23
|
+
music?: string;
|
|
24
|
+
musicVolume?: number;
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { AbsoluteFill, Audio, Sequence, staticFile } from "remotion";
|
|
3
|
+
import { FPS, theme } from "./theme";
|
|
4
|
+
const NARRATION_START_PAD_SECONDS = 0.25;
|
|
5
|
+
const DEFAULT_TAIL_SECONDS = 0.5;
|
|
6
|
+
export function sceneDurationInFrames(spec, manifest) {
|
|
7
|
+
if (spec.fixedSeconds !== undefined) {
|
|
8
|
+
return Math.round(spec.fixedSeconds * FPS);
|
|
9
|
+
}
|
|
10
|
+
const narration = manifest.scenes[spec.id]?.seconds ?? 1.5;
|
|
11
|
+
const total = NARRATION_START_PAD_SECONDS + narration + (spec.extraSeconds ?? DEFAULT_TAIL_SECONDS);
|
|
12
|
+
return Math.round(total * FPS);
|
|
13
|
+
}
|
|
14
|
+
export function totalDurationInFrames(specs, manifest) {
|
|
15
|
+
return specs.reduce((sum, spec) => sum + sceneDurationInFrames(spec, manifest), 0);
|
|
16
|
+
}
|
|
17
|
+
export const Timeline = ({ episodeId, scenes, manifest, music, musicVolume }) => {
|
|
18
|
+
let cursor = 0;
|
|
19
|
+
const sequences = scenes.map((spec) => {
|
|
20
|
+
const duration = sceneDurationInFrames(spec, manifest);
|
|
21
|
+
const from = cursor;
|
|
22
|
+
cursor += duration;
|
|
23
|
+
const hasNarration = manifest.scenes[spec.id] !== undefined && spec.fixedSeconds === undefined;
|
|
24
|
+
return (_jsxs(Sequence, { from: from, durationInFrames: duration, name: spec.id, children: [hasNarration && (_jsx(Sequence, { from: Math.round(NARRATION_START_PAD_SECONDS * FPS), name: `${spec.id}-narration`, children: _jsx(Audio, { src: staticFile(`audio/${episodeId}/${spec.id}.wav`) }) })), spec.sfx && _jsx(Audio, { src: staticFile(`sfx/${spec.sfx}`), volume: spec.sfxVolume ?? 0.4 }), spec.element] }, spec.id));
|
|
25
|
+
});
|
|
26
|
+
return (_jsxs(AbsoluteFill, { style: { backgroundColor: theme.bg }, children: [music && _jsx(Audio, { loop: true, src: staticFile(`music/${music}`), volume: musicVolume ?? 0.13 }), sequences] }));
|
|
27
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gitreel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Turn your pull requests into movie trailers — narrated, fast-paced, agent-authored PR review videos.",
|
|
5
|
+
"keywords": ["pull-request", "code-review", "video", "remotion", "tts", "agent", "skill"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"bin": {
|
|
9
|
+
"gitreel": "dist/cli/index.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
"./engine": {
|
|
13
|
+
"types": "./dist/engine/index.d.ts",
|
|
14
|
+
"default": "./dist/engine/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": ["dist", "assets/music", "assets/sfx", "templates", "skills", "README.md", "LICENSE"],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@remotion/bundler": "^4.0.0",
|
|
28
|
+
"@remotion/cli": "^4.0.0",
|
|
29
|
+
"@remotion/renderer": "^4.0.0",
|
|
30
|
+
"commander": "^12.1.0",
|
|
31
|
+
"diff": "^7.0.0",
|
|
32
|
+
"kokoro-js": "^1.2.0",
|
|
33
|
+
"react": "^18.3.1",
|
|
34
|
+
"react-dom": "^18.3.1",
|
|
35
|
+
"remotion": "^4.0.0",
|
|
36
|
+
"shiki": "^1.29.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/diff": "^6.0.0",
|
|
40
|
+
"@types/node": "^22.0.0",
|
|
41
|
+
"@types/react": "^18.3.0",
|
|
42
|
+
"typescript": "^5.7.0"
|
|
43
|
+
},
|
|
44
|
+
"pnpm": {
|
|
45
|
+
"onlyBuiltDependencies": ["esbuild", "onnxruntime-node", "protobufjs", "sharp"]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gitreel
|
|
3
|
+
description: Turn a GitHub pull request into a narrated, fast-paced movie-trailer-style review video. Use when the user asks for a PR video, a video walkthrough/review of a PR, or invokes /gitreel. Args: PR number or URL (required), mode `short` (default, ~3-5 min) or `long` (~6-10 min), optional path to a review .md whose findings get woven into the video, optional --draft for a fast low-res preview.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# gitreel
|
|
7
|
+
|
|
8
|
+
You produce a narrated video that reviews a GitHub PR — fast-paced, funny, visually clean
|
|
9
|
+
(Fireship-style). The `gitreel` CLI is your instrument: it owns the render engine, TTS, and
|
|
10
|
+
workspace. You are the writer-director: you read the PR, write the screenplay (narration +
|
|
11
|
+
scene composition), and drive the CLI.
|
|
12
|
+
|
|
13
|
+
## Step 0 — ensure the engine
|
|
14
|
+
|
|
15
|
+
Run `gitreel --version`. If missing, install it: `npm i -g gitreel`, then run `gitreel doctor`
|
|
16
|
+
and resolve anything it flags (it checks node ≥ 20, the `gh` CLI auth, the render browser).
|
|
17
|
+
`gitreel where` prints the workspace (default `~/gitreel`, override via `GITREEL_HOME`).
|
|
18
|
+
|
|
19
|
+
## Pipeline
|
|
20
|
+
|
|
21
|
+
1. **Gather**: `gh pr view <n> --json title,body,commits,files` and `gh pr diff <n>` (add
|
|
22
|
+
`--repo owner/name` for PRs outside the cwd). Read the PR body and the commit sequence —
|
|
23
|
+
the video narrates the change in logical order (commit order is usually right).
|
|
24
|
+
2. **Scaffold**: `gitreel new <episode-id>` (use `<repo>-<number>`, e.g. `fastify-5821`). This
|
|
25
|
+
creates `episodes/<id>/episode.tsx` + `narration.json` in the workspace.
|
|
26
|
+
3. **Write the screenplay** (narration.json + episode.tsx). Structure is non-negotiable,
|
|
27
|
+
high level FIRST:
|
|
28
|
+
- (a) **title card**; (b) **goal scene** — what the PR is for, in plain words, before any code;
|
|
29
|
+
(c) **architecture diagram** (`DiagramScene`) — pre-existing parts dimmed, narration says
|
|
30
|
+
"none of this is new"; (d) **files overview** (`FileTreeScene`) — every changed file grouped
|
|
31
|
+
by area, narration walks the groups via spotlights; (e) **problem statement** — why this
|
|
32
|
+
change is needed/hard; (f) **roadmap** (`MontageBeat` with per-item `atSecond`) — the numbered
|
|
33
|
+
steps the detail half will follow; (g) **detail scenes**, kickers numbered
|
|
34
|
+
"step N of M · <area>"; (h) **review beats**; (i) **verdict card**.
|
|
35
|
+
- Short mode = 3–5 load-bearing changes close-up; long mode = every commit gets a beat.
|
|
36
|
+
4. **Narrate**: `gitreel narrate <id>`. Caches per-scene by text; `--force` regenerates all.
|
|
37
|
+
5. **Time the beats**: read `<workspace>/public/audio/<id>/manifest.json` for each scene's
|
|
38
|
+
measured seconds, then set every `atSecond` (spotlights, diagram reveals, morphs) at
|
|
39
|
+
word-fraction × measured duration. Never guess timings from word counts alone.
|
|
40
|
+
6. **Verify with stills BEFORE the full render**: `gitreel still <id> --frame <n>` at each scene's
|
|
41
|
+
midpoint (compute frame offsets: scenes run sequentially, each 0.25s lead + narration seconds
|
|
42
|
+
+ 0.5s tail, at 30fps). Look for: clipped titles (drop the scene title or shrink fontSize),
|
|
43
|
+
overflowing panels, spotlights on wrong lines, arrows not landing on their boxes.
|
|
44
|
+
7. **Render**: `gitreel render <id>` (`--draft` for fast low-res iteration), then open the
|
|
45
|
+
printed MP4 path for the user.
|
|
46
|
+
|
|
47
|
+
## Episode contract
|
|
48
|
+
|
|
49
|
+
`episode.tsx` default-exports an `EpisodeDefinition` from `gitreel/engine`:
|
|
50
|
+
`{ id, music: "drive.wav", musicVolume: 0.11, scenes: SceneSpec[] }`. Each `SceneSpec` is
|
|
51
|
+
`{ id, element, sfx?, sfxVolume?, extraSeconds?, fixedSeconds? }` — scene ids must match
|
|
52
|
+
narration.json scene ids; narration audio duration drives scene duration.
|
|
53
|
+
|
|
54
|
+
Scene primitives (all from `gitreel/engine`): `TitleCard`, `BigText`, `CodeScene` (timed
|
|
55
|
+
`spotlights`, optional `tree` breadcrumb + `finding` callout), `DiffMorph` (before→after morph at
|
|
56
|
+
`morphAtSecond`), `DiagramScene` (nodes/arrows with timed reveals — ALWAYS set explicit
|
|
57
|
+
`fromSide`/`toSide` on arrows), `FileTreeScene` (grouped files, per-group spotlights),
|
|
58
|
+
`TerminalScene` (typed command + revealed output), `MontageBeat`, `VerdictCard`, `CalloutOverlay`,
|
|
59
|
+
plus `SceneShell`/`Panel`/`FileChip` for bespoke scenes. SFX in the workspace: `whoosh.wav`,
|
|
60
|
+
`pop.wav`, `sting.wav`, `thud.wav`, `tick.wav`, `success.wav`.
|
|
61
|
+
|
|
62
|
+
## Craft rules (each one earned from viewer feedback — do not skip)
|
|
63
|
+
|
|
64
|
+
- **Length is not a constraint.** Viewers can 2x. Cutting context to save seconds is the cardinal
|
|
65
|
+
sin; never let a video feel like it assumes knowledge it didn't establish.
|
|
66
|
+
- **Every detail scene grounds itself in its first sentence** — whose code this is, who calls it,
|
|
67
|
+
or what came before ("this is the class the CLI calls", "remember the Router from earlier?").
|
|
68
|
+
A scene that opens cold on new code is a bug.
|
|
69
|
+
- **Introduce every example before its payoff.** Any class/fixture appearing in a terminal-output
|
|
70
|
+
scene needs its own intro scene (code + tree breadcrumb) first.
|
|
71
|
+
- **Never show a file the viewer can't place** — unfamiliar paths get the `tree` breadcrumb.
|
|
72
|
+
- **Introduce fixture/demo code as fake**: narration says it's a fake project that exists for
|
|
73
|
+
tests; badge it "fake demo code" (amber). Never present it as the PR's production code.
|
|
74
|
+
- **Existing vs new, everywhere**: pre-existing code/architecture renders dimmed + tagged; new
|
|
75
|
+
code full-brightness. Never present old code as new.
|
|
76
|
+
- **Narration style**: punchy, dry-to-roasty humor, short sentences, no filler. Spell for speech
|
|
77
|
+
("T-S morph", "PR fifty eight twenty one"). ~150 spoken words ≈ 1 minute. Voice `am_michael`,
|
|
78
|
+
speed 1.1.
|
|
79
|
+
- **Review findings**: if the user supplied a review .md, extract findings and weave each into the
|
|
80
|
+
scene showing that code via the `finding` prop (severity: blocking=red / warn=amber / nit=grey).
|
|
81
|
+
No review file → do your own light pass over the diff. Always end with `VerdictCard`
|
|
82
|
+
(counts + stamp).
|
|
83
|
+
- Code snippets: abridge to what the narration discusses (≤ ~20 lines per scene), real code from
|
|
84
|
+
the diff, never pseudo-code presented as real.
|
|
85
|
+
- New scene-type ideas are welcome — build bespoke scenes from `SceneShell`/`Panel` primitives in
|
|
86
|
+
the episode file; they don't need to live in the engine.
|
|
87
|
+
|
|
88
|
+
## Gotchas (each one cost a render — respect them)
|
|
89
|
+
|
|
90
|
+
- The bundler does NOT type-check episode.tsx; type errors surface only as runtime crashes during
|
|
91
|
+
still/render. Render one cheap still immediately after writing the episode to catch them early.
|
|
92
|
+
- `spotlights[].lines` ranges are TUPLES: `[[4, 9]]`, never `{ from: 4, to: 9 }`.
|
|
93
|
+
- Diagram arrow labels draw UNDER node boxes — keep them short and away from nodes.
|
|
94
|
+
- All `atSecond` timings are scene-local; narration starts after a 0.25s lead pad, so add 0.25 to
|
|
95
|
+
every word-fraction timing.
|
|
96
|
+
- `DiagramScene` nodes: `kind: "new"` shows a NEW badge automatically; `kind: "focus"` is for
|
|
97
|
+
spotlighting pre-existing code and shows a badge only if you set `badge: "..."` — never present
|
|
98
|
+
existing code as new.
|
|
99
|
+
- `React` must be imported in episode.tsx.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { BigText, TitleCard, type EpisodeDefinition, type SceneSpec } from "gitreel/engine";
|
|
3
|
+
|
|
4
|
+
const scenes: SceneSpec[] = [
|
|
5
|
+
{
|
|
6
|
+
id: "cold-open",
|
|
7
|
+
sfx: "whoosh.wav",
|
|
8
|
+
element: (
|
|
9
|
+
<TitleCard chip="your repo · PR #0" words={["hello", "gitreel"]} subtitle="a freshly scaffolded episode" />
|
|
10
|
+
),
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: "goal",
|
|
14
|
+
sfx: "pop.wav",
|
|
15
|
+
element: (
|
|
16
|
+
<BigText
|
|
17
|
+
emoji="🎬"
|
|
18
|
+
text="replace these scenes with your story"
|
|
19
|
+
sub="import scene primitives from gitreel/engine"
|
|
20
|
+
/>
|
|
21
|
+
),
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const episode: EpisodeDefinition = {
|
|
26
|
+
id: "EPISODE_ID",
|
|
27
|
+
music: "drive.wav",
|
|
28
|
+
musicVolume: 0.11,
|
|
29
|
+
scenes,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default episode;
|