Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ src/
promo.js → renderPromoTile (HTML template → image)
describe.js → extractListing / renderDescriptionDoc (STORE_LISTING.md → copy)
presets.js → PRESETS / resolveSize (CWS + SNS sizes)
video.js → demo post-processing: webm→H.264 mp4 + trim (real ffmpeg required)
cli.js → CLI arg parsing + config resolution (unit-tested)
index.js → public API (the contract — don't break exports)
bin/shotkit.js → CLI (thin wrapper over capture(); --json agent contract)
Expand Down
4 changes: 2 additions & 2 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
## 상태와 범위 (Status & Scope)

- **현재 구현된 것** — Playwright 캡처 **엔진**(빌드 → `--load-extension`으로 *빌드된* 익스텐션 로드 → scene 구동 → 스크린샷 → 캡션/면책 밴드 → HTML 프로모 타일 → 데모 `webm` → `STORE_LISTING.md`에서 문안 추출), **에이전트 계약**을 갖춘 **CLI**(`shotkit` — `--json` 머신 출력, 선택적 `path` 인자, `0/1/2` 종료 코드), 양쪽 용도 **사이즈 프리셋**(CWS `1280×800`/`440×280`, SNS `1200×675`/`1200×630`/`1080×1080`), **path-traversal 안전** 로컬 픽스처 서버, 프로그램 API(`capture()`), **Claude Code skill**([`skills/capture/`](skills/capture/SKILL.md)), 셸을 가진 어떤 코딩 에이전트든 호출법을 읽을 수 있는 **AGENTS.md 실행 블록**, 그리고 **npm 패키지** [`@starter-series/shotkit`](https://www.npmjs.com/package/@starter-series/shotkit). `browser-extension-starter`·`skillBridge`가 소비.
- **계획된 것** — **동영상 편집**(`webm → mp4`, 트림, 캡션). (capture-in-CI GitHub Action·마켓플레이스 등재는 ✅.)
- **계획된 것** — 데모 클립 캡션 오버레이. (동영상 mp4 변환·트림은 ✅ — `--mp4` 또는 config의 `demo.mp4`/`demo.trim`; 진짜 ffmpeg 필요(`SHOTKIT_FFMPEG`/PATH, GitHub ubuntu 러너 기본 탑재). Playwright 동봉 ffmpeg는 vp8 전용이라 불가.)
- **설계 의도** — *엔진 1개, 표면 여러 개 — 단, 도구 성격에 맞는 표면.* shotkit은 무겁고 파일을 산출하는 빌드 도구라 표면이 CLI(+`--json`)·skill·CI입니다 — MCP가 아니라(하지 않기로 한 것 참고). 캡처는 **결정적**(로그인 불필요 픽스처, freeze된 데이터)이고, 실행이 **실제 빌드본 smoke test를 겸함** — 스크린샷이 나온다 = 그 기능이 출하 코드에서 렌더됨. 모든 샷에 면책 밴드를 합성해 **상표 안전**.
- **하지 않기로 한 것** — **MCP 서버**(의도적으로 폐기: 셸이 있는 에이전트에는 `--json` + skill이 세션당 컨텍스트 비용 없이 더 나은 계약이며, 여기엔 빠른 구조화 질의가 없음). repo별 **scene 설정** 제거(어떤 화면이 *당신의* money shot인지는 환원 불가한 의도 — `shotkit.config.js`에 둠). 범용 동영상 편집기(v1은 깔끔한 녹화만; 편집은 계획). 호스티드 서비스(파일을 만지는 캡처는 본질적으로 로컬).
- **공개하지 않음** — 없음.
Expand Down Expand Up @@ -65,7 +65,7 @@ skill(Agent Skills 표준 — 호환 도구의 skills 디렉터리에 폴더째

## 로드맵 — 엔진 1개, 표면 여러 개

CLI `--json`+`path`(✅) · `capture()`(✅) · Claude Code plugin+skill(✅ `/plugin install shotkit@starter-series`) · AGENTS.md 실행 블록(✅) · npm 패키지(✅) · capture-in-CI GitHub Action(✅) · 동영상 편집(계획). MCP stdio 도구는 검토 후 **폐기** — "하지 않기로 한 것" 참고.
CLI `--json`+`path`(✅) · `capture()`(✅) · Claude Code plugin+skill(✅ `/plugin install shotkit@starter-series`) · AGENTS.md 실행 블록(✅) · npm 패키지(✅) · capture-in-CI GitHub Action(✅) · 데모 mp4/trim(✅, `--mp4`) · 캡션(계획). MCP stdio 도구는 검토 후 **폐기** — "하지 않기로 한 것" 참고.

**일반화 규칙**(시리즈의 다음 기능용): npm 패키지 1개(엔진+얇은 CLI) + `*.config.js` 이음새 1개 + **도구 성격에 맞는 에이전트 표면**(빠른 구조화 도구: `path` 받는 MCP 도구 / 무거운 빌드 도구: `--json` CLI + skill + AGENTS.md 블록) + 마켓플레이스 항목 1개. **엔진은 config 이음새 외엔 프로젝트 특이사항을 읽지 않는다.**

Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ Screenshots · promo images · demo screencast · listing copy. One command.

## Status & Scope

- **Currently implemented** — A Playwright capture **engine** (build → launch the *built* extension via `launchPersistentContext(--load-extension)` → drive scenes → screenshot → caption/disclaimer band → promo tile from HTML → demo `webm` → listing copy from `STORE_LISTING.md`), a **CLI** (`shotkit`) with an **agent contract** (`--json` machine output, optional `path` argument, `0/1/2` exit codes), **size presets** for both audiences (CWS `1280×800`/`440×280`, SNS `1200×675`/`1200×630`/`1080×1080`), a **path-traversal-safe** localhost fixture server, a programmatic API (`capture()`), a **Claude Code plugin + skill** ([`skills/capture/`](skills/capture/SKILL.md); `/plugin install shotkit@starter-series`), an **AGENTS.md run-block** so any shell-having coding agent can invoke it, and the **npm package** [`@starter-series/shotkit`](https://www.npmjs.com/package/@starter-series/shotkit). Consumed by `browser-extension-starter` and `skillBridge`.
- **Planned** — **video editing** (`webm → mp4`, trim, captions) for SNS.
- **Currently implemented** — A Playwright capture **engine** (build → launch the *built* extension via `launchPersistentContext(--load-extension)` → drive scenes → screenshot → caption/disclaimer band → promo tile from HTML → demo `webm` → listing copy from `STORE_LISTING.md`), a **CLI** (`shotkit`) with an **agent contract** (`--json` machine output, optional `path` argument, `0/1/2` exit codes), **size presets** for both audiences (CWS `1280×800`/`440×280`, SNS `1200×675`/`1200×630`/`1080×1080`), a **path-traversal-safe** localhost fixture server, a programmatic API (`capture()`), a **Claude Code plugin + skill** ([`skills/capture/`](skills/capture/SKILL.md); `/plugin install shotkit@starter-series`), an **AGENTS.md run-block** so any shell-having coding agent can invoke it, the **npm package** [`@starter-series/shotkit`](https://www.npmjs.com/package/@starter-series/shotkit), and **demo post-processing** for SNS (`webm → H.264 mp4` with `+faststart`, frame-accurate **trim** — needs an ffmpeg on PATH or `SHOTKIT_FFMPEG`; GitHub ubuntu runners ship one). Consumed by `browser-extension-starter` and `skillBridge`.
- **Planned** — caption overlays for demo clips.
- **Design intent** — *One engine, many surfaces — matched to the tool's nature.* shotkit is a heavy, file-producing build tool, so its surfaces are CLI (+`--json`), skill, and CI — not MCP (see Non-goals). Captures are **deterministic** (login-free fixtures, frozen data) and the run **doubles as a real-bundle smoke test** — a screenshot only appears if that feature rendered from the shipped code. **Trademark-safe** by construction: a disclaimer band is composited onto every shot.
- **Non-goals** — An **MCP server** (dropped by design: agents with a shell get a better contract from `--json` + the skill, without MCP's per-session context cost; nothing here is a fast structured query). Removing the per-repo **scene config** (which screens are *your* money shots is irreducible intent — it lives in your `shotkit.config.js`). A general-purpose video editor (v1 records a clean screencast; editing is Planned). A hosted service (file-touching capture is local by nature).
- **Redacted** — none. Ships no private data, credentials, or third-party identifiers.
Expand Down Expand Up @@ -64,6 +64,26 @@ shotkit ../my-extension --json # run against another checkout; JSON result on s

Outputs land in `outDir` (default `store-assets/`): `<scene>.png`, `<promoTile>.png`, `<demo>.webm`, `description.md`.

### Demo → mp4 / trim (SNS)

SNS uploaders (X, etc.) want H.264 mp4, not webm. Add `--mp4` (or configure it) and
shotkit post-processes the recording — silent H.264, `yuv420p`, `+faststart`:

```js
demo: {
name: 'demo',
mp4: true, // or { crf: 18 }
trim: { start: 2, duration: '00:30' }, // optional; applied to the mp4
async run({ page, env }) { /* … */ },
}
```

`trim` without `mp4` stream-copy-trims the webm in place. Requires a real
ffmpeg (`brew install ffmpeg` / `apt-get install -y ffmpeg`; GitHub ubuntu
runners have one; override with `SHOTKIT_FFMPEG`) — Playwright's bundled
ffmpeg is vp8-only and can't encode H.264. If mp4/trim is requested and no
ffmpeg is found, the run fails with the install hint rather than skipping.

### Agent contract (`--json`)

`shotkit [path] --json` prints **exactly one JSON object** to stdout (progress
Expand Down Expand Up @@ -128,6 +148,7 @@ module.exports = {
| Claude Code skill ([`skills/capture/`](skills/capture/SKILL.md)) | ✅ now | Claude Code (portable to Codex/Cursor/Gemini via the Agent Skills format) |
| `AGENTS.md` run-block | ✅ now | every agent that reads AGENTS.md |
| npm package (`@starter-series/shotkit`) | ✅ now | `npx` zero-install |
| Demo post-processing (`--mp4`, `trim`) | ✅ now (captions planned) | SNS clips |
| Capture-in-CI GitHub Action | ✅ now — ships in [`browser-extension-starter`](https://github.com/starter-series/browser-extension-starter)'s `capture.yml` (headless) | zero-local-browser runs + CI smoke test |
| `starter-series` marketplace entry (`/plugin install shotkit@starter-series`) | ✅ now | discovery |
| Video editing (`webm→mp4`, trim, captions) | planned | SNS clips |
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@starter-series/shotkit",
"version": "1.1.1",
"version": "1.2.0",
"description": "Capture Chrome Web Store + social promo assets (screenshots, promo images, demo video) from a built browser extension with Playwright.",
"main": "src/index.js",
"exports": {
Expand Down Expand Up @@ -52,7 +52,8 @@
"src/describe.js",
"src/extension.js",
"src/serve.js",
"src/cli.js"
"src/cli.js",
"src/video.js"
],
"coverageReporters": [
"text",
Expand Down
4 changes: 3 additions & 1 deletion skills/capture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ rendered from the shipped code.
```

Useful flags: `--scene <name>` (one scene/promoTile/demo or `description`),
`--no-video` (skip the screencast), `--no-build` (reuse an existing build).
`--no-video` (skip the screencast), `--mp4` (also emit an H.264 mp4 of the
demo — needs ffmpeg on PATH or `SHOTKIT_FFMPEG`), `--no-build` (reuse an
existing build).
3. **Read the result** — stdout is exactly one JSON object:
`{ "ok": true, "outDir": "...", "produced": ["/abs/path/01-….png", …] }`.
Progress logs go to stderr in `--json` mode.
Expand Down
7 changes: 7 additions & 0 deletions src/capture.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const { compositeCaption, DEFAULT_BAND_HEIGHT } = require('./caption');
const { renderPromoTile } = require('./promo');
const { extractListing, renderDescriptionDoc } = require('./describe');
const { resolveSize } = require('./presets');
const { postProcessDemo } = require('./video');

const DEFAULT_VIEWPORT = { width: 1280, height: 800 };

Expand All @@ -43,6 +44,7 @@ function normalizeSetup(result) {
* @param {string[]} [opts.scenes] only capture these names (scenes/promoTiles/demo/"description")
* @param {boolean} [opts.noVideo] skip the demo screencast
* @param {boolean} [opts.noBuild] skip config.build
* @param {boolean} [opts.mp4] also convert the demo webm to H.264 mp4
* @param {boolean} [opts.liveGt] passed to config hooks as flags.liveGt
* @param {boolean} [opts.freeze] passed to config hooks as flags.freeze
* @param {string} [opts.cwd] project root for build / outDir / description.from
Expand Down Expand Up @@ -176,6 +178,11 @@ async function capture(config, opts = {}) {
await video.saveAs(out);
produced.push(out);
log(`✓ ${config.demo.name}.webm (${viewport.width}×${viewport.height})`);
// SNS post-processing: mp4 (H.264) and/or trim — needs a real ffmpeg,
// fails loudly if one was requested but none is installed.
produced.push(
...postProcessDemo({ webmPath: out, mp4: config.demo.mp4 || opts.mp4, trim: config.demo.trim, log }),
);
}
await closeContext(demoCtx);
await setup2.teardown();
Expand Down
4 changes: 4 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Options:
--json machine-readable mode: stdout gets one JSON object
{ok, outDir, produced[]}; progress logs move to stderr
--no-video skip the demo screencast
--mp4 also convert the demo to H.264 mp4 (needs ffmpeg on PATH
or SHOTKIT_FFMPEG; SNS uploaders want mp4, not webm)
--no-build skip the config's build step (use an already-built bundle)
--live-gt pass flags.liveGt to config hooks
--freeze pass flags.freeze to config hooks
Expand All @@ -42,6 +44,7 @@ function parseArgs(argv) {
freeze: false,
config: null,
json: false,
mp4: false,
help: false,
path: null,
};
Expand All @@ -50,6 +53,7 @@ function parseArgs(argv) {
if (a === '--scene') opts.scenes.push(...(argv[++i] || '').split(',').filter(Boolean));
else if (a === '--config') opts.config = argv[++i];
else if (a === '--json') opts.json = true;
else if (a === '--mp4') opts.mp4 = true;
else if (a === '--no-video') opts.noVideo = true;
else if (a === '--no-build') opts.noBuild = true;
else if (a === '--live-gt') opts.liveGt = true;
Expand Down
5 changes: 5 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const { compositeCaption, DEFAULT_BAND_HEIGHT } = require('./caption');
const { renderPromoTile } = require('./promo');
const { extractListing, renderDescriptionDoc, splitSections } = require('./describe');
const { PRESETS, resolveSize } = require('./presets');
const { findFfmpeg, buildFfmpegArgs, postProcessDemo } = require('./video');

module.exports = {
capture,
Expand All @@ -47,4 +48,8 @@ module.exports = {
// sizes
PRESETS,
resolveSize,
// demo video post-processing
findFfmpeg,
buildFfmpegArgs,
postProcessDemo,
};
115 changes: 115 additions & 0 deletions src/video.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* shotkit — demo video post-processing (mp4 conversion + trim).
*
* X/Twitter and most SNS uploaders want H.264 MP4, not the vp8 webm that
* Playwright records. Conversion needs a REAL ffmpeg: Playwright's bundled
* ffmpeg is a minimal vp8-only build (no libx264 — verified empirically), so
* we resolve, in order:
* 1. SHOTKIT_FFMPEG (explicit binary path)
* 2. `ffmpeg` on PATH (GitHub ubuntu runners ship one; macOS: `brew install ffmpeg`)
* If mp4/trim was requested and no ffmpeg is found we fail loudly with the
* install hint — a requested output is never silently skipped.
*/

const fs = require('fs');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');

const INSTALL_HINT =
'no ffmpeg found — install one (macOS: `brew install ffmpeg`; Debian/Ubuntu: ' +
'`apt-get install -y ffmpeg`; GitHub ubuntu runners already have it) or set ' +
"SHOTKIT_FFMPEG to a binary. Playwright's bundled ffmpeg cannot encode H.264.";

/**
* Locate a usable ffmpeg. Returns the binary path/name, or null.
* @param {NodeJS.ProcessEnv} [env]
*/
function findFfmpeg(env = process.env) {
for (const bin of [env.SHOTKIT_FFMPEG, 'ffmpeg']) {
if (!bin) continue;
try {
const r = spawnSync(bin, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', env });
if (r.status === 0 && /ffmpeg version/i.test(r.stdout || '')) return bin;
} catch (_e) {
/* try the next candidate */
}
}
return null;
}

/**
* Build the ffmpeg argv. Pure (unit-tested).
*
* @param {object} o
* @param {string} o.input
* @param {string} o.output
* @param {{start?: string|number, duration?: string|number}} [o.trim]
* @param {number} [o.crf=23] H.264 quality (lower = better/larger)
* @param {boolean} [o.copy=false] stream-copy (no re-encode) — for webm-only trims
*/
function buildFfmpegArgs({ input, output, trim, crf = 23, copy = false }) {
const args = ['-hide_banner', '-loglevel', 'error', '-y'];
// -ss before -i = fast keyframe seek; with re-encode it is frame-accurate.
if (trim && trim.start != null) args.push('-ss', String(trim.start));
args.push('-i', input);
if (trim && trim.duration != null) args.push('-t', String(trim.duration));
if (copy) {
args.push('-c', 'copy');
} else {
args.push(
'-c:v', 'libx264',
'-crf', String(crf),
'-pix_fmt', 'yuv420p',
// libx264 + yuv420p require even dimensions; presets are even already,
// this scale filter is insurance for custom viewports.
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
'-movflags', '+faststart',
// Screencasts are silent; skipping the audio track avoids any AAC
// encoder dependency and SNS uploaders accept silent H.264 fine.
'-an',
);
}
args.push(output);
return args;
}

/**
* Post-process a recorded demo webm per config:
* - mp4 requested → write `<name>.mp4` next to the webm (trim applied there)
* - trim only → stream-copy trim the webm in place
*
* @param {object} o
* @param {string} o.webmPath the recorded demo .webm
* @param {boolean|{crf?: number}} [o.mp4]
* @param {{start?: string|number, duration?: string|number}} [o.trim]
* @param {(msg: string) => void} o.log
* @param {NodeJS.ProcessEnv} [o.env]
* @returns {string[]} extra produced file paths (the mp4, when written)
*/
function postProcessDemo({ webmPath, mp4, trim, log, env = process.env }) {
if (!mp4 && !trim) return [];
const bin = findFfmpeg(env);
if (!bin) throw new Error(`demo mp4/trim requested but ${INSTALL_HINT}`);

const produced = [];
if (mp4) {
const crf = typeof mp4 === 'object' && mp4.crf != null ? mp4.crf : undefined;
const mp4Path = webmPath.replace(/\.webm$/, '.mp4');
execFileSync(bin, buildFfmpegArgs({ input: webmPath, output: mp4Path, trim, crf }), {
stdio: ['ignore', 'ignore', 'inherit'],
});
produced.push(mp4Path);
log(`✓ ${path.basename(mp4Path)} (H.264${trim ? ', trimmed' : ''})`);
} else {
// Trim-only: stream-copy to a sibling temp file, then swap in place.
const tmp = `${webmPath}.trim.webm`;
execFileSync(bin, buildFfmpegArgs({ input: webmPath, output: tmp, trim, copy: true }), {
stdio: ['ignore', 'ignore', 'inherit'],
});
fs.renameSync(tmp, webmPath);
log(`✓ ${path.basename(webmPath)} trimmed in place`);
}
return produced;
}

module.exports = { findFfmpeg, buildFfmpegArgs, postProcessDemo, INSTALL_HINT };
Loading