diff --git a/AGENTS.md b/AGENTS.md index 6a27712..a3447d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/README.ko.md b/README.ko.md index 4757b46..04c4f89 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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은 깔끔한 녹화만; 편집은 계획). 호스티드 서비스(파일을 만지는 캡처는 본질적으로 로컬). - **공개하지 않음** — 없음. @@ -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 이음새 외엔 프로젝트 특이사항을 읽지 않는다.** diff --git a/README.md b/README.md index 63a0330..b2b8f6f 100644 --- a/README.md +++ b/README.md @@ -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. @@ -64,6 +64,26 @@ shotkit ../my-extension --json # run against another checkout; JSON result on s Outputs land in `outDir` (default `store-assets/`): `.png`, `.png`, `.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 @@ -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 | diff --git a/package.json b/package.json index 06bf7dc..a019c00 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -52,7 +52,8 @@ "src/describe.js", "src/extension.js", "src/serve.js", - "src/cli.js" + "src/cli.js", + "src/video.js" ], "coverageReporters": [ "text", diff --git a/skills/capture/SKILL.md b/skills/capture/SKILL.md index 637ae5c..3b63370 100644 --- a/skills/capture/SKILL.md +++ b/skills/capture/SKILL.md @@ -24,7 +24,9 @@ rendered from the shipped code. ``` Useful flags: `--scene ` (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. diff --git a/src/capture.js b/src/capture.js index d5a900e..d21318f 100644 --- a/src/capture.js +++ b/src/capture.js @@ -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 }; @@ -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 @@ -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(); diff --git a/src/cli.js b/src/cli.js index 852a6c5..4ad002b 100644 --- a/src/cli.js +++ b/src/cli.js @@ -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 @@ -42,6 +44,7 @@ function parseArgs(argv) { freeze: false, config: null, json: false, + mp4: false, help: false, path: null, }; @@ -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; diff --git a/src/index.js b/src/index.js index 8316b00..579d7a4 100644 --- a/src/index.js +++ b/src/index.js @@ -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, @@ -47,4 +48,8 @@ module.exports = { // sizes PRESETS, resolveSize, + // demo video post-processing + findFfmpeg, + buildFfmpegArgs, + postProcessDemo, }; diff --git a/src/video.js b/src/video.js new file mode 100644 index 0000000..0e396c8 --- /dev/null +++ b/src/video.js @@ -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 `.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 }; diff --git a/test/video.test.js b/test/video.test.js new file mode 100644 index 0000000..405d22b --- /dev/null +++ b/test/video.test.js @@ -0,0 +1,55 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { findFfmpeg, buildFfmpegArgs, INSTALL_HINT } = require('../src/video'); + +describe('buildFfmpegArgs', () => { + test('mp4 conversion defaults: libx264, yuv420p, faststart, silent, even-dims', () => { + const args = buildFfmpegArgs({ input: 'in.webm', output: 'out.mp4' }); + expect(args).toEqual([ + '-hide_banner', '-loglevel', 'error', '-y', + '-i', 'in.webm', + '-c:v', 'libx264', '-crf', '23', '-pix_fmt', 'yuv420p', + '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', + '-movflags', '+faststart', '-an', + 'out.mp4', + ]); + }); + + test('trim places -ss before -i (fast seek) and -t after', () => { + const args = buildFfmpegArgs({ input: 'in.webm', output: 'out.mp4', trim: { start: 2, duration: '00:10' } }); + expect(args.indexOf('-ss')).toBeLessThan(args.indexOf('-i')); + expect(args[args.indexOf('-ss') + 1]).toBe('2'); + expect(args[args.indexOf('-t') + 1]).toBe('00:10'); + }); + + test('custom crf is honored', () => { + const args = buildFfmpegArgs({ input: 'a', output: 'b', crf: 18 }); + expect(args[args.indexOf('-crf') + 1]).toBe('18'); + }); + + test('copy mode stream-copies without encoder flags', () => { + const args = buildFfmpegArgs({ input: 'a.webm', output: 'b.webm', trim: { duration: 5 }, copy: true }); + expect(args).toContain('-c'); + expect(args).not.toContain('libx264'); + expect(args).not.toContain('-movflags'); + }); +}); + +describe('findFfmpeg', () => { + test('honors SHOTKIT_FFMPEG when it looks like a real ffmpeg', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sk-ff-')); + const fake = path.join(dir, 'fake-ffmpeg'); + fs.writeFileSync(fake, '#!/bin/sh\necho "ffmpeg version 7.0-test"\n'); + fs.chmodSync(fake, 0o755); + expect(findFfmpeg({ SHOTKIT_FFMPEG: fake, PATH: '' })).toBe(fake); + }); + + test('returns null when nothing usable exists', () => { + expect(findFfmpeg({ SHOTKIT_FFMPEG: '/nonexistent/ffmpeg', PATH: '/nonexistent' })).toBeNull(); + }); + + test('install hint names the env override', () => { + expect(INSTALL_HINT).toMatch(/SHOTKIT_FFMPEG/); + }); +});