Skip to content

Commit ad7f408

Browse files
committed
feat: add repeatable blog media capture pipeline
1 parent 5417069 commit ad7f408

12 files changed

Lines changed: 399 additions & 0 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ cms-upload-*/
88
git-cms-test-*/
99
.obsidian/
1010
STUNT-BLOG.md
11+
docs/media/generated/
1112

1213
# LaTeX artifacts
1314
docs/adr-tex-2/*.aux

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ git config remote.stargate.push "+refs/_blog/*:refs/_blog/*"
200200
## Where To Go Next
201201

202202
- **Blog companion / runnable appendix:** [docs/GIT_CMS_COMPANION.md](./docs/GIT_CMS_COMPANION.md)
203+
- **Media capture pipeline:** [docs/media/README.md](./docs/media/README.md)
203204
- **Reader walkthrough:** [docs/GETTING_STARTED.md](./docs/GETTING_STARTED.md)
204205
- **Command and API reference:** [QUICK_REFERENCE.md](./QUICK_REFERENCE.md)
205206
- **Testing and safety details:** [TESTING_GUIDE.md](./TESTING_GUIDE.md)

docs/media/README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Media Capture
2+
3+
Reproducible article media lives here.
4+
5+
Generated assets are written to `docs/media/generated/` and are intentionally gitignored. The goal is to make blog visuals repeatable without bloating the repo.
6+
7+
## `git-cms` browser footage
8+
9+
This repo ships a dedicated Playwright capture path for the seeded sandbox walkthrough.
10+
11+
Run it from the repo root:
12+
13+
```bash
14+
npm run capture:cms
15+
```
16+
17+
What it does:
18+
19+
- starts a fresh isolated media sandbox on port `47639`
20+
- creates a dedicated draft-only `restore-demo` article inside that sandbox
21+
- opens version history
22+
- previews an older version
23+
- restores it
24+
- writes a poster screenshot and browser video into `docs/media/generated/git-cms/`
25+
26+
Output files:
27+
28+
- `docs/media/generated/git-cms/git-cms-walkthrough.webm`
29+
- `docs/media/generated/git-cms/git-cms-poster.png`
30+
31+
Implementation:
32+
33+
- [playwright.media.config.js](../../playwright.media.config.js)
34+
- [test/media/git-cms.capture.spec.js](../../test/media/git-cms.capture.spec.js)
35+
- [scripts/capture-cms-footage.mjs](../../scripts/capture-cms-footage.mjs)
36+
- [scripts/start-media-sandbox.sh](../../scripts/start-media-sandbox.sh)
37+
38+
## `git-cas` terminal capture
39+
40+
This repo also carries a VHS starter for the sibling `git-cas` project so the series can produce consistent terminal-native motion assets.
41+
42+
Run it from this repo root:
43+
44+
```bash
45+
npm run capture:git-cas:vhs
46+
```
47+
48+
Defaults:
49+
50+
- expects the `git-cas` source repo at `$HOME/git/git-stunts/git-cas`
51+
- creates a temporary throwaway repo at `/tmp/git-cas-vhs-demo`
52+
- renders a GIF into `docs/media/generated/git-cas/`
53+
54+
Override the source repo path if needed:
55+
56+
```bash
57+
GIT_CAS_REPO=/absolute/path/to/git-cas npm run capture:git-cas:vhs
58+
```
59+
60+
Output file:
61+
62+
- `docs/media/generated/git-cas/git-cas-inspect.gif`
63+
64+
Implementation:
65+
66+
- [vhs/git-cas-inspect.tape](../../vhs/git-cas-inspect.tape)
67+
- [scripts/render-git-cas-vhs.sh](../../scripts/render-git-cas-vhs.sh)
68+
69+
## Notes
70+
71+
- The `git-cms` capture is meant for article-quality browser footage, not test coverage.
72+
- The filmed restore flow uses a draft-only article because restoring a still-published article is intentionally blocked by the product.
73+
- The VHS tape focuses on the `git-cas` inspect flow because it is concise, deterministic, and terminal-native.
74+
- If you want MP4 transcoding or caption burn-ins later, add that as a second pass. The canonical raw outputs here are WebM for browser footage and GIF for VHS.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"demo": "./scripts/demo.sh",
2020
"quickstart": "./scripts/quickstart.sh",
2121
"setup": "./scripts/setup.sh",
22+
"capture:cms": "node ./scripts/capture-cms-footage.mjs",
23+
"capture:git-cas:vhs": "./scripts/render-git-cas-vhs.sh",
2224
"check:deps": "node scripts/check-dependency-integrity.mjs",
2325
"check:docs": "./scripts/check-doc-drift.sh",
2426
"test": "./test/run-docker.sh",

playwright.media.config.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// @ts-check
2+
import { defineConfig, devices } from '@playwright/test';
3+
4+
const mediaPort = Number(process.env.MEDIA_PORT || 47639);
5+
6+
export default defineConfig({
7+
testDir: './test/media',
8+
outputDir: './test-results/media',
9+
fullyParallel: false,
10+
forbidOnly: !!process.env.CI,
11+
retries: 0,
12+
workers: 1,
13+
reporter: 'list',
14+
use: {
15+
baseURL: `http://127.0.0.1:${mediaPort}`,
16+
trace: 'off',
17+
screenshot: 'off',
18+
video: 'on',
19+
colorScheme: 'light',
20+
viewport: { width: 1440, height: 960 },
21+
launchOptions: {
22+
slowMo: process.env.CI ? 0 : 125,
23+
},
24+
},
25+
webServer: {
26+
command: `MEDIA_PORT=${mediaPort} ./scripts/start-media-sandbox.sh`,
27+
port: mediaPort,
28+
timeout: 120_000,
29+
reuseExistingServer: !!process.env.MEDIA_REUSE_EXISTING,
30+
stdout: 'pipe',
31+
stderr: 'pipe',
32+
},
33+
projects: [
34+
{
35+
name: 'chromium',
36+
use: { ...devices['Desktop Chrome'] },
37+
},
38+
],
39+
});

scripts/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,33 @@ These scripts power the long-lived reader sandbox.
4949
- `prepare-playground.sh` initializes a repo, configures Git identity, and seeds `hello-world` history when the playground repo is empty.
5050
- `start-playground.sh` prepares the repo and then starts the HTTP server against it.
5151

52+
### `capture-cms-footage.mjs` / `start-media-sandbox.sh`
53+
54+
These scripts power repeatable browser footage for the blog post.
55+
56+
- `capture-cms-footage.mjs` runs a dedicated Playwright capture flow against a fresh media sandbox.
57+
- `start-media-sandbox.sh` starts an isolated seeded sandbox on a dedicated port and tears it down afterward.
58+
59+
Run:
60+
61+
```bash
62+
npm run capture:cms
63+
```
64+
65+
Outputs land in `docs/media/generated/git-cms/`.
66+
67+
### `render-git-cas-vhs.sh`
68+
69+
Renders the `git-cas` terminal GIF used in the broader `Git Stunts` series media pipeline.
70+
71+
Run:
72+
73+
```bash
74+
npm run capture:git-cas:vhs
75+
```
76+
77+
See: [docs/media/README.md](../docs/media/README.md)
78+
5279
### `bootstrap-stargate.sh` (Advanced: Git Gateway)
5380

5481
Creates a local "Stargate" gateway repository with Git hooks that enforce:

scripts/capture-cms-footage.mjs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
2+
import path from 'node:path';
3+
import { spawnSync } from 'node:child_process';
4+
import net from 'node:net';
5+
import { fileURLToPath } from 'node:url';
6+
7+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
8+
const root = path.resolve(__dirname, '..');
9+
const outDir = path.resolve(root, process.env.MEDIA_OUT_DIR || 'docs/media/generated/git-cms');
10+
const resultsDir = path.resolve(root, 'test-results/media');
11+
const mediaProject = process.env.MEDIA_PROJECT || 'git-cms-media';
12+
async function findOpenPort(start = 47639) {
13+
for (let port = start; port < start + 50; port += 1) {
14+
const isOpen = await new Promise((resolve) => {
15+
const server = net.createServer();
16+
server.once('error', () => resolve(false));
17+
server.once('listening', () => {
18+
server.close(() => resolve(true));
19+
});
20+
server.listen(port, '127.0.0.1');
21+
});
22+
if (isOpen) return String(port);
23+
}
24+
throw new Error(`Could not find an open media port starting at ${start}`);
25+
}
26+
27+
const mediaPort = process.env.MEDIA_PORT || await findOpenPort();
28+
29+
function walk(dir) {
30+
const entries = readdirSync(dir, { withFileTypes: true });
31+
for (const entry of entries) {
32+
const full = path.join(dir, entry.name);
33+
if (entry.isDirectory()) {
34+
const nested = walk(full);
35+
if (nested) return nested;
36+
continue;
37+
}
38+
if (entry.isFile() && entry.name === 'video.webm') {
39+
return full;
40+
}
41+
}
42+
return null;
43+
}
44+
45+
rmSync(resultsDir, { recursive: true, force: true });
46+
mkdirSync(outDir, { recursive: true });
47+
48+
spawnSync('docker', ['compose', '-p', mediaProject, 'down', '-v', '--remove-orphans'], {
49+
cwd: root,
50+
stdio: 'ignore',
51+
});
52+
53+
const env = {
54+
...process.env,
55+
MEDIA_OUT_DIR: outDir,
56+
MEDIA_PORT: mediaPort,
57+
MEDIA_PROJECT: mediaProject,
58+
};
59+
delete env.NO_COLOR;
60+
61+
const args = [
62+
path.join(root, 'node_modules/@playwright/test/cli.js'),
63+
'test',
64+
'--config',
65+
'playwright.media.config.js',
66+
];
67+
68+
const result = spawnSync(process.execPath, args, {
69+
cwd: root,
70+
env,
71+
stdio: 'inherit',
72+
});
73+
74+
if (result.status !== 0) {
75+
process.exit(result.status ?? 1);
76+
}
77+
78+
if (!existsSync(resultsDir)) {
79+
console.error(`Expected Playwright output directory at ${resultsDir}`);
80+
process.exit(1);
81+
}
82+
83+
const videoPath = walk(resultsDir);
84+
if (!videoPath) {
85+
console.error(`Could not find recorded video under ${resultsDir}`);
86+
process.exit(1);
87+
}
88+
89+
const finalVideoPath = path.join(outDir, 'git-cms-walkthrough.webm');
90+
copyFileSync(videoPath, finalVideoPath);
91+
92+
console.log(`Saved browser footage to ${finalVideoPath}`);
93+
console.log(`Saved poster image to ${path.join(outDir, 'git-cms-poster.png')}`);

scripts/render-git-cas-vhs.sh

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5+
GIT_CAS_REPO="${GIT_CAS_REPO:-$HOME/git/git-stunts/git-cas}"
6+
MEDIA_REPO="${GIT_CAS_MEDIA_REPO:-/tmp/git-cas-vhs-demo}"
7+
OUT_DIR="$ROOT/docs/media/generated/git-cas"
8+
TAPE="$ROOT/vhs/git-cas-inspect.tape"
9+
TMP_DIR="$(mktemp -d /tmp/git-cas-vhs-XXXXXX)"
10+
TMP_TAPE="$TMP_DIR/tape.tape"
11+
OUT_FILE="$OUT_DIR/git-cas-inspect.gif"
12+
13+
cleanup() {
14+
rm -rf "$TMP_DIR"
15+
}
16+
17+
trap cleanup EXIT
18+
19+
if ! command -v vhs > /dev/null 2>&1; then
20+
echo "❌ VHS is not installed. Install https://github.com/charmbracelet/vhs first."
21+
exit 1
22+
fi
23+
24+
if [ ! -f "$GIT_CAS_REPO/package.json" ]; then
25+
echo "❌ Could not find git-cas repo at $GIT_CAS_REPO"
26+
echo " Set GIT_CAS_REPO=/absolute/path/to/git-cas and try again."
27+
exit 1
28+
fi
29+
30+
mkdir -p "$OUT_DIR"
31+
rm -rf "$MEDIA_REPO"
32+
mkdir -p "$MEDIA_REPO"
33+
git init -q "$MEDIA_REPO"
34+
git -C "$MEDIA_REPO" config user.name "VHS Bot"
35+
git -C "$MEDIA_REPO" config user.email "vhs@example.com"
36+
printf 'hello\nhello\nhello\nhello\n' > "$MEDIA_REPO/repetitive.txt"
37+
38+
(
39+
cd "$GIT_CAS_REPO"
40+
node bin/git-cas.js store "$MEDIA_REPO/repetitive.txt" --slug demo/hello --cwd "$MEDIA_REPO" --tree > /dev/null
41+
)
42+
43+
(
44+
cd "$ROOT"
45+
sed \
46+
-e "s|__GIT_CAS_OUTPUT__|$OUT_FILE|g" \
47+
-e "s|__GIT_CAS_REPO__|$GIT_CAS_REPO|g" \
48+
-e "s|__GIT_CAS_MEDIA_REPO__|$MEDIA_REPO|g" \
49+
"$TAPE" > "$TMP_TAPE"
50+
vhs "$TMP_TAPE"
51+
)
52+
53+
echo "Saved VHS capture to $OUT_DIR/git-cas-inspect.gif"

scripts/start-media-sandbox.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5+
PROJECT="${MEDIA_PROJECT:-git-cms-media}"
6+
PORT="${MEDIA_PORT:-47639}"
7+
8+
compose() {
9+
PLAYGROUND_PORT="$PORT" docker compose -p "$PROJECT" "$@"
10+
}
11+
12+
cleanup() {
13+
compose down -v --remove-orphans > /dev/null 2>&1 || true
14+
}
15+
16+
trap cleanup EXIT INT TERM
17+
18+
cd "$ROOT"
19+
unset NO_COLOR
20+
compose down -v --remove-orphans > /dev/null 2>&1 || true
21+
exec env PLAYGROUND_PORT="$PORT" docker compose -p "$PROJECT" up --build playground

0 commit comments

Comments
 (0)