diff --git a/AGENTS.md b/AGENTS.md index f12b9d8..d156cf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ # shotkit -A Playwright capture engine for store/social assets, used via the `shotkit` CLI, -`capture()` programmatically, or the `skills/capture/` Claude Code skill. +An autonomous launch asset pipeline with explicit final user approval for browser extensions. +Playwright drives the shipped product; channel profiles, automated QA, the `shotkit` CLI, programmatic +`capture()`, and `skills/capture/` Claude Code skill expose the same engine. Vanilla JS, CommonJS, no build step. ## Run this tool (for agents) @@ -22,7 +23,12 @@ command must succeed. Headless works (`HEADED=0`; verified on macOS + Linux CI, video included); the local default is headed. Exit codes: `0` ok · `1` runtime failure · `2` usage / no config. In `--json` mode progress logs go to stderr; stdout is exactly one JSON object. Useful flags: `--scene `, -`--mp4`, `--no-video`, `--no-build`. +`--target `, `--attempt `, `--mp4`, `--no-video`, `--no-build`. +Success JSON carries a technical `machineStatus` plus delivery `status`: +`approved`, `awaiting-approval`, `changes-requested`, `needs-fix`, `blocked`, or +`not-requested`. Agents own `needs-fix` actions and retry without interrupting +the user; after technical QA passes, the user reviews the final media and +chooses Approve or Request changes. Every run also writes `storyboard.json`, `captions.json`, and `shotkit-manifest.json` unless `handoff:false` is set in config. @@ -35,16 +41,26 @@ src/ extension.js → stageExtension / patchManifestForLocalhost serve.js → serveDirectory (path-traversal-safe localhost fixture server) caption.js → compositeCaption (disclaimer/caption band, stacked UNDER the shot) - demo.js → demo story helpers (DOM caption overlay + demo.caption/step/wait/click) + demo.js → demo story helpers (caption/pointer + demo.caption/step/wait/click/select) + demo-caption-focus.js → deterministic short-form caption chunks + active-word frames + demo-caption-qa.js → measured caption/protected-region composition QA + demo-select.js → recordable native-select mirror and real value-change action + calibration.js / calibrator-server.js → tracked profiles + local dashboard API + approval.js → digest-bound user approval decisions + publication gate + channels.js → autonomous CWS/YouTube, X, and Shorts target profiles 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: mp4/trim/crop/zoom/thumbnail (real ffmpeg required) handoff.js → storyboard/captions/shotkit-manifest JSON contract + handoff-files.js / handoff-validator.js → integrity, atomic IO, runtime schemas + image-qa.js → nonblank thumbnail pixel checks + publish.js → publish-ready/needs-fix/blocked target plan schemas/ → JSON schemas for the v1 handoff contract 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) +calibrator/ → local constrained composition UI (actual capture media) skills/capture/ → Claude Code skill wrapping the CLI (Agent Skills format) test/ → unit tests for the pure/safe modules (no browser) ``` @@ -63,21 +79,46 @@ test/ → unit tests for the pure/safe modules (no browser) headless in CI. - **Caption band stacks UNDER the shot** (scene captured at `height - bandHeight`, band appended) so the final image is the exact preset size and no UI is hidden. -- **Demo captions and pointer highlights overlay the recorded page**, while the +- **Demo captions, arrow pointers, and select mirrors overlay the recorded page**, while the disclaimer badge stays top-left. Keep this lightweight: one `demo` or several - `demos[]` entries, timed captions, `demo.caption/step/wait/click`, static + `demos[]` entries, timed captions, `demo.caption/step/wait/click/select`, static `zoom`/`crop`, `thumbnail`, and storyboard lint — not a timeline editor. -- **Handoff JSON is the product boundary**: shotkit creates source evidence and - metadata; external editors or MCP adapters do polish. Read - `shotkit-manifest.json` first, then use `assets[].role` and the packaged - `schemas/` files instead of filename guessing. -- **Adapter hints are product guidance**: `handoff.adapterHints[]` should tell - agents which MCP/editor/video tool to try next and whether it is ready, - missing assets, or missing non-shotkit inputs. Do not call those tools from - shotkit itself. +- **Shorts focus captions are authored-story animation, not transcription**: + `youtube-shorts` defaults to `captionOptions.mode:'focus'`, three-word chunks, + current-word emphasis, and a bottom safe offset. Keep CWS/X static by default. + Preserve every authored word; `dense-focus-caption` means the agent must + lengthen the beat or shorten its copy before publishing. `captions.json` + carries the resolved trim-relative frame timeline for downstream adapters. + Add Whisper-style alignment only as an optional future audio adapter; silent + product demos already have deterministic caption timing. +- **Localized typography is measured, not guessed**: localized publishing + configs declare `captionOptions.typography.locale` and project-local font + files. Preserve authored separators during focus segmentation, verify glyph + coverage and browser font loading, fit only within declared size/line bounds, + and treat typography QA warnings as agent-owned fixes. A Skill may author + copy and emphasis, but the harness owns deterministic measurement. +- **Handoff JSON is the machine boundary**: target workflows use + `handoff.automation` to fix and retry until technical `publish-ready`; users + do not read manifests or repair media. They review the resulting media in the + Calibrator and make the final Approve / Request changes decision. Use + `assets[].role` and bundled schemas instead of filename guessing. +- **Exception-only automation**: every `needs-fix` action is owned by the agent. + Escalate only after `automation.maxAttempts` yields `blocked`. Manual editor + hints are disabled unless `automation.manualFallback:true` is explicit. +- **User approval is the publication gate**: never treat machine + `publish-ready` as permission to publish. `shotkit-approval.json` binds each + decision to the exact deliverable SHA-256 and calibration profile hash; any + recapture or profile edit makes the old decision stale. Only an `approved` + current digest is publishable. Agents must not approve on the user's behalf. - **Storyboard lint is structured for agents**: runtime logs are human strings, but `storyboard.json` carries `code`, `severity`, `message`, and `fix` so the next config edit can be mechanical. +- **Calibration is exception-only**: `shotkit --calibrate` may adjust only a + declared layout preset, bounded framing, caption lane/appearance, and up to + three protected regions. It writes `shotkit.calibration.json`, never config + source. A profile is verified only after a matching real recapture returns + `publish-ready`; stale profiles stay `needs-fix`. Do not grow this into a + free-layer, keyframe, or timeline editor. - **`promo.js` innerHTML** is trusted, build-time content only (the repo's own template + config replacements) rendered in a throwaway page — not user input. - **`config.build`** is a repo-committed command string run via shell on purpose @@ -118,9 +159,21 @@ enough to read. Use `demos: []` for multiple campaign cuts such as `demo-translate`, `demo-restore`, or `demo-popup`; `--scene ` reruns just one clip. Use `demo.click(selectorOrLocator, { moveMs, beforeMs, holdMs })` for visible cursor -pacing. Prefer `thumbnail: { at: 1.2 }`; use `zoom: { scale: 1.04 }` or a -small `crop` only when the key UI is too small. Storyboard lint warns; set -`storyboardLint:false` only for intentionally short smoke clips. -Read `shotkit-manifest.json` before handing assets to Screen Studio, Canva, -Supademo, or a connector. Use `handoff.adapterHints[]` to choose the next tool -instead of making the user research the ecosystem first. +pacing. Native select popups are outside page video; always use +`demo.select(selectorOrLocator, value, { openMs, holdMs })` so real DOM options, +the arrow cursor, and the real value change are recorded. Prefer +`thumbnail: { at: 1.2 }`; use `zoom: { scale: 1.04 }` or a +small `crop` only when the key UI is too small. Storyboard lint must stay on +for channel targets; `storyboardLint:false` is only for legacy, non-publishing +smoke clips and must produce `needs-fix` for a target. +For a desktop product that does not reflow at 720×1280, use the Shorts +`targetOptions` override for a narrower story and capture-only fixture layout. +Do not squeeze the complete desktop page into 9:16. Runtime caption QA must pass +actual bounds, overflow, line-count, stroke, presence, and timing checks. +Prefer one story with `targets:['cws-youtube','x','youtube-shorts']`; shotkit +replays the action script with target-specific framing. Read +`handoff.automation`, apply agent-owned fixes, and rerun only +`automation.retryScenes[]`. Once technical QA passes, open the final candidate +in the Calibrator for the user's approval. Treat Request changes feedback as +the next agent-owned edit and recapture. Do not propose iMovie or manual +recapture unless the user explicitly requests fallback editing. diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c6587..ea1442d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,67 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +### Added +- An explicit final user approval gate in the Calibrator. Approve and Request + changes decisions are stored in `shotkit-approval.json`, bound to the exact + deliverable SHA-256 and calibration profile hash, and exposed through the + manifest's schema-backed `handoff.approval` contract. Recapture or profile + changes automatically invalidate stale decisions. +- A local exception-only composition Calibrator (`shotkit --calibrate`) backed + by tracked `shotkit.calibration.json`: declared layout presets, bounded + framing, caption lane/appearance controls, up to three protected regions, + actual capture media, and save-then-recapture verification. +- Protected-region collision QA and profile hashes. Changed calibration stays + `needs-fix` until the exact profile produces a real `publish-ready` capture. +- Autonomous channel profiles for `cws-youtube`, `x`, and `youtube-shorts`. + One demo story can declare `targets[]`; shotkit expands target variants and + applies viewport, H.264, duration-cap, caption, and thumbnail defaults. +- Final MP4 probing through ffprobe plus PNG pixel QA for blank/uniform poster + frames. The manifest now carries per-target checks and media metadata. +- Publish targets cannot bypass story checks with `storyboardLint:false`. +- `demo.select()` mirrors native select options into page recordings while + applying the real value change; pointer actions now use a visible arrow and + click ripple instead of an ambiguous circle. +- Timed captions now support a dependency-free `focus` mode with compact word + chunks, active-word color/pop emphasis, configurable pacing, and a platform + safe-area offset. YouTube Shorts enables it by default; CWS and X remain + static. Resolved caption style is included in storyboard and captions handoff + documents, along with a trim-relative rendered frame timeline. Dense beats + preserve their full text and produce an agent-fixable storyboard warning. +- Shorts focus captions default to the transparent `outline` appearance, with + `panel|outline` preserved in both handoff schemas. Runtime caption QA measures + actual bounds, overflow, line count, stroke, frame presence, and timing drift + and routes failures into the existing agent retry plan. +- Exception-only `handoff.automation`: `publish-ready`, `needs-fix`, and + exhausted `blocked` states, agent-owned fix/rerun actions, `--target`, and + bounded `--attempt` retries. +- Every handoff pack now bundles its four JSON Schemas, exposes their + manifest-relative paths, and records byte size plus SHA-256 integrity for + each delivered file except the self-referential manifest. +- The manifest now carries additive v1 metadata for the agent-ready launch + asset category, current run selection, per-asset provenance/state, a + manifest-level review summary, and the number of asset-ready adapters. + ### Changed - Package identity is now the unscoped npm product noun `shotkit`. - The handoff manifest `tool` field now emits `shotkit`, matching the package and CLI identity. +- `--json` success results now return the absolute `manifest` entrypoint. +- Public messaging now leads with the autonomous launch asset pipeline and its + explicit final user approval gate; Playwright remains the implementation + mechanism. +- Target workflows no longer route technical diagnosis or routine repair to + humans. Agents own those retries; users review the technically verified final + media and make the publication-gating decision. Manual adapter hints require + `automation.manualFallback:true`. ### Fixed +- Caption and native-select overlays are isolated from host-page translation, + so localization products cannot rewrite authored campaign text. Outline also + applies to direct helper/static captions, and condensed outline sizing now + works on wide viewports. +- Unsupported caption positions and bottom offsets that leave no viewport room + can no longer pass storyboard lint and claim `publish-ready`. - `step(text, fn, options)` now honors flat caption display options (e.g. `{ position }`, by analogy with `caption()`) instead of silently dropping anything outside `options.captionOptions`. @@ -20,8 +75,19 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. `mp4Requested`. - A thumbnail-only demo (no mp4/crop/zoom/trim) no longer re-muxes and overwrites the source `.webm`; the thumbnail is taken from the original clip. +- Partial handoff runs prune missing outputs, replace every retained format for + a refreshed logical source, mark untouched assets as `retained`, and flag + changed retained evidence as `modified` instead of recommending it. +- A fresh `--no-video` run with configured demos now reports an `incomplete` + review and does not claim storyboard-only adapter readiness. +- Final handoff publication validates all three documents with the packaged + AJV schemas, rejects duplicate asset IDs/paths, and writes JSON through atomic + temporary-file renames. Malformed or foreign prior packs are no longer merged + into a partial run. +- New manifest fields remain additive under contract v1; the original v1 + `positioning` and storyboard `purpose` constants stay unchanged. -## [1.3.0] - 2026-06-18 +## 1.3.0 - 2026-06-18 (source-staged) ### Added - **Demo story renderer** — demo configs accept a single `demo` or several @@ -64,6 +130,8 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. storyboard/captions/manifest documents against `schemas/`. ### Notes +- Source files are versioned at `1.3.0`, but no `v1.3.0` GitHub release tag or + public npm publication is assumed until the release step is cut. - The demo post-processing pipeline (`webm → H.264 mp4` with `+faststart`, frame-accurate trim) shipped in 1.2.0 and remains available; it requires an `ffmpeg` on `PATH` or `SHOTKIT_FFMPEG`. @@ -72,5 +140,4 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. The repo-local research harness (`scripts/`, `skills/research-to-product-fit/`, generated `research-runs/`) is not published. -[Unreleased]: https://github.com/starter-series/shotkit/compare/v1.3.0...HEAD -[1.3.0]: https://github.com/starter-series/shotkit/releases/tag/v1.3.0 +[Unreleased]: https://github.com/starter-series/shotkit/compare/v1.2.0...HEAD diff --git a/README.ko.md b/README.ko.md index 5a1d991..73f3d40 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,9 +2,11 @@ # shotkit -**빌드된 브라우저 익스텐션에서 스토어 자산과 데모 handoff pack을 캡처 — Playwright 기반.** +**최종 사용자 승인 게이트가 있는 브라우저 익스텐션 출시 자산 자동화.** -스크린샷 · 프로모 이미지 · 데모 클립 · storyboard · handoff manifest. 한 커맨드. +스토리와 채널만 정하면 에이전트가 촬영·편집·검증·재시도를 수행하고, +사용자는 최종 파일을 검수해 승인하거나 수정 요청합니다. 기술적 판단은 +자동화가 소진되어 blocker가 생겼을 때만 사용자에게 요청합니다. [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Node ≥ 22](https://img.shields.io/badge/node-%E2%89%A522-brightgreen.svg)](.nvmrc) @@ -15,14 +17,14 @@ --- -> **[Starter Series](https://github.com/starter-series)** — 클론 템플릿이 아니라 재사용 가능한 도구. `shotkit`은 이 캡처 엔진의 unscoped 패키지 이름입니다. 공개 npm 설치는 릴리스 게이트이며, README가 현재 게시 상태를 가정하지 않습니다. +> **[Starter Series](https://github.com/starter-series)** — 클론 템플릿이 아니라 재사용 가능한 도구. `shotkit`은 이 출시 자산 파이프라인의 unscoped 패키지 이름입니다. 공개 npm 설치는 릴리스 게이트이며, README가 현재 게시 상태를 가정하지 않습니다. ## 상태와 범위 (Status & Scope) -- **현재 구현된 것** — Playwright 캡처 **엔진**(빌드 → `--load-extension`으로 *빌드된* 익스텐션 로드 → scene 구동 → 스크린샷 → 캡션/면책 밴드 → HTML 프로모 타일 → DOM 캡션 오버레이가 들어간 데모 `webm` → `STORE_LISTING.md`에서 문안 추출 → `storyboard.json` / `captions.json` / `shotkit-manifest.json` handoff pack), **에이전트 계약**을 갖춘 **CLI**(`shotkit` — `--json` 머신 출력, 선택적 `path` 인자, `0/1/2` 종료 코드), 양쪽 용도 **사이즈 프리셋**(CWS `1280×800`/`440×280`, SNS `1200×675`/`1280×720`/`1200×630`/`1080×1080`), **path-traversal 안전** 로컬 픽스처 서버, 프로그램 API(`capture()`), **Claude Code skill**([`skills/capture/`](skills/capture/SKILL.md)), 셸을 가진 어떤 코딩 에이전트든 호출법을 읽을 수 있는 **AGENTS.md 실행 블록**. -- **스토리 렌더러** — 데모 config는 단일 `demo` 또는 여러 `demos: []`, timed `captions`, click highlight, cursor pacing, 정적 zoom/crop, thumbnail frame, storyboard lint, 작은 `demo` helper(`caption`, `step`, `wait`, `click`)를 쓸 수 있습니다. 에이전트가 기능 체크리스트를 20~40초짜리 before → action → result → safety/restore 캠페인 컷으로 바꾸기 쉬운 정도까지만 제공합니다. +- **현재 구현된 것** — Playwright로 *실제 출하 빌드*를 실행하고 하나의 story를 `cws-youtube`, `x`, `youtube-shorts` variant로 확장합니다. target별 viewport/H.264/trim/caption/thumbnail을 자동 적용하고, 최종 MP4를 ffprobe로 검사하며, thumbnail 픽셀의 blank-frame 여부까지 확인해 기술 상태 `machineStatus`(`publish-ready`, `needs-fix`, `blocked`)를 산출합니다. 별도의 해시 기반 승인 게이트가 전달 상태 `awaiting-approval`, `changes-requested`, `approved`를 관리합니다. manifest에는 에이전트가 실행할 retry action, source evidence와 사용자 승인 상태가 함께 남습니다. +- **스토리 렌더러** — 데모 config는 단일 `demo` 또는 여러 `demos: []`, timed `captions`, click highlight, 녹화 가능한 native select 변경, cursor pacing, 정적 zoom/crop, thumbnail frame, storyboard lint, 작은 `demo` helper(`caption`, `step`, `wait`, `click`, `select`)를 쓸 수 있습니다. 에이전트가 기능 체크리스트를 20~40초짜리 before → action → result → safety/restore 캠페인 컷으로 바꾸기 쉬운 정도까지만 제공합니다. - **설계 의도** — *엔진 1개, 표면 여러 개 — 단, 도구 성격에 맞는 표면.* shotkit은 무겁고 파일을 산출하는 빌드 도구라 표면이 CLI(+`--json`)·skill·CI입니다 — MCP가 아니라(하지 않기로 한 것 참고). 캡처는 **결정적**(로그인 불필요 픽스처, freeze된 데이터)이고, 실행이 **실제 빌드본 smoke test를 겸함** — 스크린샷이 나온다 = 그 기능이 출하 코드에서 렌더됨. 모든 샷에 면책 밴드를 합성해 **상표 안전**. -- **하지 않기로 한 것** — shotkit 내부 **MCP 서버**(셸이 있는 에이전트에는 `--json` + skill이 더 나은 계약). repo별 **scene 설정** 제거(어떤 화면이 *당신의* money shot인지는 환원 불가한 의도 — `shotkit.config.js`에 둠). 범용 동영상 편집기나 호스티드 데모 플랫폼. shotkit은 source evidence와 handoff pack을 만들고, Screen Studio/Canva/Supademo/향후 MCP connector가 polish를 이어받게 합니다. +- **하지 않기로 한 것** — shotkit 내부 MCP 서버, repo별 story/action 의도 제거, 범용 timeline editor, 호스티드 데모 플랫폼. 반복 가능한 채널 작업은 자동화하고 수동 편집기는 명시적으로 요청한 fallback일 때만 노출합니다. - **공개하지 않음** — 없음. ## 설치 @@ -58,44 +60,133 @@ npx shotkit ```bash shotkit # outDir에 전부 산출 shotkit --scene 01-feature # 특정 scene/타일/데모/demos 항목 또는 "description"만 +shotkit --target x # 설정된 X variant만 제작/재시도 +shotkit --attempt 2 --json # 두 번째 자동 수정 시도 +shotkit --campaign # Recipe 선택, 자동 제작, 최종 영상 검수 +shotkit --calibrate # 로컬 구도 Calibrator 열기 shotkit --no-video # 스크린캐스트 생략 shotkit --no-build # 이미 빌드된 번들 사용 shotkit ../my-extension --json # 다른 체크아웃 대상 실행; 결과 JSON을 stdout에 ``` -산출물은 `outDir`(기본 `store-assets/`): `.png`, `.png`, `.webm`, 선택적 `.mp4`, 선택적 `-thumbnail.png`, `description.md`, 그리고 기본값으로 `storyboard.json`, `captions.json`, `shotkit-manifest.json`입니다(`handoff: false`면 handoff 파일을 끕니다). +산출물은 `outDir`(기본 `store-assets/`): `.png`, `.png`, `.webm`, 선택적 `.mp4`, 선택적 `-thumbnail.png`, `description.md`, 그리고 기본값으로 `storyboard.json`, `captions.json`, `shotkit-manifest.json`, `schemas/*.schema.json`입니다(`handoff: false`면 handoff pack을 끕니다). 첫 검수 결정 시 `shotkit-approval.json`이 생성됩니다. + +### 캠페인 대시보드 + +`shotkit --campaign`은 로컬 Campaign Dashboard를 엽니다. 채널 target이 있는 +story는 기본적으로 하나의 Campaign Recipe가 되며, Recipe가 설정된 channel +profile 전체를 소유합니다. 따라서 사용자는 채널 조합이나 영상 편집 설정을 +반복해서 고르지 않고 Recipe 하나를 선택한 뒤 최종 산출물만 검수합니다. + +선택한 Recipe는 `shotkit-campaign.json`에 별도로 기록됩니다. 이 파일은 config, +manifest, calibration, approval 계약을 바꾸지 않습니다. `config.campaign.recipes` +로 Recipe의 이름과 설명을 붙일 수 있으며, calibration을 사용하지 않는 repo도 +Campaign Dashboard에서 제작과 digest-bound 승인을 사용할 수 있습니다. +구도 예외가 있을 때만 Advanced의 기존 Calibrator를 엽니다. + +### 구도 Calibrator + +자동 재시도로 세로 구도가 해결되지 않는 repo는 `config.calibration`에 추적할 +`shotkit.calibration.json` 경로와 허용할 layout preset 목록을 선언할 수 +있습니다. `shotkit --calibrate`는 실제 캡처 MP4를 사용하는 로컬 대시보드를 +열며, 조정 범위를 layout preset, 1.00~1.20 framing, caption 위치/표현, 최대 +3개의 protected region으로 제한합니다. 저장은 CommonJS config를 고치지 않고 +JSON만 기록합니다. 현재 profile로 실제 story를 재촬영해 `publish-ready`가 +나오고 profile hash까지 일치할 때만 Verified가 됩니다. + +이는 상시 수동 검수나 범용 timeline/layer editor가 아니라 예외 구도용 +calibration surface입니다. 에이전트가 이 제어면에서 구도를 수정하고 기술 QA를 +통과시키면, 사용자는 Campaign Dashboard에서 최종 영상만 확인해 Approve 또는 +Request changes를 선택합니다. 기존 Calibrator는 Advanced/Debug 도구로 그대로 +남습니다. ### Handoff Pack -shotkit은 영상 편집기를 이기려는 도구가 아닙니다. 편집기 앞단의 starter -layer입니다. 실제 빌드된 확장을 캡처하고, source clip과 “이 클립이 무슨 -의도인지”를 같이 남깁니다. +handoff pack은 에이전트가 target별 최종 파일을 검증하고 자동 수정·재촬영하는 +내부 machine boundary입니다. 사람이 JSON을 읽거나 영상을 편집하도록 넘기는 +단계가 아닙니다. 사용자는 수정 과정이 아니라 렌더링된 최종 후보를 검수합니다. - `storyboard.json` — demo 이름, audience, viewport, trim/framing hint, beats, 구조화된 storyboard lint warning, 추천 next tool. -- `captions.json` — demo별 caption timing/text. -- `shotkit-manifest.json` — asset 목록, output path, role, project info, - 추천 handoff flow와 다음 도구 후보 `adapterHints`. +- `captions.json` — demo별 caption timing/text와 실제 렌더링 타이포그래피 QA. +- `shotkit-manifest.json` — entrypoint. asset inventory/integrity, 실행·freshness + metadata, 로컬 schema path, `handoff.automation`의 target 검사/retry action, + `handoff.approval`의 최종 게시 게이트. +- `shotkit-approval.json` — 첫 결정 후 생성되며 Approve 또는 Request changes를 + 정확한 미디어 SHA-256과 calibration profile hash에 결합합니다. +- `schemas/*.schema.json` — 설치된 npm 패키지 없이도 검증할 수 있도록 + 모든 pack에 함께 복사되는 계약. + +target workflow에서는 수동 editor hint를 기본으로 숨기며, +`automation.manualFallback:true`일 때만 명시적으로 다시 노출합니다. + +다국어 campaign variant는 실행 환경의 시스템 폰트 대신 locale과 프로젝트 내부 +폰트를 명시할 수 있습니다. Shotkit은 작성된 글자의 glyph coverage를 먼저 +확인하고, 필요한 글자만 WOFF2로 줄여 녹화 페이지에 삽입한 뒤 브라우저 로드를 +기다립니다. 각 caption은 선언된 최소 크기와 최대 줄 수 안에서만 자동으로 +축소됩니다. -이렇게 하면 에이전트나 MCP connector가 manifest를 읽고 mp4/webm, -thumbnail, captions를 Screen Studio, Canva, Supademo 또는 다른 편집 도구로 -넘기기 쉽습니다. repo fixture와 storyboard는 반복 가능한 source of truth로 -남습니다. +```js +captionOptions: { + mode: 'focus', + appearance: 'outline', + typography: { + locale: 'ko-KR', + family: '"Campaign Sans", sans-serif', + weight: 800, + minFontSize: 28, + maxFontSize: 44, + maxLines: 2, + fit: 'shrink', + fonts: [{ + family: 'Campaign Sans', + from: '.shotkit/fonts/campaign-sans.woff2', + weight: '100 900', + }], + }, +} +``` -manifest는 downstream 연결 후보도 제안합니다. 예를 들어 thumbnail/storyboard -재료가 충분하면 `figma-mcp`가 나오고, AI video campaign variant에는 -`higgsfield`, avatar/presenter 계열에는 `longcat-video-avatar`가 나옵니다. -추가 입력이 필요한 경우 `needs-input`으로 표시됩니다. shotkit은 다음 도구를 -제안하고, 실제 연결은 에이전트의 MCP/tool 환경이 수행합니다. +폰트 경로는 consumer 프로젝트 내부여야 합니다. OTF, TTF, WOFF, WOFF2를 +최대 4개, 파일당 24 MB까지 사용할 수 있습니다. focus caption은 locale-aware +word segmentation으로 원문의 문장부호와 구분자를 보존하므로 일본어·중국어에 +임의의 공백을 넣지 않습니다. 누락 glyph, 폰트 로드 실패, 최소 크기 overflow, +불균형한 두 줄 구성은 에이전트가 수정할 구조화 warning이 됩니다. -handoff 규약은 버전과 schema를 갖습니다. `$schema` 값은 URN 식별자이고, -실제 schema 파일은 설치된 패키지의 `schemas/`에서 읽으면 됩니다. +handoff 규약은 버전과 schema를 갖습니다. `$schema` 값은 안정적인 URN +식별자이고, `handoff.schemaFiles`가 output pack 안의 실제 schema로 연결합니다. +자기 자신을 참조하는 manifest를 제외한 실파일에는 byte 크기와 SHA-256이 +기록됩니다. [`docs/handoff-conventions.md`](docs/handoff-conventions.md)와 [`schemas/`](schemas/)를 보세요. 프로젝트별 적용 계획 문서는 repo-internal로 유지하며 npm 패키지에는 포함하지 않습니다. +### 자동 채널 target + +제품 동작과 caption은 하나의 story로 두고 목적지만 선언합니다. + +```js +demo: { + name: 'skillbridge', + targets: ['cws-youtube', 'x', 'youtube-shorts'], + captions: [ + { at: 0.5, text: 'Translate the lesson in place' }, + { at: 18, text: 'Restore the original anytime' }, + ], + async run({ page, env, demo, target }) { /* 재사용 가능한 제품 동작 */ }, +} +``` + +Shotkit은 이를 target별 이름으로 확장하고 가로형은 1280×720, Shorts는 +720×1280로 촬영합니다. H.264/yuv420p, 30초 cap, poster frame과 최종 파일 +검사를 자동 적용합니다. `needs-fix`는 사용자 검토 요청이 아니라 에이전트가 +config를 수정하고 `automation.retryScenes[]`를 다시 실행하라는 뜻입니다. +기본 3회가 소진된 뒤에만 `blocked`로 기술 입력을 요청합니다. 기술 QA가 끝나면 +사용자는 최종 후보를 반드시 검수합니다. Request changes의 메모는 다음 에이전트 +수정 작업이 되고, Approve는 검수한 정확한 파일 해시에만 게시를 허용합니다. + ### CWS 자산과 SNS 데모 클립 Chrome Web Store 자산은 검사 가능한 표면입니다. 선명한 스크린샷, 프로모 @@ -143,17 +234,22 @@ demo: { } ``` -`demo.click(selectorOrLocator)`는 녹화에 synthetic pointer와 click ripple을 +`demo.click(selectorOrLocator)`는 녹화에 고대비 화살표 cursor와 click ripple을 보여줍니다. `{ moveMs, beforeMs, holdMs }`로 속도를 조절하고, `{ highlight: false }`로 끌 수 있습니다. selector가 어색한 경우 Playwright Locator나 `{ x, y }` point도 받을 수 있습니다. 영상 framing은 작게 유지합니다: +native `` popups are OS/browser UI and do not appear in Playwright's +page screencast. Use `demo.select()` so shotkit mirrors the element's real DOM +options inside the recorded page, shows the pointer, and then applies the real +selection: + +```js +await demo.select('#language', 'ko', { + moveMs: 550, + openMs: 900, + holdMs: 700, +}); +``` + +Legacy/custom clips can use timed captions, the helper API, or both. Autonomous +channel targets must include timed `captions[]`: that authored schedule is the +handoff and retry contract, while helper calls remain immediate runtime callouts. ```js demo: { @@ -178,12 +380,18 @@ demo: { await page.waitForSelector('[data-demo-translated="true"]'); }); await demo.caption('Restore the original anytime'); - await demo.click('[data-demo-restore]'); + await demo.select('#language', 'en'); await demo.wait(900); }, } ``` +Focus sequencing applies to timed `captions[]`. Direct `demo.caption()` and +`demo.step()` calls remain immediate full-phrase callouts so their existing +control-flow timing does not change. Shotkit marks its caption and select +overlays as non-translatable so a product localization feature cannot rewrite +authored campaign copy. + Framing options are intentionally small: ```js @@ -191,7 +399,7 @@ demo: { crop: { x: 120, y: 0, width: 1040, height: 720 }, // output a cropped mp4 zoom: { scale: 1.08 }, // center zoom, still 16:9 thumbnail: { at: 1.5 }, // poster frame - storyboardLint: false, // optional escape hatch + storyboardLint: false, // legacy/short smoke clips only } ``` @@ -199,8 +407,15 @@ Storyboard lint runs by default and logs warnings instead of failing the run. The same warnings are written to `storyboard.json` with `code`, `severity`, `message`, and `fix`, so an agent can revise `shotkit.config.js` on the next pass. Current checks cover missing mp4, first caption after 3 seconds, odd video -dimensions, long captions, missing safety/restore beat, crop/zoom edge risk, and -clips outside the 20-40 second target. +dimensions, long captions, missing safety/restore beat, unsupported/offscreen +caption placement, crop/zoom edge risk, and clips outside the 20-40 second +target. During the real recording, Shotkit also measures every scheduled +caption frame's bounding box, overflow, line count, computed outline stroke, +presence, timing drift, configured font load, fitted size, and line balance. +A mismatch becomes structured lint and prevents +`publish-ready`. +Autonomous channel targets require lint to remain enabled; setting +`storyboardLint:false` makes their automation status `needs-fix`. For several campaign cuts, keep the old single `demo` field out and use `demos: []`. Each entry writes `.webm` and optional `.mp4`, and @@ -241,11 +456,19 @@ move cursor/click/typing actions slowly, and use mp4 for X. logs move to stderr): ```json -{ "ok": true, "outDir": "/abs/store-assets", "produced": ["/abs/store-assets/01-popup.png"] } +{ "ok": true, "status": "awaiting-approval", "machineStatus": "publish-ready", "outDir": "/abs/store-assets", "manifest": "/abs/store-assets/shotkit-manifest.json", "produced": ["/abs/store-assets/skillbridge-x.mp4"] } ``` -Exit codes: `0` ok · `1` runtime failure (stderr carries `{"ok":false,"error":…}`) · -`2` usage / no config found. Drop-in agent wiring: the run-block in +Exit codes: `0` ok · `1` runtime failure · `2` usage / no config found. Failure +payloads also use the single stdout JSON object (`{"ok":false,"error":…}`). +`ok:true` means execution completed. `machineStatus:publish-ready` means every +requested target passed shotkit's story lint, H.264/yuv420p, actual +dimensions, actual duration, thumbnail, nonblank-frame, integrity, and target +profile checks. Delivery `status` remains `awaiting-approval` until the user +reviews the media. It becomes `approved` only for that exact digest; a recapture +or profile change makes the decision stale. An authorized connector may publish +only when `handoff.approval.publishable` is true. +Drop-in agent wiring: the run-block in [`AGENTS.md`](AGENTS.md) (read by Claude Code, Codex, Cursor, Gemini CLI, …) and the [`skills/capture/`](skills/capture/SKILL.md) skill (Agent Skills format — copy the folder into any compatible tool's skills directory). @@ -277,15 +500,14 @@ module.exports = { promoTiles: [{ name: 'promo', template: 'path/to/promo.html', preset: 'cws-promo-small', replacements: { NAME: 'My Ext' } }], - // Use `demo` for one canonical clip, or `demos: []` for campaign variants. + // One story can render several channel variants automatically. demos: [ - { name: 'demo-feature', preset: 'sns-video', mp4: { crf: 18 }, - trim: { start: 0, duration: '00:30' }, + { name: 'demo-feature', targets: ['cws-youtube', 'x', 'youtube-shorts'], captions: [ { at: 0.5, text: 'Show the result first' }, { at: 18.0, text: 'Restore the original anytime' }, ], - async run({ page, env, demo }) { /* walkthrough with demo.step/click/wait */ } }, + async run({ page, env, demo, target }) { /* reusable walkthrough */ } }, ], }; ``` @@ -294,6 +516,8 @@ module.exports = { - The harness reduces a captioned scene's capture height by the band height and stacks the band under it, so the final image is exactly the preset size and **no UI is hidden**. - Demo captions are overlays inside the recorded page, not screenshot bands; they are meant for story clips, not CWS screenshots. - Demo names must be unique across `demo` and `demos` because they become output filenames. +- Target demos expand to `-` filenames. Use `--target x` or + `--scene ` for an automatic retry pass. ### Product manifest listing/privacy @@ -355,7 +579,8 @@ permission tables. It is intentionally not a privacy policy generator. `capture(config, opts)` · `serveDirectory` · `stageExtension` · `patchManifestForLocalhost` · `launchWithExtension` · `closeContext` · `compositeCaption` · `renderPromoTile` · `extractListing` · `extractProductManifest` · `renderDescriptionDoc` · -`renderPrivacyDisclosureDoc` · `PRESETS` · `resolveSize` · +`renderPrivacyDisclosureDoc` · `PRESETS` · `resolveSize` · `CHANNEL_PROFILES` · +`resolveChannelProfile` · `buildPublishPlan` · `createDemoController` · `normalizeDemoConfigs` · `analyzeDemoStoryboard` · `lintDemoStoryboard` · `installDemoCaptionOverlay` · `setDemoCaption` · `buildVideoFilter` · `buildThumbnailArgs` · `HANDOFF_VERSION` · @@ -370,10 +595,10 @@ permission tables. It is intentionally not a privacy policy generator. | 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 (`shotkit`) | release target | `npx` zero-install after publish | -| Demo story rendering (`demo`, `demos[]`, captions, click highlight, pacing, crop/zoom, thumbnails, storyboard lint, `--mp4`, `trim`) | ✅ now | SNS clips | +| Autonomous target rendering (`demo.targets`, CWS/YouTube, X, Shorts, ffprobe, pixel QA, retry actions) | ✅ now | publish-ready channel variants | | 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 | -| Timeline/audio/motion editing | non-goal | use a real editor after shotkit | +| General timeline/audio editing | non-goal | explicit `automation.manualFallback:true` only | An MCP stdio tool was considered and **dropped** — see Non-goals: shotkit is a heavy, file-producing build tool, so a `--json` CLI + skill serves agents better than an MCP server's per-session context cost. diff --git a/bin/shotkit.js b/bin/shotkit.js index a159f61..9031741 100644 --- a/bin/shotkit.js +++ b/bin/shotkit.js @@ -2,7 +2,7 @@ /* * shotkit CLI — thin wrapper over capture(). * - * shotkit [path] [--config ] [--scene ]... [--json] + * shotkit [path] [--config ] [--scene ]... [--json|--calibrate] * [--no-video] [--no-build] [--live-gt] [--freeze] * * `path` (optional positional) is the repo to run against (default: cwd) — @@ -15,42 +15,16 @@ * to stderr, so agents can parse stdout blindly. */ -const fs = require('fs'); -const path = require('path'); -const { capture } = require('../src'); -const { parseArgs, resolveConfigPath, USAGE } = require('../src/cli'); +const { runCli } = require('../src/cli-runner'); async function main() { - const opts = parseArgs(process.argv.slice(2)); - if (opts.help) { - process.stdout.write(USAGE); - return; - } - if (opts.errors.length) { - const msg = opts.errors.join('; '); - if (opts.json) process.stderr.write(JSON.stringify({ ok: false, error: msg, code: 2 }) + '\n'); - else console.error(`[shotkit] ${msg}\n\n${USAGE}`); - process.exit(2); - } - const cwd = path.resolve(process.cwd(), opts.path || '.'); - const configPath = resolveConfigPath(opts.config, cwd); - if (!configPath || !fs.existsSync(configPath)) { - const msg = `No config found (looked for shotkit.config.js / store.config.js in ${cwd}). Pass --config .`; - if (opts.json) process.stderr.write(JSON.stringify({ ok: false, error: msg, code: 2 }) + '\n'); - else console.error(`[shotkit] ${msg}`); - process.exit(2); - } - const config = require(configPath); - // In JSON mode, route human-readable progress to stderr so stdout stays pure. - const log = opts.json ? (m) => process.stderr.write(`[shotkit] ${m}\n`) : undefined; - const { produced, outDir } = await capture(config, { ...opts, cwd, log }); - if (opts.json) process.stdout.write(JSON.stringify({ ok: true, outDir, produced }) + '\n'); + process.exitCode = await runCli(process.argv.slice(2)); } main().catch((err) => { - const msg = err && err.message ? err.message : String(err); if (process.argv.includes('--json')) { - process.stderr.write(JSON.stringify({ ok: false, error: msg, code: 1 }) + '\n'); + const msg = err && err.message ? err.message : String(err); + process.stdout.write(JSON.stringify({ ok: false, error: msg, code: 1 }) + '\n'); } else { console.error('[shotkit] FAILED:', err && err.stack ? err.stack : err); } diff --git a/calibrator/app.js b/calibrator/app.js new file mode 100644 index 0000000..f041f5c --- /dev/null +++ b/calibrator/app.js @@ -0,0 +1,473 @@ +import { clamp, clone, keyFor, profileDefaults, round } from './model.js'; +import { createPreviewController } from './preview.js'; +import { createRegionEditor } from './regions.js'; + +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + const elements = { + app: $('app'), + projectName: $('projectName'), + statusDot: $('statusDot'), + targetStatus: $('targetStatus'), + operationState: $('operationState'), + recaptureButton: $('recaptureButton'), + saveButton: $('saveButton'), + requestChangesButton: $('requestChangesButton'), + approveButton: $('approveButton'), + targetCount: $('targetCount'), + targetList: $('targetList'), + layoutCount: $('layoutCount'), + layoutList: $('layoutList'), + captureName: $('captureName'), + captureIdentity: $('captureIdentity'), + warningSummary: $('warningSummary'), + warningCount: $('warningCount'), + viewportLabel: $('viewportLabel'), + canvasFrame: $('canvasFrame'), + previewVideo: $('previewVideo'), + previewCanvas: $('previewCanvas'), + previewPoster: $('previewPoster'), + mediaEmpty: $('mediaEmpty'), + captionLane: $('captionLane'), + captionPreview: $('captionPreview'), + regionLayer: $('regionLayer'), + focusPoint: $('focusPoint'), + profileName: $('profileName'), + profileState: $('profileState'), + profileForm: $('profileForm'), + reviewState: $('reviewState'), + reviewNote: $('reviewNote'), + bottomOffsetRange: $('bottomOffsetRange'), + bottomOffsetNumber: $('bottomOffsetNumber'), + zoomRange: $('zoomRange'), + zoomNumber: $('zoomNumber'), + focusX: $('focusX'), + focusY: $('focusY'), + regionTabs: $('regionTabs'), + deleteRegionButton: $('deleteRegionButton'), + addRegionButton: $('addRegionButton'), + regionEmpty: $('regionEmpty'), + regionFields: $('regionFields'), + regionX: $('regionX'), + regionY: $('regionY'), + regionWidth: $('regionWidth'), + regionHeight: $('regionHeight'), + warningsBadge: $('warningsBadge'), + warningsList: $('warningsList'), + beatSummary: $('beatSummary'), + timeReadout: $('timeReadout'), + beatList: $('beatList'), + notice: $('notice'), + noticeTitle: $('noticeTitle'), + noticeMessage: $('noticeMessage'), + noticeClose: $('noticeClose'), + }; + + const state = { + document: null, + selectedKey: null, + target: null, + profile: null, + selectedRegionId: null, + dirty: false, + busy: false, + }; + + function showNotice(title, message) { + elements.noticeTitle.textContent = title; + elements.noticeMessage.textContent = message; + elements.notice.hidden = false; + } + + function setOperation(label) { + elements.operationState.textContent = label; + } + + function setBusy(busy, label) { + state.busy = busy; + elements.saveButton.disabled = busy || !state.target || !state.dirty; + elements.recaptureButton.disabled = busy || !state.target; + const reviewable = !!(state.target && state.target.reviewable); + const reviewStatus = state.target && state.target.review ? state.target.review.status : 'not-ready'; + elements.requestChangesButton.disabled = busy || !reviewable || reviewStatus === 'changes-requested'; + elements.approveButton.disabled = busy || !reviewable || reviewStatus === 'approved'; + elements.profileForm.toggleAttribute('inert', busy); + if (label) setOperation(label); + } + + function markDirty() { + if (!state.profile || state.busy) return; + state.dirty = true; + delete state.profile.verification; + elements.profileState.textContent = 'Unsaved'; + elements.profileState.className = 'profile-state is-dirty'; + elements.saveButton.disabled = false; + } + + const preview = createPreviewController({ elements, state, markDirty }); + const regions = createRegionEditor({ elements, state, markDirty }); + + function setProfileState() { + if (!state.profile) { + elements.profileState.textContent = 'Read only'; + elements.profileState.className = 'profile-state'; + return; + } + const verified = state.target && state.target.verified; + const persisted = state.target && state.target.hasProfile; + elements.profileState.textContent = verified ? 'Verified' : persisted ? 'Saved' : 'Draft'; + elements.profileState.className = verified ? 'profile-state is-verified' : 'profile-state'; + } + + function renderTargetStatus() { + const status = state.target.status; + const labels = { + approved: 'Approved', + 'awaiting-approval': 'Ready for review', + 'changes-requested': 'Changes requested', + 'needs-fix': 'Needs fix', + blocked: 'Blocked', + 'not-requested': 'Not requested', + }; + elements.targetStatus.textContent = labels[status] || status.replaceAll('-', ' '); + const tone = status === 'approved' ? 'is-ready' + : status === 'not-requested' ? 'is-neutral' + : status === 'changes-requested' || status === 'blocked' ? 'is-error' + : 'is-warning'; + elements.statusDot.className = `status-dot ${tone}`; + } + + function renderReview() { + const review = state.target.review || { status: 'not-ready' }; + const labels = { + approved: 'Approved', + 'awaiting-approval': 'Awaiting decision', + 'changes-requested': 'Changes requested', + 'not-ready': 'Not ready', + }; + elements.reviewState.textContent = labels[review.status] || review.status; + elements.reviewState.className = `review-state ${review.status === 'approved' ? 'is-approved' : review.status === 'changes-requested' ? 'is-changes' : review.status === 'awaiting-approval' ? 'is-awaiting' : ''}`; + elements.reviewNote.value = review.decision && review.decision.note ? review.decision.note : ''; + } + + async function api(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: options.body ? { 'Content-Type': 'application/json', ...(options.headers || {}) } : options.headers, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.ok === false) throw new Error(payload.error || `Request failed (${response.status})`); + return payload; + } + + async function loadState(preferredKey = state.selectedKey) { + setBusy(true, 'Loading'); + try { + const currentUrl = new URL(window.location.href); + const requestedTarget = new URLSearchParams(); + if (currentUrl.searchParams.get('story')) requestedTarget.set('story', currentUrl.searchParams.get('story')); + if (currentUrl.searchParams.get('target')) requestedTarget.set('target', currentUrl.searchParams.get('target')); + const targetQuery = requestedTarget.toString(); + state.document = await api(`/api/state${targetQuery ? `?${targetQuery}` : ''}`); + elements.projectName.textContent = state.document.project || 'Project'; + elements.targetCount.textContent = String(state.document.targets.length); + const preferred = state.document.targets.find((target) => keyFor(target) === preferredKey); + const selected = preferred || state.document.targets.find((target) => ( + state.document.selected && target.story === state.document.selected.story && target.target === state.document.selected.target + )) || state.document.targets[0]; + if (selected) selectTarget(selected, { force: true }); + else renderEmpty(); + setOperation('Ready'); + } catch (error) { + showNotice('Could not load project', error.message); + setOperation('Connection failed'); + } finally { + elements.app.setAttribute('aria-busy', 'false'); + setBusy(false); + } + } + + function renderEmpty() { + state.target = null; + state.profile = null; + elements.targetList.replaceChildren(); + elements.layoutList.replaceChildren(); + elements.captureName.textContent = 'No calibrated target'; + elements.captureIdentity.textContent = 'Add a channel target to shotkit.config.js'; + elements.mediaEmpty.hidden = false; + elements.previewVideo.hidden = true; + elements.recaptureButton.disabled = true; + elements.saveButton.disabled = true; + elements.requestChangesButton.disabled = true; + elements.approveButton.disabled = true; + } + + function selectTarget(target, { force = false } = {}) { + if (!force && state.dirty && !window.confirm('Discard unsaved calibration changes?')) return; + state.target = target; + state.selectedKey = keyFor(target); + state.profile = profileDefaults(target); + state.selectedRegionId = state.profile.protectedRegions[0] && state.profile.protectedRegions[0].id; + state.dirty = false; + renderAll(); + } + + function createTargetButton(target) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'target-item'; + button.dataset.status = target.status; + button.setAttribute('aria-current', keyFor(target) === state.selectedKey ? 'true' : 'false'); + const title = document.createElement('strong'); + title.textContent = target.story; + const subtitle = document.createElement('span'); + subtitle.textContent = target.target; + const dot = document.createElement('i'); + dot.setAttribute('aria-hidden', 'true'); + button.append(title, subtitle, dot); + button.addEventListener('click', () => selectTarget(target)); + return button; + } + + function renderTargets() { + elements.targetList.replaceChildren(...state.document.targets.map(createTargetButton)); + } + + function renderLayouts() { + const layouts = state.target.layouts.length ? state.target.layouts : ['default']; + elements.layoutCount.textContent = String(layouts.length); + const buttons = layouts.map((layout) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'layout-item'; + button.setAttribute('role', 'option'); + button.setAttribute('aria-selected', state.profile.layoutPreset === layout ? 'true' : 'false'); + button.textContent = layout.replaceAll('-', ' '); + button.addEventListener('click', () => { + state.profile.layoutPreset = layout; + markDirty(); + renderLayouts(); + }); + return button; + }); + elements.layoutList.replaceChildren(...buttons); + } + + function renderWarnings() { + const warnings = state.target.warnings || []; + elements.warningCount.textContent = String(warnings.length); + elements.warningSummary.hidden = warnings.length === 0; + elements.warningsBadge.textContent = String(warnings.length); + if (!warnings.length) { + const item = document.createElement('li'); + item.className = 'no-warnings'; + item.textContent = 'No warnings'; + elements.warningsList.replaceChildren(item); + return; + } + elements.warningsList.replaceChildren(...warnings.map((warning) => { + const item = document.createElement('li'); + item.textContent = `${warning.message}${warning.fix ? ` - ${warning.fix}` : ''}`; + return item; + })); + } + + function syncControls() { + const { captionOptions, framing } = state.profile; + const position = document.querySelector(`input[name="captionPosition"][value="${captionOptions.position}"]`); + const appearance = document.querySelector(`input[name="captionAppearance"][value="${captionOptions.appearance}"]`); + if (position) position.checked = true; + if (appearance) appearance.checked = true; + const maxOffset = Math.max(0, state.target.viewport.height - 96); + elements.bottomOffsetRange.max = String(maxOffset); + elements.bottomOffsetNumber.max = String(maxOffset); + elements.bottomOffsetRange.value = String(clamp(captionOptions.bottomOffset, 0, maxOffset)); + elements.bottomOffsetNumber.value = elements.bottomOffsetRange.value; + elements.zoomRange.value = String(framing.scale); + elements.zoomNumber.value = String(framing.scale); + elements.focusX.value = String(Math.round(framing.focusX * 100)); + elements.focusY.value = String(Math.round(framing.focusY * 100)); + } + + function renderAll() { + const target = state.target; + renderTargets(); + renderLayouts(); + preview.setMedia(target); + elements.captureName.textContent = target.name; + elements.captureIdentity.textContent = `${target.story} / ${target.target}`; + elements.viewportLabel.textContent = `${target.viewport.width} x ${target.viewport.height}`; + elements.profileName.textContent = `${target.story} / ${target.target}`; + renderTargetStatus(); + renderReview(); + setProfileState(); + syncControls(); + preview.applyGeometry(); + regions.render(); + renderWarnings(); + preview.renderBeats(); + elements.saveButton.disabled = true; + elements.recaptureButton.disabled = false; + setBusy(state.busy); + } + + function profilePayload() { + return clone(state.profile); + } + + async function saveProfile() { + if (!state.target || !state.dirty) return; + setBusy(true, 'Saving profile'); + try { + const result = await api('/api/profile', { + method: 'POST', + body: JSON.stringify({ + story: state.target.story, + target: state.target.target, + profile: profilePayload(), + }), + }); + state.profile = profileDefaults({ ...state.target, profile: result.profile }); + state.target.profile = clone(result.profile); + state.target.hasProfile = true; + state.target.verified = false; + state.target.status = 'needs-fix'; + state.target.reviewable = false; + state.target.publishable = false; + state.target.review = { status: 'not-ready', stale: true }; + state.dirty = false; + setProfileState(); + renderTargetStatus(); + renderReview(); + renderTargets(); + setOperation('Saved'); + } catch (error) { + showNotice('Profile was not saved', error.message); + setOperation('Save failed'); + } finally { + setBusy(false); + elements.saveButton.disabled = !state.dirty; + } + } + + async function recapture() { + if (!state.target) return; + if (state.dirty || !state.target.hasProfile) { + if (!state.dirty) state.dirty = true; + await saveProfile(); + } + if (state.dirty) return; + const selectedKey = state.selectedKey; + setBusy(true, 'Recapturing with Chromium'); + try { + const result = await api('/api/recapture', { + method: 'POST', + body: JSON.stringify({ story: state.target.story, target: state.target.target }), + }); + setOperation(result.status === 'publish-ready' ? 'Verified' : result.status || 'Finished'); + elements.previewVideo.dataset.source = ''; + await loadState(selectedKey); + setOperation(result.machineStatus === 'publish-ready' ? 'Ready for review' : result.status || 'Finished'); + } catch (error) { + showNotice('Recapture failed', error.message); + setOperation('Recapture failed'); + } finally { + setBusy(false); + } + } + + async function submitReview(status) { + if (!state.target || !state.target.reviewable) return; + const note = elements.reviewNote.value.trim(); + if (status === 'changes-requested' && !note) { + showNotice('Feedback is required', 'Tell the agent what should change before requesting another pass.'); + elements.reviewNote.focus(); + return; + } + const selectedKey = state.selectedKey; + setBusy(true, status === 'approved' ? 'Approving capture' : 'Sending feedback'); + try { + await api('/api/review', { + method: 'POST', + body: JSON.stringify({ + story: state.target.story, + target: state.target.target, + status, + assetDigest: state.target.assetDigest, + ...(state.target.profileHash ? { profileHash: state.target.profileHash } : {}), + ...(status === 'changes-requested' ? { note } : {}), + }), + }); + await loadState(selectedKey); + setOperation(status === 'approved' ? 'Approved by user' : 'Changes requested'); + } catch (error) { + showNotice('Review decision was not saved', error.message); + setOperation('Review failed'); + } finally { + setBusy(false); + } + } + + function bindNumberPair(range, number, apply) { + const update = (source) => { + if (source.value === '' || !Number.isFinite(Number(source.value))) return; + const value = clamp(Number(source.value), Number(source.min), Number(source.max)); + range.value = String(value); + number.value = String(value); + apply(value); + markDirty(); + preview.applyGeometry(); + }; + range.addEventListener('input', () => update(range)); + number.addEventListener('input', () => update(number)); + } + + function bindEvents() { + elements.saveButton.addEventListener('click', saveProfile); + elements.recaptureButton.addEventListener('click', recapture); + elements.requestChangesButton.addEventListener('click', () => submitReview('changes-requested')); + elements.approveButton.addEventListener('click', () => submitReview('approved')); + elements.noticeClose.addEventListener('click', () => { elements.notice.hidden = true; }); + elements.warningSummary.addEventListener('click', () => $('warningsSection').scrollIntoView({ behavior: 'smooth', block: 'start' })); + preview.bind(); + regions.bind(); + + document.querySelectorAll('input[name="captionPosition"]').forEach((input) => { + input.addEventListener('change', () => { + state.profile.captionOptions.position = input.value; + markDirty(); + preview.applyGeometry(); + }); + }); + document.querySelectorAll('input[name="captionAppearance"]').forEach((input) => { + input.addEventListener('change', () => { + state.profile.captionOptions.appearance = input.value; + markDirty(); + preview.applyGeometry(); + }); + }); + bindNumberPair(elements.bottomOffsetRange, elements.bottomOffsetNumber, (value) => { + state.profile.captionOptions.bottomOffset = Math.round(value); + }); + bindNumberPair(elements.zoomRange, elements.zoomNumber, (value) => { + state.profile.framing.scale = round(value, 2); + }); + elements.focusX.addEventListener('input', () => { + if (elements.focusX.value === '' || !Number.isFinite(Number(elements.focusX.value))) return; + state.profile.framing.focusX = clamp(Number(elements.focusX.value) / 100, 0, 1); + markDirty(); + preview.applyGeometry(); + }); + elements.focusY.addEventListener('input', () => { + if (elements.focusY.value === '' || !Number.isFinite(Number(elements.focusY.value))) return; + state.profile.framing.focusY = clamp(Number(elements.focusY.value) / 100, 0, 1); + markDirty(); + preview.applyGeometry(); + }); + } + + bindEvents(); + loadState(); +})(); diff --git a/calibrator/index.html b/calibrator/index.html new file mode 100644 index 0000000..6fbfdbd --- /dev/null +++ b/calibrator/index.html @@ -0,0 +1,274 @@ + + + + + + + Shotkit Calibrator + + + +
+
+
+ +
+ Shotkit Calibrator + Loading project +
+
+ +
+ + Loading + Connecting +
+ +
+ + + + + + + +
+
+ +
+ + +
+
+
+ No target selected + -- +
+
+ + -- x -- +
+
+ +
+
+ + + +
+ + No capture available +
+ + + +
+ Caption preview +
+ +
+ +
+
+
+ + + +
+
+
+

Beats

+ 0 markers +
+ 00:00 / 00:00 +
+
+
+
+
+ + + + + + + + diff --git a/calibrator/model.js b/calibrator/model.js new file mode 100644 index 0000000..1aff736 --- /dev/null +++ b/calibrator/model.js @@ -0,0 +1,44 @@ +export function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +export function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +export function round(value, digits = 0) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +export function keyFor(target) { + return `${target.story}::${target.target}`; +} + +export function profileDefaults(target) { + const style = target.captionStyle || {}; + const source = clone(target.profile || {}); + return { + layoutPreset: source.layoutPreset || target.layouts[0] || 'default', + framing: { + scale: source.framing && Number.isFinite(source.framing.scale) ? source.framing.scale : 1, + focusX: source.framing && Number.isFinite(source.framing.focusX) ? source.framing.focusX : 0.5, + focusY: source.framing && Number.isFinite(source.framing.focusY) ? source.framing.focusY : 0.5, + }, + captionOptions: { + position: source.captionOptions && source.captionOptions.position || style.position || 'bottom-left', + appearance: source.captionOptions && source.captionOptions.appearance || style.appearance || 'panel', + bottomOffset: source.captionOptions && Number.isInteger(source.captionOptions.bottomOffset) + ? source.captionOptions.bottomOffset + : Number.isInteger(style.bottomOffset) ? style.bottomOffset : 80, + }, + protectedRegions: Array.isArray(source.protectedRegions) ? source.protectedRegions : [], + ...(source.verification ? { verification: source.verification } : {}), + }; +} + +export function formatClock(seconds) { + if (!Number.isFinite(seconds)) return '00:00'; + const value = Math.max(0, Math.floor(seconds)); + return `${String(Math.floor(value / 60)).padStart(2, '0')}:${String(value % 60).padStart(2, '0')}`; +} diff --git a/calibrator/preview.js b/calibrator/preview.js new file mode 100644 index 0000000..0255a32 --- /dev/null +++ b/calibrator/preview.js @@ -0,0 +1,197 @@ +import { clamp, formatClock } from './model.js'; + +export function createPreviewController({ elements, state, markDirty }) { + let frameRequest = null; + + function setMedia(target) { + const cacheKey = target.videoUrl ? `${target.videoUrl}${target.videoUrl.includes('?') ? '&' : '?'}v=${Date.now()}` : ''; + if (target.videoUrl) { + if (elements.previewVideo.dataset.source !== target.videoUrl) { + elements.previewCanvas.hidden = true; + elements.previewVideo.src = cacheKey; + elements.previewVideo.dataset.source = target.videoUrl; + elements.previewVideo.poster = target.thumbnailUrl || ''; + } + elements.previewVideo.hidden = false; + elements.previewPoster.src = target.thumbnailUrl || ''; + elements.previewPoster.hidden = !target.thumbnailUrl; + elements.mediaEmpty.hidden = true; + } else if (target.thumbnailUrl) { + elements.previewPoster.src = target.thumbnailUrl; + elements.previewPoster.hidden = false; + elements.previewVideo.hidden = true; + elements.previewCanvas.hidden = true; + elements.mediaEmpty.hidden = true; + } else { + elements.previewVideo.hidden = true; + elements.previewPoster.hidden = true; + elements.previewCanvas.hidden = true; + elements.mediaEmpty.hidden = false; + } + } + + function fitCanvas() { + if (!state.target) return; + const surface = elements.canvasFrame.parentElement; + const surfaceStyle = window.getComputedStyle(surface); + const canvasStyle = window.getComputedStyle(elements.canvasFrame); + const pixelLimit = (value) => value.endsWith('px') ? parseFloat(value) : Infinity; + const availableWidth = surface.clientWidth + - parseFloat(surfaceStyle.paddingLeft) + - parseFloat(surfaceStyle.paddingRight); + const availableHeight = surface.clientHeight + - parseFloat(surfaceStyle.paddingTop) + - parseFloat(surfaceStyle.paddingBottom); + const boundedWidth = Math.min(availableWidth, pixelLimit(canvasStyle.maxWidth)); + const boundedHeight = Math.min(availableHeight, pixelLimit(canvasStyle.maxHeight)); + const ratio = state.target.viewport.width / state.target.viewport.height; + const width = Math.max(1, Math.min(boundedWidth, boundedHeight * ratio)); + elements.canvasFrame.style.width = `${width}px`; + elements.canvasFrame.style.height = `${width / ratio}px`; + } + + function applyGeometry() { + const { width, height } = state.target.viewport; + const { scale, focusX, focusY } = state.profile.framing; + elements.canvasFrame.style.setProperty('--canvas-ratio', `${width} / ${height}`); + fitCanvas(); + for (const media of [elements.previewVideo, elements.previewPoster, elements.previewCanvas]) { + media.style.transformOrigin = `${focusX * 100}% ${focusY * 100}%`; + media.style.transform = `scale(${scale})`; + } + elements.focusPoint.style.left = `calc(${focusX * 100}% - 14px)`; + elements.focusPoint.style.top = `calc(${focusY * 100}% - 14px)`; + + const safe = state.target.safeArea; + const actionGuide = elements.canvasFrame.querySelector('.action-safe'); + actionGuide.style.left = `${safe.x / width * 100}%`; + actionGuide.style.top = `${safe.y / height * 100}%`; + actionGuide.style.width = `${safe.width / width * 100}%`; + actionGuide.style.height = `${safe.height / height * 100}%`; + + const caption = state.profile.captionOptions; + elements.captionLane.dataset.position = caption.position; + elements.captionLane.dataset.appearance = caption.appearance; + elements.captionLane.style.bottom = `${caption.bottomOffset / height * 100}%`; + } + + function updateActiveBeat() { + const current = elements.previewVideo.currentTime || 0; + const buttons = Array.from(elements.beatList.querySelectorAll('.beat-item')); + let active = null; + for (const button of buttons) { + if (Number(button.dataset.at) <= current) active = button; + button.classList.remove('is-active'); + } + if (active) active.classList.add('is-active'); + elements.timeReadout.textContent = `${formatClock(current)} / ${formatClock(elements.previewVideo.duration)}`; + } + + function renderBeats() { + const beats = state.target.beats || []; + elements.beatSummary.textContent = `${beats.length} marker${beats.length === 1 ? '' : 's'}`; + elements.beatList.replaceChildren(...beats.map((beat) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'beat-item'; + button.dataset.at = String(beat.at); + const time = document.createElement('time'); + time.textContent = formatClock(beat.at); + const label = document.createElement('span'); + label.textContent = beat.text; + button.append(time, label); + button.addEventListener('click', () => { + elements.previewVideo.currentTime = beat.at; + elements.previewVideo.pause(); + elements.captionPreview.textContent = beat.text; + updateActiveBeat(); + }); + return button; + })); + elements.captionPreview.textContent = beats[0] ? beats[0].text : 'Caption lane'; + } + + function drawFrame() { + const video = elements.previewVideo; + if (video.hidden || video.readyState < 2 || !video.videoWidth || !video.videoHeight) return; + const canvas = elements.previewCanvas; + if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + } + const context = canvas.getContext('2d'); + if (!context) return; + try { + context.drawImage(video, 0, 0, canvas.width, canvas.height); + } catch (_error) { + return; + } + canvas.hidden = false; + } + + function drawPlayingFrames() { + drawFrame(); + if (!elements.previewVideo.paused && !elements.previewVideo.ended) { + frameRequest = window.requestAnimationFrame(drawPlayingFrames); + } else { + frameRequest = null; + } + } + + function startFrames() { + if (frameRequest != null) window.cancelAnimationFrame(frameRequest); + frameRequest = window.requestAnimationFrame(drawPlayingFrames); + } + + function primeFrame() { + const firstBeat = state.target && state.target.beats && state.target.beats[0]; + if (elements.previewVideo.poster) { + updateActiveBeat(); + } else if (elements.previewVideo.currentTime < .05 && firstBeat && Number.isFinite(firstBeat.at)) { + elements.previewVideo.currentTime = clamp(firstBeat.at, 0, elements.previewVideo.duration || firstBeat.at); + } else { + drawFrame(); + } + updateActiveBeat(); + } + + function startFocusDrag(event) { + if (event.button !== 0) return; + event.preventDefault(); + const move = (next) => { + const rect = elements.canvasFrame.getBoundingClientRect(); + state.profile.framing.focusX = clamp((next.clientX - rect.left) / rect.width, 0, 1); + state.profile.framing.focusY = clamp((next.clientY - rect.top) / rect.height, 0, 1); + elements.focusX.value = Math.round(state.profile.framing.focusX * 100); + elements.focusY.value = Math.round(state.profile.framing.focusY * 100); + markDirty(); + applyGeometry(); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up, { once: true }); + } + + function bind() { + elements.previewVideo.addEventListener('timeupdate', () => { + updateActiveBeat(); + if (elements.previewVideo.paused) drawFrame(); + }); + elements.previewVideo.addEventListener('loadedmetadata', primeFrame); + elements.previewVideo.addEventListener('loadeddata', drawFrame); + elements.previewVideo.addEventListener('seeked', drawFrame); + elements.previewVideo.addEventListener('seeking', () => { elements.previewPoster.hidden = true; }); + elements.previewVideo.addEventListener('play', () => { + elements.previewPoster.hidden = true; + startFrames(); + }); + elements.previewVideo.addEventListener('pause', drawFrame); + elements.focusPoint.addEventListener('pointerdown', startFocusDrag); + window.addEventListener('resize', fitCanvas); + } + + return { applyGeometry, bind, fitCanvas, renderBeats, setMedia }; +} diff --git a/calibrator/regions.js b/calibrator/regions.js new file mode 100644 index 0000000..88b253b --- /dev/null +++ b/calibrator/regions.js @@ -0,0 +1,138 @@ +import { clamp, round } from './model.js'; + +export function createRegionEditor({ elements, state, markDirty }) { + function current() { + return state.profile.protectedRegions.find((region) => region.id === state.selectedRegionId) || null; + } + + function select(id) { + state.selectedRegionId = id; + render(); + } + + function renderInspector() { + const { width, height } = state.target.viewport; + const tabs = state.profile.protectedRegions.map((region, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'region-tab'; + button.setAttribute('role', 'tab'); + button.setAttribute('aria-selected', region.id === state.selectedRegionId ? 'true' : 'false'); + button.title = region.label || region.id; + button.textContent = String(index + 1); + button.addEventListener('click', () => select(region.id)); + return button; + }); + elements.regionTabs.replaceChildren(...tabs); + const region = current(); + elements.regionEmpty.hidden = !!region; + elements.regionFields.hidden = !region; + elements.deleteRegionButton.disabled = !region; + elements.addRegionButton.disabled = state.profile.protectedRegions.length >= 3; + if (!region) return; + elements.regionX.value = round(region.x / width * 100, 1); + elements.regionY.value = round(region.y / height * 100, 1); + elements.regionWidth.value = round(region.width / width * 100, 1); + elements.regionHeight.value = round(region.height / height * 100, 1); + } + + function startDrag(event, id, resize) { + if (event.button !== 0) return; + event.preventDefault(); + select(id); + const region = current(); + const start = { x: event.clientX, y: event.clientY, region: { ...region } }; + const rect = elements.canvasFrame.getBoundingClientRect(); + const viewport = state.target.viewport; + const move = (next) => { + const dx = (next.clientX - start.x) / rect.width * viewport.width; + const dy = (next.clientY - start.y) / rect.height * viewport.height; + if (resize) { + region.width = clamp(start.region.width + dx, 16, viewport.width - region.x); + region.height = clamp(start.region.height + dy, 16, viewport.height - region.y); + } else { + region.x = clamp(start.region.x + dx, 0, viewport.width - region.width); + region.y = clamp(start.region.y + dy, 0, viewport.height - region.height); + } + markDirty(); + render(); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up, { once: true }); + } + + function render() { + const { width, height } = state.target.viewport; + const nodes = state.profile.protectedRegions.map((region) => { + const node = document.createElement('div'); + node.className = `protected-region${region.id === state.selectedRegionId ? ' is-selected' : ''}`; + node.dataset.id = region.id; + node.style.left = `${region.x / width * 100}%`; + node.style.top = `${region.y / height * 100}%`; + node.style.width = `${region.width / width * 100}%`; + node.style.height = `${region.height / height * 100}%`; + node.tabIndex = 0; + node.setAttribute('role', 'button'); + node.setAttribute('aria-label', `Protected region ${region.label || region.id}`); + const label = document.createElement('span'); + label.className = 'protected-region-label'; + label.textContent = region.label || region.id; + const handle = document.createElement('span'); + handle.className = 'resize-handle'; + handle.setAttribute('aria-hidden', 'true'); + node.append(label, handle); + node.addEventListener('pointerdown', (event) => startDrag(event, region.id, event.target === handle)); + node.addEventListener('focus', () => select(region.id)); + return node; + }); + elements.regionLayer.replaceChildren(...nodes); + renderInspector(); + } + + function updateFromFields() { + const region = current(); + if (!region) return; + const inputs = [elements.regionX, elements.regionY, elements.regionWidth, elements.regionHeight]; + if (inputs.some((input) => input.value === '' || !Number.isFinite(Number(input.value)))) return; + const { width, height } = state.target.viewport; + region.x = clamp(Number(elements.regionX.value) / 100 * width, 0, width - region.width); + region.y = clamp(Number(elements.regionY.value) / 100 * height, 0, height - region.height); + region.width = clamp(Number(elements.regionWidth.value) / 100 * width, 16, width - region.x); + region.height = clamp(Number(elements.regionHeight.value) / 100 * height, 16, height - region.y); + markDirty(); + render(); + } + + function bind() { + [elements.regionX, elements.regionY, elements.regionWidth, elements.regionHeight] + .forEach((input) => input.addEventListener('input', updateFromFields)); + elements.addRegionButton.addEventListener('click', () => { + if (state.profile.protectedRegions.length >= 3) return; + const { width, height } = state.target.viewport; + const id = `region-${Date.now().toString(36)}`; + state.profile.protectedRegions.push({ + id, + label: `Protected ${state.profile.protectedRegions.length + 1}`, + x: width * .2, + y: height * .2, + width: width * .45, + height: height * .25, + }); + state.selectedRegionId = id; + markDirty(); + render(); + }); + elements.deleteRegionButton.addEventListener('click', () => { + state.profile.protectedRegions = state.profile.protectedRegions.filter((region) => region.id !== state.selectedRegionId); + state.selectedRegionId = state.profile.protectedRegions[0] && state.profile.protectedRegions[0].id; + markDirty(); + render(); + }); + } + + return { bind, render }; +} diff --git a/calibrator/styles.css b/calibrator/styles.css new file mode 100644 index 0000000..44ce603 --- /dev/null +++ b/calibrator/styles.css @@ -0,0 +1,391 @@ +:root { + color-scheme: light; + --toolbar-h: 52px; + --left-w: 224px; + --right-w: 318px; + --beat-h: 108px; + --ink: #202124; + --muted: #6f7479; + --line: #d9dcdf; + --panel: #f7f8f8; + --surface: #ffffff; + --stage: #252729; + --accent: #e15f47; + --accent-soft: #fff0ec; + --focus: #2563eb; + --good: #247a4d; + --warn: #a66300; + --bad: #b42318; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +* { box-sizing: border-box; } + +html, +body { margin: 0; min-height: 100%; } + +body { + min-width: 280px; + background: var(--panel); + color: var(--ink); + font-size: 13px; + letter-spacing: 0; + overflow: hidden; +} + +button, +input { font: inherit; letter-spacing: 0; } + +button { color: inherit; } +a { color: inherit; text-decoration: none; } + +.app { min-height: 100dvh; } + +.toolbar { + position: relative; + z-index: 20; + display: grid; + grid-template-columns: minmax(210px, 1fr) auto minmax(210px, 1fr); + align-items: center; + height: var(--toolbar-h); + padding: 0 12px; + border-bottom: 1px solid #cfd2d5; + background: var(--surface); +} + +.brand-lockup, +.toolbar-status, +.toolbar-actions, +.brand-copy, +.stage-title, +.stage-meta, +.section-heading, +.warnings-heading, +.field-label-row, +.control-heading, +.region-toolbar, +.beat-header, +.beat-header > div { display: flex; align-items: center; } + +.brand-lockup { min-width: 0; gap: 9px; } + +.brand-mark, +.empty-mark { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid #252729; + border-radius: 5px; + background: #252729; + color: #fff; + font-size: 10px; + font-weight: 800; +} + +.brand-copy { min-width: 0; flex-direction: column; align-items: flex-start; line-height: 1.1; } +.brand-copy strong { font-size: 13px; } +.brand-copy span { max-width: 220px; color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.toolbar-status { justify-self: center; gap: 7px; white-space: nowrap; } +.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #9aa0a6; } +.status-dot.is-ready { background: var(--good); } +.status-dot.is-warning { background: var(--warn); } +.status-dot.is-error { background: var(--bad); } +.target-status { font-weight: 700; text-transform: capitalize; } +.operation-state { padding-left: 7px; border-left: 1px solid var(--line); color: var(--muted); font-size: 11px; } + +.toolbar-actions { justify-self: end; gap: 7px; } +.button, +.icon-button, +.warning-summary, +.notice-close { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 5px; + cursor: pointer; +} +.button { min-height: 32px; gap: 6px; padding: 0 11px; font-weight: 700; } +.button-primary { border-color: #c94e38; background: var(--accent); color: #fff; } +.button-secondary { border-color: var(--line); background: var(--surface); } +.button-review { border-color: #d6a03a; background: #fff8e8; color: #725000; } +.button-success { border-color: #1f6a43; background: var(--good); color: #fff; } +.button:hover:not(:disabled), +.icon-button:hover:not(:disabled) { filter: brightness(.96); } +button:disabled { cursor: not-allowed; opacity: .45; } + +.icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; } + +.workspace { + display: grid; + grid-template-columns: var(--left-w) minmax(0, 1fr) var(--right-w); + grid-template-rows: minmax(0, calc(100dvh - var(--toolbar-h) - var(--beat-h))) var(--beat-h); + height: calc(100dvh - var(--toolbar-h)); +} + +.left-rail, +.inspector { + min-height: 0; + background: var(--panel); + overflow-y: auto; +} +.left-rail { grid-row: 1 / 3; border-right: 1px solid var(--line); } +.inspector { grid-column: 3; grid-row: 1 / 3; border-left: 1px solid var(--line); } +.rail-section { padding: 15px 12px; border-bottom: 1px solid var(--line); } +.section-heading { justify-content: space-between; margin-bottom: 9px; } +.section-heading h2, +.stage-title strong, +.inspector-header h2, +.warnings-heading h3, +.beat-header h2 { margin: 0; font-size: 12px; line-height: 1.2; } +.section-heading span, +.warnings-heading span { color: var(--muted); font-size: 11px; } + +.target-list, +.layout-list { display: grid; gap: 5px; } +.target-item, +.layout-item { + width: 100%; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + text-align: left; + cursor: pointer; +} +.target-item { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; padding: 9px; } +.target-item strong { min-width: 0; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.target-item span { grid-column: 1; color: var(--muted); font-size: 10px; } +.target-item i { grid-column: 2; grid-row: 1 / 3; align-self: center; width: 7px; height: 7px; border-radius: 50%; background: #9aa0a6; } +.target-item[data-status="publish-ready"] i, +.target-item[data-status="approved"] i { background: var(--good); } +.target-item[data-status="needs-fix"] i, +.target-item[data-status="awaiting-approval"] i { background: var(--warn); } +.target-item[data-status="changes-requested"] i, +.target-item[data-status="blocked"] i { background: var(--bad); } +.target-item[aria-current="true"], +.layout-item[aria-selected="true"] { border-color: #ef9c8c; background: var(--accent-soft); } +.layout-item { position: relative; padding: 8px 9px 8px 24px; font-size: 12px; } +.layout-item::before { content: ""; position: absolute; left: 9px; top: 10px; width: 7px; height: 11px; border: 1px solid currentColor; border-radius: 2px; } +.layout-item[aria-selected="true"] { color: #9d3422; font-weight: 700; } +.rail-placeholder { height: 48px; border-radius: 5px; background: #e8eaec; animation: pulse 1.2s ease-in-out infinite alternate; } +@keyframes pulse { to { opacity: .55; } } + +.stage-panel { grid-column: 2; grid-row: 1; display: grid; grid-template-rows: 42px minmax(0, 1fr); min-width: 0; min-height: 0; background: var(--stage); } +.stage-header { display: flex; align-items: center; justify-content: space-between; width: 100%; min-width: 0; padding: 0 13px; border-bottom: 1px solid #424548; background: #303336; color: #f5f6f6; } +.stage-title { min-width: 0; gap: 8px; } +.stage-title strong { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.stage-title span, +.viewport-label { color: #b8bdc1; font-size: 11px; } +.stage-meta { gap: 8px; } +.warning-summary { gap: 4px; height: 25px; padding: 0 7px; border-color: #725d2c; background: #4a4028; color: #ffd980; } + +.stage-surface { + position: relative; + display: grid; + place-items: center; + width: 100%; + min-width: 0; + min-height: 0; + padding: 18px; + overflow: hidden; + background: var(--stage); +} + +.canvas-frame { + --canvas-ratio: 9 / 16; + position: relative; + max-width: 100%; + max-height: 100%; + aspect-ratio: var(--canvas-ratio); + overflow: hidden; + background: #eef0f1; + box-shadow: 0 12px 36px rgba(0,0,0,.28); + touch-action: none; +} + +#previewVideo, +.preview-poster, +.preview-canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transform-origin: 50% 50%; } +#previewVideo { background: #111; } +.preview-poster, +.preview-canvas { z-index: 1; clip-path: inset(0 0 46px 0); pointer-events: none; } +.media-empty { position: absolute; inset: 0; z-index: 2; display: grid; place-content: center; justify-items: center; gap: 9px; color: var(--muted); } +.media-empty .empty-mark { width: 38px; height: 38px; } + +.safe-guides, +.region-layer { position: absolute; inset: 0; z-index: 4; pointer-events: none; } +.safe-guide { position: absolute; border: 1px dashed rgba(255,255,255,.68); box-shadow: 0 0 0 1px rgba(0,0,0,.16); } +.safe-guide span { position: absolute; left: 4px; top: 3px; padding: 1px 4px; border-radius: 3px; background: rgba(24,26,28,.72); color: #fff; font-size: 8px; } +.title-safe { inset: 7% 9% 18%; } +.action-safe { inset: 12% 17% 25% 6%; border-color: rgba(255,210,83,.85); } + +.caption-lane { + position: absolute; + z-index: 6; + left: 7%; + bottom: 22%; + max-width: 76%; + padding: 7px 10px; + border-radius: 5px; + background: rgba(24,26,28,.84); + color: #fff; + font-size: 22px; + font-weight: 850; + line-height: 1.08; + text-align: center; + pointer-events: none; +} +.caption-lane[data-position="bottom"] { left: 50%; transform: translateX(-50%); } +.caption-lane[data-appearance="outline"] { padding-inline: 0; background: transparent; text-shadow: 0 1px 0 #111, 1px 0 0 #111, -1px 0 0 #111, 0 -1px 0 #111, 0 3px 8px rgba(0,0,0,.45); } + +.protected-region { position: absolute; min-width: 18px; min-height: 18px; border: 2px solid #ff7a61; background: rgba(225,95,71,.13); color: #fff; pointer-events: auto; cursor: move; } +.protected-region.is-selected { border-color: #ffd253; background: rgba(255,210,83,.12); } +.protected-region-label { position: absolute; left: -2px; top: -20px; max-width: 130px; padding: 2px 5px; border-radius: 3px 3px 0 0; background: #d64f37; font-size: 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.protected-region.is-selected .protected-region-label { background: #9a6c00; } +.resize-handle { position: absolute; right: -5px; bottom: -5px; width: 11px; height: 11px; border: 2px solid #fff; background: #e15f47; cursor: nwse-resize; } + +.focus-point { position: absolute; z-index: 7; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 50%; background: rgba(37,99,235,.16); cursor: crosshair; touch-action: none; } +.focus-point::before, +.focus-point::after { content: ""; position: absolute; background: #2563eb; } +.focus-point::before { left: 13px; top: 2px; width: 2px; height: 24px; } +.focus-point::after { left: 2px; top: 13px; width: 24px; height: 2px; } +.focus-point span { position: absolute; inset: 8px; border: 2px solid #fff; border-radius: 50%; } + +.inspector-header { position: sticky; top: 0; z-index: 4; display: flex; align-items: center; justify-content: space-between; height: 46px; padding: 0 14px; border-bottom: 1px solid var(--line); background: var(--surface); } +.inspector-header > div { min-width: 0; } +.inspector-header h2 { font-size: 13px; } +.inspector-header span { color: var(--muted); font-size: 10px; } +.profile-state { padding: 3px 6px; border-radius: 4px; background: #e8eaec; } +.profile-state.is-dirty { background: #fff0c7; color: #785000; } +.profile-state.is-verified { background: #dff3e7; color: #17623c; } + +.review-section { background: var(--surface); } +.review-heading { display: flex; align-items: center; justify-content: space-between; } +.review-heading h3 { margin: 0; font-size: 12px; } +.review-state { padding: 3px 6px; border-radius: 4px; background: #e8eaec; color: var(--muted); font-size: 10px; } +.review-state.is-awaiting { background: #fff0c7; color: #785000; } +.review-state.is-approved { background: #dff3e7; color: #17623c; } +.review-state.is-changes { background: #fde4df; color: #8c2f22; } +#reviewNote { width: 100%; min-height: 68px; resize: vertical; border: 1px solid #cfd2d5; border-radius: 4px; padding: 7px 8px; background: var(--surface); color: var(--ink); font: inherit; line-height: 1.35; letter-spacing: 0; } + +#profileForm { min-width: 0; } +.inspector-section { margin: 0; padding: 14px; border: 0; border-bottom: 1px solid var(--line); } +fieldset.inspector-section legend { float: left; width: 100%; margin-bottom: 12px; padding: 0; font-weight: 800; } +fieldset.inspector-section legend + * { clear: both; } +.field-label, +.field-label-row, +.control-heading { margin: 12px 0 6px; color: #4e5357; font-size: 11px; font-weight: 700; } +.field-label-row, +.control-heading { justify-content: space-between; } +.field-label-row span, +.control-heading span { color: var(--muted); font-weight: 500; } + +.segmented-control { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid #cfd2d5; border-radius: 5px; overflow: hidden; background: #eceeef; } +.segmented-control input { position: absolute; opacity: 0; pointer-events: none; } +.segmented-control label { display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 4px; border-right: 1px solid #cfd2d5; cursor: pointer; font-size: 11px; text-align: center; } +.segmented-control label:last-child { border-right: 0; } +.segmented-control input:checked + label { background: var(--surface); color: #9d3422; font-weight: 800; box-shadow: inset 0 -2px var(--accent); } +.appearance-swatch { width: 15px; height: 10px; border-radius: 2px; } +.panel-swatch { background: #292b2d; } +.outline-swatch { border: 1px solid #292b2d; background: #fff; } + +.range-control { display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 8px; align-items: center; } +input[type="range"] { width: 100%; accent-color: var(--accent); } +input[type="number"] { width: 100%; height: 30px; border: 1px solid #cfd2d5; border-radius: 4px; background: var(--surface); padding: 0 6px; color: var(--ink); } +.coordinate-grid, +.region-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; } +.coordinate-grid label, +.region-fields label { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 4px; color: var(--muted); font-size: 10px; } + +.region-toolbar { justify-content: space-between; gap: 8px; } +.region-tabs { display: flex; min-width: 0; gap: 4px; overflow-x: auto; } +.region-tab { min-width: 28px; height: 28px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); cursor: pointer; font-size: 10px; } +.region-tab[aria-selected="true"] { border-color: var(--accent); color: #9d3422; font-weight: 800; } +.region-actions { display: flex; gap: 4px; } +.icon-button { width: 28px; height: 28px; padding: 0; border-color: var(--line); background: var(--surface); } +.toolbar-link { width: 32px; height: 32px; } +.region-empty { padding: 14px 0 3px; color: var(--muted); font-size: 11px; text-align: center; } +.region-fields { margin-top: 10px; } + +.warnings-section { background: #fffdf5; } +.warnings-heading { justify-content: space-between; } +.warnings-list { display: grid; gap: 6px; margin: 9px 0 0; padding: 0; list-style: none; } +.warnings-list li { padding-left: 10px; border-left: 2px solid #e0a11a; color: #5d4b20; font-size: 11px; line-height: 1.35; } +.warnings-list .no-warnings { border-left-color: var(--good); color: var(--good); } + +.beat-strip { grid-column: 2; grid-row: 2; display: grid; grid-template-rows: 34px minmax(0, 1fr); min-width: 0; border-top: 1px solid var(--line); background: var(--surface); } +.beat-header { justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #eceeef; } +.beat-header > div { gap: 7px; } +.beat-header span, +.beat-header output { color: var(--muted); font-size: 10px; } +.beat-list { display: flex; align-items: stretch; gap: 1px; min-width: 0; padding: 8px 10px; overflow-x: auto; } +.beat-item { position: relative; flex: 1 0 110px; min-width: 110px; max-width: 220px; padding: 6px 8px 5px; border: 0; border-left: 3px solid #e6a32a; background: #f3f4f4; cursor: pointer; text-align: left; } +.beat-item:hover, +.beat-item.is-active { background: #fff1ed; } +.beat-item time { display: block; margin-bottom: 2px; color: var(--muted); font-size: 9px; } +.beat-item span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; font-weight: 700; } + +.notice { position: fixed; z-index: 50; right: 16px; bottom: 16px; display: grid; grid-template-columns: 1fr auto; gap: 3px 12px; width: min(360px, calc(100vw - 32px)); padding: 11px 12px; border: 1px solid #edb6aa; border-radius: 6px; background: #fff5f2; box-shadow: 0 12px 30px rgba(0,0,0,.16); color: #7e2e20; } +.notice strong { font-size: 12px; } +.notice span { grid-column: 1; font-size: 11px; line-height: 1.35; } +.notice-close { grid-column: 2; grid-row: 1 / 3; align-self: start; width: 24px; height: 24px; border-color: transparent; background: transparent; font-size: 18px; } + +[hidden] { display: none !important; } + +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +@media (max-width: 980px) { + :root { --left-w: 184px; --right-w: 280px; } + .button-label { display: none; } + .button { width: 34px; padding: 0; } + .brand-copy span { max-width: 130px; } +} + +@media (max-width: 760px) { + body { overflow: auto; } + .app { min-height: 100dvh; } + .toolbar { position: sticky; top: 0; grid-template-columns: 1fr auto; height: 58px; } + .toolbar-status { grid-column: 1 / 3; grid-row: 2; justify-self: start; margin: -14px 0 2px 37px; transform: scale(.9); transform-origin: left center; } + .toolbar-actions { grid-column: 2; grid-row: 1; } + .workspace { display: flex; flex-direction: column; height: auto; min-height: calc(100dvh - 58px); } + .left-rail, + .inspector, + .stage-panel, + .beat-strip { width: 100%; border: 0; } + .left-rail { order: 1; overflow: visible; } + .target-section, + .layout-section { padding: 9px 10px; } + .target-list, + .layout-list { display: flex; overflow-x: auto; } + .target-item { flex: 0 0 160px; } + .layout-item { flex: 0 0 130px; } + .stage-panel { order: 2; min-height: 620px; grid-template-rows: 42px 1fr; } + .stage-surface { min-height: 578px; padding: 12px; } + .canvas-frame { max-width: 326px; max-height: 554px; } + .caption-lane { font-size: 18px; } + .beat-strip { order: 3; min-height: 104px; } + .inspector { order: 4; overflow: visible; } + .inspector-header { top: 58px; } + .coordinate-grid, + .region-fields { grid-template-columns: repeat(4, minmax(0, 1fr)); } +} + +@media (max-width: 420px) { + .toolbar { padding-inline: 8px; } + .toolbar-actions { gap: 3px; } + .toolbar-actions .button, + .toolbar-link { width: 29px; min-height: 29px; } + .brand-lockup { gap: 5px; } + .brand-mark { width: 25px; height: 25px; } + .brand-copy strong { font-size: 12px; } + .brand-copy span { max-width: 92px; } + .toolbar-status { max-width: 210px; overflow: hidden; } + .stage-panel { min-height: 590px; } + .stage-surface { min-height: 548px; } + .canvas-frame { width: min(294px, 100%); max-width: 100%; max-height: 522px; } + .coordinate-grid, + .region-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} diff --git a/campaign/api.js b/campaign/api.js new file mode 100644 index 0000000..fbb674c --- /dev/null +++ b/campaign/api.js @@ -0,0 +1,53 @@ +export async function requestJson(url, options = {}, fetchImpl = globalThis.fetch) { + const hasBody = Object.prototype.hasOwnProperty.call(options, 'body'); + const body = hasBody && typeof options.body !== 'string' + ? JSON.stringify(options.body) + : options.body; + const response = await fetchImpl(url, { + ...options, + ...(hasBody ? { body } : {}), + headers: hasBody + ? { 'Content-Type': 'application/json', ...(options.headers || {}) } + : options.headers, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.ok === false) { + throw new Error(payload.error || `Request failed (${response.status})`); + } + return payload; +} + +export function buildRunPayload(recipeId) { + return { recipeId }; +} + +export function buildReviewPayload({ recipeId, targets, status, note = '' }) { + return { + recipeId, + candidates: targets.map((target) => ({ + target: target.id, + assetDigest: target.assetDigest, + ...(target.profileHash ? { profileHash: target.profileHash } : {}), + })), + status, + ...(status === 'changes-requested' ? { note } : {}), + }; +} + +export function loadCampaign() { + return requestJson('/api/campaign'); +} + +export function startCampaign(recipeId) { + return requestJson('/api/campaign/run', { + method: 'POST', + body: buildRunPayload(recipeId), + }); +} + +export function submitCampaignReview(payload) { + return requestJson('/api/campaign/review', { + method: 'POST', + body: payload, + }); +} diff --git a/campaign/app.js b/campaign/app.js new file mode 100644 index 0000000..68ba41d --- /dev/null +++ b/campaign/app.js @@ -0,0 +1,140 @@ +import { + activeTarget, + createCampaignState, + initializeSelection, + selectRecipeInState, + selectedRecipe, + selectedTargets, + setCampaignView, + shouldPoll, +} from './model.js'; +import { + buildReviewPayload, + loadCampaign, + startCampaign, + submitCampaignReview, +} from './api.js'; +import { createCampaignRenderer } from './render.js'; + +const state = createCampaignState(); +const renderer = createCampaignRenderer({ + state, + onSelectRecipe: selectRecipe, + onSelectTarget: selectTarget, +}); +const { elements } = renderer; + +function setBusy(busy, label) { + state.busy = busy; + renderer.renderBusy(); + if (label) renderer.setConnection(label, busy ? 'busy' : 'ready'); +} + +function selectRecipe(recipeId) { + if (!selectRecipeInState(state, recipeId)) return; + renderer.renderPlan(); + renderer.renderNavigation(); +} + +function selectTarget(targetId) { + state.activeTargetId = targetId; + renderer.renderReview(); +} + +function setView(view) { + if (!setCampaignView(state, view)) return; + renderer.renderView(); + schedulePoll(); +} + +async function loadState({ followPhase = true } = {}) { + try { + state.document = await loadCampaign(); + renderer.renderDocumentChrome(); + initializeSelection(state); + if (followPhase) { + setCampaignView(state, state.document.phase === 'complete' ? 'review' : state.document.phase); + } + renderer.render(); + const running = state.document.run.status === 'running'; + renderer.setConnection(running ? 'Producing' : 'Local', running ? 'busy' : 'ready'); + } catch (error) { + renderer.setConnection('Offline', 'error'); + renderer.showNotice('Could not load campaign', error.message); + } finally { + renderer.markLoaded(); + schedulePoll(); + } +} + +function schedulePoll() { + if (state.pollTimer) window.clearTimeout(state.pollTimer); + state.pollTimer = null; + if (!shouldPoll(state)) return; + state.pollTimer = window.setTimeout(() => loadState({ followPhase: true }), 1500); +} + +async function startProduction() { + const recipe = selectedRecipe(state); + if (!recipe || state.busy) return; + setBusy(true, 'Starting'); + try { + await startCampaign(recipe.id); + state.view = 'production'; + await loadState({ followPhase: true }); + } catch (error) { + renderer.showNotice('Production did not start', error.message); + } finally { + setBusy(false, 'Local'); + renderer.renderNavigation(); + } +} + +async function review(status, targetIds) { + const recipe = selectedRecipe(state); + if (!recipe || state.busy) return; + const note = renderer.reviewNote(); + if (status === 'changes-requested' && !note) { + renderer.focusReviewNote(); + renderer.showNotice('Feedback required', 'Describe what the agent should change.'); + return; + } + setBusy(true, status === 'approved' ? 'Approving' : 'Submitting'); + try { + const targets = targetIds.map((targetId) => ( + selectedTargets(state).find((target) => target.id === targetId) + )); + const response = await submitCampaignReview(buildReviewPayload({ + recipeId: recipe.id, + targets, + status, + note, + })); + state.document = response.campaign; + if (status === 'changes-requested') state.view = 'production'; + renderer.render(); + schedulePoll(); + } catch (error) { + renderer.showNotice('Review was not saved', error.message); + } finally { + setBusy(false, 'Local'); + if (state.view === 'review') renderer.renderReview(); + } +} + +for (const tab of elements.tabs) { + tab.addEventListener('click', () => { + if (!tab.disabled) setView(tab.dataset.view); + }); +} +elements.startButton.addEventListener('click', startProduction); +elements.openReviewButton.addEventListener('click', () => setView('review')); +elements.retryButton.addEventListener('click', startProduction); +elements.requestChangesButton.addEventListener('click', () => { + const target = activeTarget(state); + if (target) review('changes-requested', [target.id]); +}); +elements.approveButton.addEventListener('click', () => review('approved', [...state.targets])); +elements.noticeClose.addEventListener('click', renderer.dismissNotice); + +loadState(); diff --git a/campaign/index.html b/campaign/index.html new file mode 100644 index 0000000..a614c17 --- /dev/null +++ b/campaign/index.html @@ -0,0 +1,204 @@ + + + + + + + Shotkit Campaigns + + + +
+
+ + + + Shotkit + Loading project + + + + + +
+ Connecting + + + +
+
+ +
+
+
+
+

Campaign recipe

+

Select a story

+
+ 0 recipes +
+ +
+
+ + +
+
+ + + + + + +
+
+ + + + + + + + diff --git a/campaign/model.js b/campaign/model.js new file mode 100644 index 0000000..5550a1c --- /dev/null +++ b/campaign/model.js @@ -0,0 +1,102 @@ +export const CAMPAIGN_VIEWS = Object.freeze(['plan', 'production', 'review']); + +export function createCampaignState() { + return { + document: null, + recipeId: null, + targets: new Set(), + activeTargetId: null, + view: 'plan', + busy: false, + pollTimer: null, + }; +} + +export function selectedRecipe(state) { + return state.document + ? state.document.recipes.find((recipe) => recipe.id === state.recipeId) || null + : null; +} + +export function selectedTargets(state) { + const recipe = selectedRecipe(state); + return recipe ? recipe.targets.filter((target) => state.targets.has(target.id)) : []; +} + +export function activeTarget(state) { + const targets = selectedTargets(state); + return targets.find((target) => target.id === state.activeTargetId) || targets[0] || null; +} + +export function selectRecipeInState(state, recipeId) { + if (!state.document || state.busy) return false; + const recipe = state.document.recipes.find((item) => item.id === recipeId); + if (!recipe) return false; + state.recipeId = recipe.id; + state.targets = new Set(recipe.targets.map((target) => target.id)); + state.activeTargetId = recipe.targets[0] ? recipe.targets[0].id : null; + return true; +} + +export function initializeSelection(state) { + if (!state.document) return null; + const selected = state.document.selection; + const recipe = state.document.recipes.find((item) => item.id === (selected && selected.recipeId)) + || state.document.recipes[0]; + if (!recipe) return null; + const recipeChanged = state.recipeId !== recipe.id; + state.recipeId = recipe.id; + state.targets = new Set(recipe.targets.map((target) => target.id)); + if (recipeChanged || !state.activeTargetId) { + state.activeTargetId = recipe.targets[0] ? recipe.targets[0].id : null; + } + return recipe; +} + +export function setCampaignView(state, view) { + if (!CAMPAIGN_VIEWS.includes(view)) return false; + state.view = view; + return true; +} + +export function formatViewport(viewport) { + return viewport ? `${viewport.width} x ${viewport.height}` : 'Configured output'; +} + +export function statusLabel(status) { + const labels = { + idle: 'Ready', + queued: 'Queued', + running: 'Agent working', + 'publish-ready': 'Ready', + completed: 'Ready to review', + 'needs-fix': 'Agent working', + failed: 'Needs attention', + blocked: 'Needs attention', + approved: 'Approved', + 'awaiting-approval': 'Ready to review', + 'changes-requested': 'Agent working', + 'not-requested': 'Queued', + 'not-ready': 'Preparing', + }; + return labels[status] || 'Preparing'; +} + +export function productionStatus(state, target) { + if (target.publishable) return 'approved'; + if (target.review.status === 'changes-requested') return 'changes-requested'; + const run = state.document.run; + const current = run.recipeId === state.recipeId + ? run.targets.find((item) => item.target === target.id) + : null; + if (current) return current.status; + if (target.reviewable) return 'publish-ready'; + return target.machineStatus; +} + +export function shouldPoll(state) { + return !!state.document && ( + state.document.run.status === 'running' + || (state.view === 'production' && state.document.phase === 'production') + ); +} diff --git a/campaign/package.json b/campaign/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/campaign/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/campaign/render.js b/campaign/render.js new file mode 100644 index 0000000..74a66bf --- /dev/null +++ b/campaign/render.js @@ -0,0 +1,378 @@ +import { + activeTarget, + formatViewport, + productionStatus, + selectedRecipe, + selectedTargets, + statusLabel, +} from './model.js'; + +const $ = (id) => document.getElementById(id); + +function bindElements() { + return { + app: $('app'), + projectName: $('projectName'), + connectionState: $('connectionState'), + advancedLink: $('advancedLink'), + tabs: [...document.querySelectorAll('.step')], + views: [...document.querySelectorAll('.view')], + planTab: $('planTab'), + productionTab: $('productionTab'), + reviewTab: $('reviewTab'), + recipeCount: $('recipeCount'), + recipeGrid: $('recipeGrid'), + selectedRecipeName: $('selectedRecipeName'), + recipeDescription: $('recipeDescription'), + channelCount: $('channelCount'), + channelList: $('channelList'), + planOutputCount: $('planOutputCount'), + planFormatSummary: $('planFormatSummary'), + startButton: $('startButton'), + productionTitle: $('productionTitle'), + runState: $('runState'), + progressBar: $('progressBar'), + targetProgressList: $('targetProgressList'), + productionOutputCount: $('productionOutputCount'), + readyOutputCount: $('readyOutputCount'), + reviewOutputCount: $('reviewOutputCount'), + runMessage: $('runMessage'), + retryButton: $('retryButton'), + openReviewButton: $('openReviewButton'), + reviewTitle: $('reviewTitle'), + approvalState: $('approvalState'), + reviewTargetTabs: $('reviewTargetTabs'), + mediaStage: $('mediaStage'), + reviewVideo: $('reviewVideo'), + reviewImage: $('reviewImage'), + mediaPlaceholder: $('mediaPlaceholder'), + reviewTargetName: $('reviewTargetName'), + targetReviewState: $('targetReviewState'), + reviewPlatform: $('reviewPlatform'), + reviewFormat: $('reviewFormat'), + reviewQa: $('reviewQa'), + reviewWarnings: $('reviewWarnings'), + reviewNote: $('reviewNote'), + requestChangesButton: $('requestChangesButton'), + approveButton: $('approveButton'), + emptyView: $('emptyView'), + notice: $('notice'), + noticeTitle: $('noticeTitle'), + noticeMessage: $('noticeMessage'), + noticeClose: $('noticeClose'), + }; +} + +export function createCampaignRenderer({ state, onSelectRecipe, onSelectTarget }) { + const elements = bindElements(); + + function showNotice(title, message) { + elements.noticeTitle.textContent = title; + elements.noticeMessage.textContent = message; + elements.notice.hidden = false; + } + + function dismissNotice() { + elements.notice.hidden = true; + } + + function setConnection(label, tone = 'ready') { + elements.connectionState.className = `connection is-${tone}`; + elements.connectionState.querySelector('span').textContent = label; + } + + function renderBusy() { + const target = activeTarget(state); + const targets = selectedTargets(state); + elements.startButton.disabled = state.busy || !state.recipeId || state.targets.size === 0; + elements.requestChangesButton.disabled = state.busy || !target || !target.reviewable; + elements.approveButton.disabled = state.busy || !targets.length + || !targets.every((item) => item.reviewable); + } + + function recipeCard(recipe) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'recipe-card'; + button.setAttribute('role', 'radio'); + button.setAttribute('aria-checked', recipe.id === state.recipeId ? 'true' : 'false'); + + const preview = document.createElement('div'); + preview.className = 'recipe-preview'; + if (recipe.previewUrl) { + const image = document.createElement('img'); + image.src = recipe.previewUrl; + image.alt = ''; + preview.appendChild(image); + } else { + const visual = document.createElement('span'); + visual.className = 'recipe-visual'; + visual.setAttribute('aria-hidden', 'true'); + preview.appendChild(visual); + } + + const body = document.createElement('span'); + body.className = 'recipe-card-body'; + const name = document.createElement('strong'); + name.textContent = recipe.name; + const story = document.createElement('span'); + story.className = 'recipe-story'; + story.textContent = recipe.story; + const description = document.createElement('span'); + description.className = 'recipe-card-description'; + description.textContent = recipe.description || `${recipe.targets.length} channel outputs`; + const targets = document.createElement('span'); + targets.className = 'recipe-targets'; + for (const target of recipe.targets) { + const label = document.createElement('span'); + label.textContent = target.id; + targets.appendChild(label); + } + body.append(name, story, description, targets); + button.append(preview, body); + button.addEventListener('click', () => onSelectRecipe(recipe.id)); + return button; + } + + function channelOption(target) { + const row = document.createElement('div'); + row.className = 'channel-option'; + const mark = document.createElement('span'); + mark.className = 'channel-mark'; + mark.setAttribute('aria-hidden', 'true'); + mark.textContent = '✓'; + const copy = document.createElement('span'); + const name = document.createElement('strong'); + name.textContent = target.label; + const platform = document.createElement('small'); + platform.textContent = target.platform; + copy.append(name, platform); + const format = document.createElement('span'); + format.className = 'channel-format'; + format.textContent = formatViewport(target.viewport); + row.append(mark, copy, format); + return row; + } + + function renderPlan() { + const recipes = state.document.recipes; + elements.recipeCount.textContent = `${recipes.length} recipe${recipes.length === 1 ? '' : 's'}`; + elements.recipeGrid.replaceChildren(...recipes.map(recipeCard)); + const recipe = selectedRecipe(state); + if (!recipe) return; + elements.selectedRecipeName.textContent = recipe.name; + elements.recipeDescription.textContent = recipe.description || recipe.story; + elements.channelCount.textContent = String(recipe.targets.length); + const legend = elements.channelList.querySelector('legend'); + elements.channelList.replaceChildren(legend, ...recipe.targets.map(channelOption)); + elements.planOutputCount.textContent = `${recipe.targets.length} output${recipe.targets.length === 1 ? '' : 's'}`; + elements.planFormatSummary.textContent = recipe.targets.map((target) => target.id).join(' · '); + elements.startButton.disabled = state.busy || recipe.targets.length === 0; + } + + function progressRow(target) { + const status = productionStatus(state, target); + const row = document.createElement('div'); + row.className = 'target-progress'; + row.dataset.status = status; + const main = document.createElement('div'); + main.className = 'target-progress-main'; + const icon = document.createElement('span'); + icon.className = 'target-progress-icon'; + icon.innerHTML = ''; + const copy = document.createElement('span'); + const name = document.createElement('strong'); + name.textContent = target.label; + const format = document.createElement('small'); + format.textContent = formatViewport(target.viewport); + copy.append(name, format); + main.append(icon, copy); + const statusNode = document.createElement('span'); + statusNode.className = 'target-progress-status'; + const dot = document.createElement('i'); + dot.setAttribute('aria-hidden', 'true'); + const label = document.createElement('span'); + label.textContent = statusLabel(status); + statusNode.append(dot, label); + row.append(main, statusNode); + return row; + } + + function renderProduction() { + const recipe = selectedRecipe(state); + if (!recipe) return; + const targets = selectedTargets(state); + const run = state.document.run; + const ready = targets.filter((target) => target.machineStatus === 'publish-ready').length; + const reviewable = targets.filter((target) => target.reviewable).length; + const completed = targets.filter((target) => ['publish-ready', 'approved'].includes( + productionStatus(state, target), + )).length; + const hasChangesRequested = targets.some((target) => target.review.status === 'changes-requested'); + elements.productionTitle.textContent = recipe.name; + elements.targetProgressList.replaceChildren(...targets.map(progressRow)); + elements.progressBar.style.width = `${targets.length ? Math.round((completed / targets.length) * 100) : 0}%`; + elements.productionOutputCount.textContent = String(targets.length); + elements.readyOutputCount.textContent = String(ready); + elements.reviewOutputCount.textContent = String(reviewable); + const ownerRunStatus = hasChangesRequested ? 'needs-fix' : run.status; + elements.runState.textContent = statusLabel(ownerRunStatus); + elements.runState.className = `run-state ${ownerRunStatus === 'running' || ownerRunStatus === 'needs-fix' ? 'is-running' : ownerRunStatus === 'completed' ? 'is-complete' : ownerRunStatus === 'failed' ? 'is-error' : ''}`; + const allReviewable = !hasChangesRequested && targets.length > 0 + && targets.every((target) => target.reviewable); + if (allReviewable) elements.runMessage.textContent = 'Final media is ready for your decision.'; + else if (hasChangesRequested) elements.runMessage.textContent = 'The agent is applying your requested changes.'; + else if (run.status === 'failed') elements.runMessage.textContent = 'The agent needs to resolve a production blocker.'; + else elements.runMessage.textContent = 'The agent is producing and validating each channel output.'; + elements.retryButton.hidden = true; + elements.openReviewButton.hidden = !allReviewable; + } + + function targetTab(target) { + const current = activeTarget(state); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'target-tab'; + button.setAttribute('role', 'tab'); + button.setAttribute('aria-selected', current && target.id === current.id ? 'true' : 'false'); + button.dataset.status = target.review.status; + button.textContent = target.id; + button.addEventListener('click', () => onSelectTarget(target.id)); + return button; + } + + function renderMedia(target) { + const portrait = target.viewport && target.viewport.height > target.viewport.width; + elements.mediaStage.dataset.orientation = portrait ? 'portrait' : 'landscape'; + if (target.viewport) { + elements.mediaStage.style.setProperty('--media-ratio', `${target.viewport.width} / ${target.viewport.height}`); + } + elements.reviewVideo.pause(); + elements.reviewVideo.hidden = true; + elements.reviewImage.hidden = true; + elements.mediaPlaceholder.hidden = true; + elements.reviewVideo.removeAttribute('src'); + elements.reviewImage.removeAttribute('src'); + if (target.videoUrl) { + elements.reviewVideo.src = target.videoUrl; + elements.reviewVideo.poster = target.thumbnailUrl || ''; + elements.reviewVideo.hidden = false; + elements.reviewVideo.load(); + } else if (target.thumbnailUrl) { + elements.reviewImage.src = target.thumbnailUrl; + elements.reviewImage.hidden = false; + } else { + elements.mediaPlaceholder.hidden = false; + } + } + + function renderReview() { + const recipe = selectedRecipe(state); + const targets = selectedTargets(state); + if (!recipe || !targets.length) return; + if (!targets.some((target) => target.id === state.activeTargetId)) { + state.activeTargetId = targets[0].id; + } + const target = activeTarget(state); + if (state.document.calibratorAvailable) { + elements.advancedLink.href = `/?story=${encodeURIComponent(recipe.story)}&target=${encodeURIComponent(target.id)}`; + } + elements.reviewTitle.textContent = recipe.name; + elements.reviewTargetTabs.replaceChildren(...targets.map(targetTab)); + renderMedia(target); + elements.reviewTargetName.textContent = target.label; + elements.reviewPlatform.textContent = target.platform; + elements.reviewFormat.textContent = formatViewport(target.viewport); + elements.reviewQa.textContent = target.reviewable ? 'Passed' : statusLabel(target.machineStatus); + const reviewStatus = target.review.status; + elements.targetReviewState.textContent = statusLabel(reviewStatus); + elements.targetReviewState.className = `target-review-state ${reviewStatus === 'approved' ? 'is-approved' : reviewStatus === 'changes-requested' ? 'is-changes' : target.reviewable ? 'is-ready' : ''}`; + const warnings = target.warnings || []; + if (warnings.length) { + elements.reviewWarnings.replaceChildren(...warnings.map((warning) => { + const item = document.createElement('li'); + item.textContent = warning.message || String(warning); + return item; + })); + } else { + const clear = document.createElement('li'); + clear.className = 'is-clear'; + clear.textContent = 'Automated checks passed'; + elements.reviewWarnings.replaceChildren(clear); + } + elements.reviewNote.value = target.review.decision && target.review.decision.note + ? target.review.decision.note + : ''; + const allApproved = targets.every((item) => item.publishable); + elements.approvalState.textContent = allApproved ? 'Approved' : 'Awaiting decision'; + elements.approvalState.className = `approval-state ${allApproved ? 'is-approved' : ''}`; + elements.requestChangesButton.disabled = state.busy || !target.reviewable; + elements.approveButton.disabled = state.busy || !targets.every((item) => item.reviewable) || allApproved; + } + + function renderNavigation() { + const recipe = selectedRecipe(state); + const persisted = !!(state.document.selection && state.document.selection.persisted); + const targets = selectedTargets(state); + const reviewReady = targets.length > 0 && targets.every((target) => target.reviewable); + elements.productionTab.disabled = !persisted; + elements.reviewTab.disabled = !reviewReady; + elements.startButton.disabled = state.busy || !recipe || !targets.length; + } + + function renderView() { + for (const tab of elements.tabs) { + tab.setAttribute('aria-selected', tab.dataset.view === state.view ? 'true' : 'false'); + } + for (const panel of elements.views) panel.hidden = panel.dataset.view !== state.view; + if (state.view === 'review') renderReview(); + } + + function render() { + const hasRecipes = state.document.recipes.length > 0; + elements.emptyView.hidden = hasRecipes; + for (const tab of elements.tabs) { + tab.setAttribute('aria-selected', tab.dataset.view === state.view ? 'true' : 'false'); + } + for (const view of elements.views) view.hidden = !hasRecipes || view.dataset.view !== state.view; + if (!hasRecipes) return; + renderPlan(); + renderProduction(); + renderNavigation(); + if (state.view === 'review') renderReview(); + } + + function renderDocumentChrome() { + elements.projectName.textContent = state.document.project || 'Project'; + elements.advancedLink.hidden = !state.document.calibratorAvailable; + } + + function markLoaded() { + elements.app.setAttribute('aria-busy', 'false'); + } + + function reviewNote() { + return elements.reviewNote.value.trim(); + } + + function focusReviewNote() { + elements.reviewNote.focus(); + } + + return { + elements, + dismissNotice, + focusReviewNote, + markLoaded, + render, + renderBusy, + renderDocumentChrome, + renderNavigation, + renderPlan, + renderReview, + renderView, + reviewNote, + setConnection, + showNotice, + }; +} diff --git a/campaign/styles.css b/campaign/styles.css new file mode 100644 index 0000000..9356d85 --- /dev/null +++ b/campaign/styles.css @@ -0,0 +1,468 @@ +:root { + color-scheme: light; + --ink: #202124; + --muted: #6f7479; + --line: #d8dcdf; + --panel: #f4f5f5; + --surface: #ffffff; + --stage: #242729; + --accent: #df624c; + --accent-dark: #aa3e2c; + --accent-soft: #fff0ec; + --blue: #2563eb; + --good: #247a4d; + --good-soft: #e3f3e9; + --warn: #9b6500; + --warn-soft: #fff3d6; + --bad: #b42318; + --bad-soft: #fde8e4; + --topbar-h: 58px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +* { box-sizing: border-box; } + +html, +body { min-width: 280px; min-height: 100%; margin: 0; } + +body { + background: var(--panel); + color: var(--ink); + font-size: 14px; + letter-spacing: 0; +} + +button, +input, +textarea { font: inherit; letter-spacing: 0; } + +button, +a { color: inherit; } + +a { text-decoration: none; } + +button { cursor: pointer; } + +button:disabled { cursor: not-allowed; opacity: .46; } + +.app { min-height: 100dvh; } + +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: grid; + grid-template-columns: minmax(190px, 1fr) auto minmax(190px, 1fr); + align-items: center; + min-height: var(--topbar-h); + padding: 0 18px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, .97); +} + +.brand, +.brand-copy, +.steps, +.step, +.topbar-actions, +.connection, +.view-heading, +.channel-panel > header, +.plan-action, +.metric-row, +.review-panel > header, +.review-actions, +.target-progress-main, +.target-progress-status { display: flex; align-items: center; } + +.brand { justify-self: start; min-width: 0; gap: 9px; } + +.brand-mark { + display: grid; + place-items: center; + width: 30px; + height: 30px; + flex: 0 0 auto; + border: 1px solid #1f2224; + border-radius: 5px; + background: #25282a; + color: #fff; + font-size: 10px; + font-weight: 850; +} + +.brand-copy { min-width: 0; flex-direction: column; align-items: flex-start; line-height: 1.08; } +.brand-copy strong { font-size: 13px; } +.brand-copy small { max-width: 220px; color: var(--muted); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.steps { justify-self: center; gap: 4px; } + +.step { + min-height: 34px; + gap: 7px; + padding: 0 10px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.step > span { + display: grid; + place-items: center; + width: 18px; + height: 18px; + border: 1px solid #bfc4c8; + border-radius: 50%; + font-size: 9px; +} + +.step[aria-selected="true"] { border-bottom-color: var(--accent); color: var(--ink); } +.step[aria-selected="true"] > span { border-color: var(--accent); background: var(--accent); color: #fff; } + +.topbar-actions { justify-self: end; gap: 12px; } +.connection { gap: 6px; color: var(--muted); font-size: 10px; } +.connection i { width: 7px; height: 7px; border-radius: 50%; background: #9aa0a6; } +.connection.is-ready i { background: var(--good); } +.connection.is-busy i { background: var(--warn); } +.connection.is-error i { background: var(--bad); } + +.icon-link, +.icon-button { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); +} + +.icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; } + +main { min-height: calc(100dvh - var(--topbar-h)); } + +.view { + width: min(1420px, 100%); + min-height: calc(100dvh - var(--topbar-h)); + margin: 0 auto; + padding: clamp(24px, 4vh, 44px) clamp(20px, 4vw, 54px) 38px; +} + +.view-heading { justify-content: space-between; min-height: 58px; margin-bottom: 24px; } +.view-heading h1 { margin: 2px 0 0; font-size: clamp(24px, 2.4vw, 34px); line-height: 1.1; letter-spacing: 0; } +.eyebrow { margin: 0; color: var(--accent-dark); font-size: 10px; font-weight: 800; text-transform: uppercase; } +.selection-count, +.run-state, +.approval-state { + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--surface); + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.plan-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(310px, 360px); + min-height: 610px; + border-top: 1px solid var(--line); +} + +.recipe-grid { + display: grid; + grid-template-columns: repeat(2, minmax(230px, 1fr)); + align-content: start; + gap: 14px; + padding: 22px 24px 22px 0; +} + +.recipe-card { + display: grid; + grid-template-rows: auto 1fr; + min-width: 0; + min-height: 250px; + padding: 0; + overflow: hidden; + border: 1px solid #cfd3d6; + border-radius: 7px; + background: var(--surface); + text-align: left; +} + +.recipe-card:hover { border-color: #aeb4b8; } +.recipe-card[aria-checked="true"] { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(223, 98, 76, .16); } + +.recipe-preview { + position: relative; + aspect-ratio: 16 / 9; + overflow: hidden; + border-bottom: 1px solid var(--line); + background: #dfe3e5; +} + +.recipe-preview img { width: 100%; height: 100%; object-fit: cover; } + +.recipe-visual { + position: absolute; + inset: 14%; + display: grid; + grid-template-columns: 28% 1fr; + border: 1px solid #9ca3a8; + background: #fff; + box-shadow: 0 8px 22px rgba(34, 38, 40, .13); +} + +.recipe-visual::before { content: ""; border-right: 1px solid #cfd4d7; background: #edf0f1; } +.recipe-visual::after { content: ""; align-self: end; height: 28%; margin: 12%; background: #ef8a74; } + +.recipe-card-body { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 14px; padding: 15px; } +.recipe-card-body strong { font-size: 15px; } +.recipe-card-description { display: block; grid-column: 1 / 3; min-height: 34px; color: var(--muted); font-size: 12px; line-height: 1.4; } +.recipe-story { align-self: start; color: var(--muted); font-size: 9px; font-weight: 700; text-transform: uppercase; } +.recipe-targets { display: flex; grid-column: 1 / 3; gap: 5px; flex-wrap: wrap; } +.recipe-targets span { padding: 3px 5px; border: 1px solid #dfe2e4; border-radius: 4px; background: #f7f8f8; color: #575c60; font-size: 9px; } + +.channel-panel { + min-width: 0; + padding: 22px 0 22px 26px; + border-left: 1px solid var(--line); +} + +.channel-panel > header { justify-content: space-between; gap: 12px; } +.channel-panel h2, +.review-panel h2 { margin: 3px 0 0; font-size: 17px; line-height: 1.2; } +.channel-panel > header > span { display: grid; place-items: center; min-width: 25px; height: 25px; border-radius: 50%; background: #e5e8e9; font-size: 10px; font-weight: 800; } +.recipe-description { min-height: 39px; margin: 12px 0 20px; color: var(--muted); font-size: 12px; line-height: 1.5; } + +.channel-panel fieldset { display: grid; gap: 7px; margin: 0; padding: 0; border: 0; } +.channel-panel legend { margin-bottom: 8px; font-size: 11px; font-weight: 800; } +.channel-option { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + min-height: 50px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); +} +.channel-mark { display: grid; place-items: center; width: 16px; height: 16px; border-radius: 4px; background: var(--good); color: #fff; font-size: 10px; font-weight: 850; } +.channel-option strong { display: block; font-size: 11px; } +.channel-option small { display: block; margin-top: 2px; color: var(--muted); font-size: 9px; } +.channel-format { color: var(--muted); font-size: 9px; font-variant-numeric: tabular-nums; } + +.plan-action { justify-content: space-between; gap: 12px; margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--line); } +.plan-summary { min-width: 0; } +.plan-summary strong, +.plan-summary span { display: block; } +.plan-summary strong { font-size: 12px; } +.plan-summary span { margin-top: 2px; color: var(--muted); font-size: 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + gap: 7px; + padding: 0 13px; + border: 1px solid transparent; + border-radius: 5px; + font-weight: 750; +} +.button-primary { border-color: #c84d38; background: var(--accent); color: #fff; } +.button-secondary { border-color: var(--line); background: var(--surface); } +.button-review { border-color: #d5a23e; background: #fff8e7; color: #735000; } +.button-success { border-color: #1f6842; background: var(--good); color: #fff; } +.button:hover:not(:disabled), +.icon-link:hover, +.icon-button:hover { filter: brightness(.96); } + +.production-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + min-height: 560px; + border-top: 1px solid var(--line); +} + +.production-board { padding: 28px 34px 28px 0; } +.progress-track { width: 100%; height: 5px; margin-bottom: 26px; overflow: hidden; border-radius: 3px; background: #dfe2e4; } +.progress-track span { display: block; width: 0; height: 100%; background: var(--accent); transition: width 220ms ease; } +.target-progress-list { display: grid; gap: 8px; } +.target-progress { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + min-height: 68px; + padding: 11px 13px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); +} +.target-progress-main { min-width: 0; gap: 11px; } +.target-progress-icon { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border-radius: 5px; background: #eceff0; color: #6c7276; } +.target-progress-icon .icon { width: 18px; height: 18px; } +.target-progress strong, +.target-progress small { display: block; } +.target-progress strong { font-size: 12px; } +.target-progress small { margin-top: 3px; color: var(--muted); font-size: 9px; } +.target-progress-status { gap: 7px; color: var(--muted); font-size: 10px; font-weight: 700; } +.target-progress-status i { width: 8px; height: 8px; border-radius: 50%; background: #a7adb1; } +.target-progress[data-status="running"] .target-progress-status i { background: var(--warn); animation: pulse 850ms ease-in-out infinite alternate; } +.target-progress[data-status="publish-ready"] .target-progress-status i { background: var(--good); } +.target-progress[data-status="needs-fix"] .target-progress-status i, +.target-progress[data-status="failed"] .target-progress-status i { background: var(--bad); } +@keyframes pulse { to { opacity: .35; } } + +.production-summary { padding: 28px 0 28px 26px; border-left: 1px solid var(--line); } +.metric-row { justify-content: space-between; min-height: 44px; border-bottom: 1px solid var(--line); } +.metric-row span { color: var(--muted); font-size: 11px; } +.metric-row strong { font-size: 17px; } +.run-message { min-height: 54px; margin: 22px 0 16px; color: var(--muted); font-size: 11px; line-height: 1.5; } +.production-summary .button { width: 100%; margin-top: 7px; } + +.review-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(310px, 350px); + min-height: 610px; + border-top: 1px solid var(--line); +} + +.media-panel { min-width: 0; padding: 20px 26px 20px 0; } +.target-tabs { display: flex; min-height: 36px; gap: 4px; margin-bottom: 12px; overflow-x: auto; } +.target-tab { flex: 0 0 auto; min-height: 32px; padding: 0 10px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface); color: var(--muted); font-size: 10px; font-weight: 700; } +.target-tab[aria-selected="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-dark); } +.target-tab[data-status="approved"]::after { content: ""; display: inline-block; width: 6px; height: 6px; margin-left: 6px; border-radius: 50%; background: var(--good); } + +.media-stage { + position: relative; + display: grid; + place-items: center; + width: 100%; + min-height: 500px; + padding: 18px; + overflow: hidden; + background: var(--stage); +} +.media-stage video, +.media-stage img { width: auto; height: auto; max-width: 100%; max-height: 560px; aspect-ratio: var(--media-ratio); object-fit: contain; background: #111; } +.media-stage[data-orientation="landscape"] video, +.media-stage[data-orientation="landscape"] img { width: 100%; } +.media-stage[data-orientation="portrait"] video, +.media-stage[data-orientation="portrait"] img { width: auto; height: min(560px, 62vh); } +.media-placeholder { display: grid; place-items: center; gap: 9px; color: #aeb4b8; } +.placeholder-icon { width: 42px; height: 42px; fill: none; stroke: currentColor; stroke-width: 1.4; } +.media-placeholder strong { font-size: 11px; } + +.review-panel { padding: 22px 0 22px 26px; border-left: 1px solid var(--line); } +.review-panel > header { justify-content: space-between; gap: 12px; } +.target-review-state { padding: 4px 6px; border-radius: 4px; background: #e7eaeb; color: var(--muted); font-size: 9px; font-weight: 800; white-space: nowrap; } +.target-review-state.is-ready { background: var(--warn-soft); color: var(--warn); } +.target-review-state.is-approved { background: var(--good-soft); color: var(--good); } +.target-review-state.is-changes { background: var(--bad-soft); color: var(--bad); } + +.output-facts { margin: 20px 0 0; } +.output-facts > div { display: grid; grid-template-columns: 78px minmax(0, 1fr); min-height: 35px; align-items: center; border-bottom: 1px solid var(--line); } +.output-facts dt { color: var(--muted); font-size: 10px; } +.output-facts dd { margin: 0; font-size: 11px; font-weight: 700; text-align: right; } +.warning-block { margin-top: 18px; } +.warning-block h3 { margin: 0 0 8px; font-size: 11px; } +.warning-block ul { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; } +.warning-block li { padding-left: 9px; border-left: 2px solid var(--warn); color: #66501e; font-size: 10px; line-height: 1.4; } +.warning-block li.is-clear { border-left-color: var(--good); color: var(--good); } +.feedback-label { display: block; margin: 20px 0 7px; font-size: 10px; font-weight: 800; } +#reviewNote { width: 100%; min-height: 88px; resize: vertical; padding: 9px; border: 1px solid #cbd0d3; border-radius: 5px; background: var(--surface); color: var(--ink); line-height: 1.4; } +.review-actions { gap: 7px; margin-top: 12px; } +.review-actions .button { flex: 1; min-width: 0; padding-inline: 8px; font-size: 11px; } + +.approval-state.is-approved, +.run-state.is-complete { border-color: #a9d2b9; background: var(--good-soft); color: var(--good); } +.run-state.is-running { border-color: #ebce8b; background: var(--warn-soft); color: var(--warn); } +.run-state.is-error { border-color: #efb5ab; background: var(--bad-soft); color: var(--bad); } + +.empty-view { display: grid; place-items: center; align-content: center; min-height: calc(100dvh - var(--topbar-h)); gap: 12px; padding: 24px; text-align: center; } +.empty-view h1 { margin: 0; font-size: 20px; } + +.notice { + position: fixed; + z-index: 50; + right: 16px; + bottom: 16px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + width: min(380px, calc(100vw - 32px)); + padding: 12px; + border: 1px solid #efb8ad; + border-radius: 6px; + background: #fff5f2; + box-shadow: 0 12px 28px rgba(32, 35, 37, .16); + color: #7d2f22; +} +.notice strong, +.notice span { display: block; } +.notice strong { font-size: 12px; } +.notice span { margin-top: 3px; font-size: 10px; line-height: 1.4; } +.notice .icon-button { flex: 0 0 auto; width: 27px; height: 27px; border-color: transparent; background: transparent; } + +[hidden] { display: none !important; } +:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; } + +@media (max-width: 900px) { + .topbar { grid-template-columns: 1fr auto; } + .steps { position: fixed; z-index: 19; left: 0; right: 0; bottom: 0; justify-content: center; min-height: 54px; border-top: 1px solid var(--line); background: rgba(255, 255, 255, .98); } + .topbar-actions { grid-column: 2; } + .connection { display: none; } + main { padding-bottom: 54px; } + .view { min-height: calc(100dvh - var(--topbar-h) - 54px); padding-inline: 22px; } + .plan-layout, + .production-layout, + .review-layout { grid-template-columns: 1fr; } + .recipe-grid { padding-right: 0; } + .channel-panel, + .production-summary, + .review-panel { padding: 24px 0 0; border-top: 1px solid var(--line); border-left: 0; } + .production-board, + .media-panel { padding-right: 0; } +} + +@media (max-width: 620px) { + .topbar { min-height: 54px; padding: 0 12px; } + .brand-copy small { max-width: 150px; } + .view { padding: 22px 14px 28px; } + .view-heading { min-height: 50px; margin-bottom: 16px; } + .view-heading h1 { font-size: 24px; } + .recipe-grid { grid-template-columns: 1fr; gap: 10px; padding-block: 16px; } + .recipe-card { min-height: 230px; } + .channel-panel { padding-top: 18px; } + .plan-action { align-items: stretch; flex-direction: column; } + .plan-action .button { width: 100%; } + .production-board { padding-block: 20px; } + .production-summary { padding-top: 18px; } + .target-progress { min-height: 62px; } + .media-panel { padding-top: 14px; } + .media-stage { min-height: 430px; padding: 10px; } + .media-stage video, + .media-stage img { max-height: 500px; } + .review-panel { padding-top: 18px; } + .review-view { padding-bottom: 126px; } + .review-actions { position: fixed; z-index: 18; left: 0; right: 0; bottom: 54px; align-items: stretch; margin: 0; padding: 10px 14px; border-top: 1px solid var(--line); background: rgba(244, 245, 245, .98); } + .review-actions .button { width: 100%; } + .selection-count { display: none; } + .step { min-width: 92px; justify-content: center; } +} + +@media (max-width: 380px) { + .brand-copy small { max-width: 120px; } + .step { min-width: 82px; padding-inline: 6px; } + .step > span { display: none; } + .media-stage { min-height: 380px; } +} diff --git a/docs/handoff-conventions.md b/docs/handoff-conventions.md index 4fcd65c..4243632 100644 --- a/docs/handoff-conventions.md +++ b/docs/handoff-conventions.md @@ -1,9 +1,11 @@ # shotkit Handoff Conventions -shotkit is the capture layer for browser-extension promotion assets. It should -not try to become Screen Studio, Canva, Supademo, or a hosted demo editor. -Instead, it writes repeatable source clips and a small JSON contract that other -tools or MCP adapters can consume. +shotkit is the autonomous launch asset pipeline for browser extensions. It +turns a reusable product story into channel variants, validates the final files, +and gives agents machine-readable fixes until the requested targets are +technically publish-ready. The handoff pack is an internal machine boundary, not a request +for a user to inspect JSON or edit media. The user reviews the final rendered +candidate and records Approve or Request changes; agents own the repair loop. ## Files @@ -12,11 +14,27 @@ Every successful run writes these files unless `handoff: false` is set: - `shotkit-manifest.json` — the entry point. Read this first. - `storyboard.json` — demo intent, beats, viewport, trim/framing hints, and structured storyboard lint. -- `captions.json` — portable caption timing and text. +- `captions.json` — portable caption timing, text, style, and measured rendering QA. +- `schemas/shotkit-manifest.schema.json` — local manifest validation contract. +- `schemas/storyboard.schema.json` — local storyboard validation contract. +- `schemas/captions.schema.json` — local captions validation contract. +- `schemas/approval.schema.json` — user decision validation contract. + +The first review decision also writes `shotkit-approval.json`. It is separate +from generated evidence and binds the decision to the exact deliverable digest. Schema references are included in each file as `$schema` URNs, and the package -ships matching schema files under `schemas/`. The URN is an identity key, not a -network fetch requirement. +ships matching schema files under `schemas/`. Each output pack also carries its +own copies; resolve `handoff.schemaFiles` relative to the directory containing +`shotkit-manifest.json`. The URN is an identity key, not a network fetch +requirement. shotkit validates the finalized core handoff documents with these +same schemas before writing the manifest and validates approval decisions when +they are read or written. + +The CLI's `ok:true` means the requested stages completed. `machineStatus` is +`publish-ready`, `needs-fix`, `blocked`, or `not-requested`. Delivery `status` +also applies the approval gate and can be `awaiting-approval`, +`changes-requested`, or `approved`. ## Manifest Roles @@ -25,22 +43,109 @@ External tools should use `assets[].role`, not filename guessing: - `store-screenshot` — CWS screenshot PNG. - `promo-tile` — CWS promo image PNG. - `store-listing-copy` — generated listing copy. +- `privacy-disclosure` — generated store-review worksheet. - `source-demo-webm` — Playwright's original recording, useful as evidence. - `sns-demo-mp4` — H.264 MP4 intended for X/SNS upload or editor import. - `thumbnail` — poster frame extracted from the final clip. - `storyboard-contract` — `storyboard.json`. - `captions-contract` — `captions.json`. - `handoff-manifest` — `shotkit-manifest.json`. +- `handoff-schema` — one of the locally bundled JSON schemas. Each asset has a stable `id`, repo-relative `path`, `outPath`, `type`, `format`, -`role`, and `source.kind`. +`role`, and `source.kind`. Resolve `outPath` relative to the manifest directory; +use `path` only when the consumer also knows the repository root. Delivered +files other than the self-referential manifest carry byte size and SHA-256 +integrity metadata. Consumers must ignore unknown roles and fields for +forward-compatible v1 processing. + +## Run and Review State + +`manifest.run.id` identifies the current invocation. On a full run, every +asset's `runId` matches it and its state is `produced`. A scene-filtered or +`--no-video` run is `run.mode:"partial"`; untouched outputs keep their older +`runId` and become `retained`. Missing files are pruned. A retained file whose +digest changed becomes `modified`, is excluded from adapter readiness, and must +be recaptured. + +`handoff.review` remains an additive v1 compatibility summary. Autonomous +callers use `handoff.automation` instead; `needs-fix` never means "ask the user +to review." `handoff.approval` is the distinct final-media decision gate. +`handoff.summary` reports asset, demo, adapter, technically publish-ready, and +approved target counts. + +## Autonomous Publishing + +One demo story can declare `targets: ['cws-youtube', 'x', 'youtube-shorts']`. +Each expanded target records its profile in `storyboard.json` and receives +mechanical defaults for viewport, H.264/yuv420p, duration cap, caption position, +and thumbnail. `youtube-shorts` resolves timed story captions to the built-in +`focus` + `outline` style: compact word chunks, current-word emphasis, and a +bottom safe offset. The style is deterministic for silent demos and requires no transcript +engine. CWS and X use `static` unless the config overrides `captionOptions`. + +`storyboard.demos[].captionStyle` and `captions.demos[].style` preserve the +resolved `mode`, `appearance`, `position`, timing, chunk size, active color, and safe offset +for downstream agents. `captions.demos[].timeline[]` is the reproducible +trim-relative render plan: each frame includes its start/end, rendered chunk, +source phrase, words, and active-word index. + +Localized variants may also declare `captionOptions.typography`. The resolved +locale, direction, family stack, fitting bounds, and configured font families +remain in the public style. Project-local font files are glyph-checked and +subset before browser injection; data URLs never enter the handoff. Instead, +`captions.demos[].qa` records project-relative font paths, source/subset byte +counts, missing glyphs, scheduled/measured frame counts, browser font-load +state, fitted sizes, line count, and line balance. + +When `config.calibration` is declared, `storyboard.demos[].calibration` records +the applied profile hash, layout preset, and protected regions. The tracked +calibration JSON remains the editable source; the storyboard is run evidence. +Protected-region collisions become structured warnings, and a dashboard profile +is considered verified only when its current hash has produced a real +`publish-ready` recapture. + +`handoff.automation.targets[]` validates: + +- final MP4 presence and unchanged integrity; +- ffprobe codec, pixel format, actual dimensions, and actual duration; +- poster-frame presence and nonblank PNG pixel statistics; +- measured caption presence, viewport bounds, overflow, line count, outline + stroke, schedule drift, font application, fitted size, and line balance from + the real recorded page; +- storyboard lint being enabled for every publish target; +- structured storyboard warnings, including early result and restore beats; +- configured targets that were skipped or produced no output. + +Failures become `automation.actions[]` with `owner:"agent"`, an explicit `fix`, +and a target/scene rerun instruction. Agents increment `--attempt`, apply every +fix, and rerun `automation.retryScenes[]`. The default maximum is three. Only +the exhausted `blocked` state sets technical +`automation.userActionRequired:true`. + +Machine `publish-ready` means these automated checks passed. It does not mean +the user approved the media. `handoff.approval.targets[]` compares the current +deliverable SHA-256 and calibration profile hash with `shotkit-approval.json`: + +- `awaiting-approval` — show the final candidate to the user. +- `changes-requested` — the agent owns the note, edit, and recapture. +- `approved` — only this exact digest passed user review. + +Any recapture or profile change makes a prior decision stale. External +publication has not happened yet; `targets[].upload` identifies the connector, +and an authorized external write may proceed only when +`handoff.approval.publishable` is true. + +Adapter `readiness` is tool-specific. `ready` means the required, unmodified +asset roles and storyboard content are present for that recommendation; it does +not mean the connector is installed or the user approved the assets. -## Tool Handoff +## Manual Fallback -`shotkit-manifest.json.handoff.adapterHints[]` is the recommendation layer. It -lets an agent see likely next tools without the user researching the ecosystem. -Hints are advisory; shotkit does not call external services, hold credentials, -or install MCP servers. +Target workflows omit `adapterHints[]` by default. They are available for +legacy targetless captures or when `automation.manualFallback:true` is +explicitly configured. Hints are advisory; shotkit does not hold credentials or +install MCP servers. Each hint includes: @@ -54,16 +159,20 @@ Each hint includes: - `missingRoles` / `missingInputs` — what to capture or provide next. - `nextStep` — the agent-facing action. -Recommended downstream flow: +Autonomous flow: -1. Read `shotkit-manifest.json`. -2. Select the MP4 asset for upload/editing; keep the WEBM as source evidence. -3. Read `storyboard.json` for the beat list and lint warnings. -4. Read `captions.json` for subtitle/caption timing. -5. Import the MP4, thumbnail, and captions into the downstream tool. -6. Keep repo fixtures and `shotkit.config.js` as the repeatable source of truth. +1. Read the CLI `status` and manifest path. +2. On `needs-fix`, execute every agent-owned action and rerun only the listed + scenes with the next `--attempt`. +3. On `blocked`, report the exhausted technical blocker and attempted fixes. +4. On `awaiting-approval`, present each final candidate in the Calibrator and + wait for the user's Approve or Request changes decision. +5. On `changes-requested`, apply the decision note, recapture, and return the + new digest for review. +6. On `approved`, let an authorized connector publish only the approved digest. +7. Keep repo fixtures and the story/action script as the repeatable source. -Tool-specific notes: +Fallback tool notes: - Figma MCP: use the `figma-mcp` hint when the manifest has a thumbnail and storyboard. It is good for cover frames, social layout, and design-system @@ -105,9 +214,12 @@ Tool-specific notes: Current warning codes: +- `invalid-captions` - `no-captions` - `single-caption` - `late-first-caption` +- `dense-focus-caption` +- `invalid-caption-options` - `long-caption` - `missing-safety-restore` - `missing-mp4` @@ -116,6 +228,22 @@ Current warning codes: - `short-duration` - `long-duration` - `missing-duration` +- `invalid-duration` +- `caption-locale-missing` +- `caption-font-not-embedded` +- `caption-missing-glyph` +- `caption-font-load-failed` +- `caption-typography-not-applied` +- `caption-type-fit-failed` +- `caption-unbalanced-lines` +- `caption-outside-viewport` +- `caption-overflow` +- `caption-too-many-lines` +- `caption-outline-missing` +- `caption-protected-region-overlap` +- `protected-region-outside-viewport` +- `caption-frame-missing` +- `caption-timing-drift` Lint warnings do not fail a capture. They tell the agent how to improve the next `shotkit.config.js` edit. @@ -127,8 +255,11 @@ The handoff contract is versioned independently from the npm package: - Top-level `version: 1` means handoff contract v1. - Top-level `kind` identifies the document type. - `$schema` points at the matching schema URN. +- Approval decisions use `kind: "shotkit.approval"` and are tied to an asset + digest, not a mutable filename. - New fields may be added in v1. Existing fields should keep their meaning. Downstream tools should ignore unknown fields and key off `kind`, `version`, and -`assets[].role`. For validation, load the schema files from the installed -`shotkit` package rather than fetching the URN. +`assets[].role`. For validation, prefer the schemas copied into the handoff +pack. The same files remain available through the installed `shotkit` package's +`./schemas/*` export; never fetch the URN as a URL. diff --git a/eslint.config.js b/eslint.config.js index 2b1e607..3748766 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,13 +17,17 @@ module.exports = [ }, }, { - files: ['test/**/*.js'], + files: ['test/**/*.{js,mjs}'], languageOptions: { globals: { ...globals.jest } }, }, { files: ['scripts/**/*.mjs'], languageOptions: { sourceType: 'module' }, }, + { + files: ['calibrator/**/*.js', 'campaign/**/*.js', 'test/**/*.mjs'], + languageOptions: { sourceType: 'module' }, + }, { ignores: ['node_modules/', 'coverage/'], }, diff --git a/package-lock.json b/package-lock.json index 4e445db..dcc9b01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,18 @@ "version": "1.3.0", "license": "MIT", "dependencies": { - "playwright": "^1.60.0" + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "fontkit": "^2.0.4", + "playwright": "^1.60.0", + "pngjs": "^7.0.0", + "subset-font": "^2.5.0" }, "bin": { "shotkit": "bin/shotkit.js" }, "devDependencies": { "@eslint/js": "^10.0.1", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", "eslint": "^10.4.1", "globals": "^17.5.0", "jest": "^30.3.0" @@ -1312,6 +1315,15 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1797,7 +1809,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1814,7 +1825,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -2019,6 +2029,26 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.35", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", @@ -2045,6 +2075,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -2265,6 +2304,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2392,6 +2440,12 @@ "node": ">=8" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2712,7 +2766,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -2733,7 +2786,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, "funding": [ { "type": "github", @@ -2807,6 +2859,33 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/fontverter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fontverter/-/fontverter-2.0.0.tgz", + "integrity": "sha512-DFVX5hvXuhi1Jven1tbpebYTCT9XYnvx6/Z+HFUPb7ZRMCW+pj2clU9VMhoTPgWKPhAs7JJDSk3CW1jNUvKCZQ==", + "license": "BSD-3-Clause", + "dependencies": { + "wawoff2": "^2.0.0", + "woff2sfnt-sfnt2woff": "^1.0.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2977,6 +3056,12 @@ "dev": true, "license": "ISC" }, + "node_modules/harfbuzzjs": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.10.3.tgz", + "integrity": "sha512-GJnLUrgLMadlMYrBGEXwYEimObbysy3prWT4HyPpFQERvgTU/OZ+ReUlEPOum6w4RBtFXzXiCCmECOr4sz3qwQ==", + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3834,9 +3919,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -3878,7 +3963,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -3958,6 +4042,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4168,7 +4258,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -4213,6 +4302,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -4429,6 +4524,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4525,7 +4629,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4554,6 +4657,12 @@ "node": ">=8" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -4835,6 +4944,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/subset-font": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/subset-font/-/subset-font-2.5.0.tgz", + "integrity": "sha512-Vsa8ngQ/ohhUj0an7on49y9jLZ2rK5U+T1FzPM4/ZQY0xUy5mLis6BfFtPGzecTjFgYXQlvY7FlsJF4t3R/6Ug==", + "license": "BSD-3-Clause", + "dependencies": { + "fontverter": "^2.0.0", + "harfbuzzjs": "^0.10.3", + "lodash": "^4.17.21", + "p-limit": "^3.1.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4932,6 +5053,12 @@ "node": "*" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -4943,9 +5070,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -4990,6 +5115,26 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -5094,6 +5239,25 @@ "makeerror": "1.0.12" } }, + "node_modules/wawoff2": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wawoff2/-/wawoff2-2.0.1.tgz", + "integrity": "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "woff2_compress.js": "bin/woff2_compress.js", + "woff2_decompress.js": "bin/woff2_decompress.js" + } + }, + "node_modules/wawoff2/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5110,6 +5274,21 @@ "node": ">= 8" } }, + "node_modules/woff2sfnt-sfnt2woff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/woff2sfnt-sfnt2woff/-/woff2sfnt-sfnt2woff-1.0.0.tgz", + "integrity": "sha512-edK4COc1c1EpRfMqCZO1xJOvdUtM5dbVb9iz97rScvnTevqEB3GllnLWCmMVp1MfQBdF1DFg/11I0rSyAdS4qQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.7" + } + }, + "node_modules/woff2sfnt-sfnt2woff/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5331,7 +5510,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/package.json b/package.json index bcae8cb..191904b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "shotkit", "version": "1.3.0", - "description": "Capture Chrome Web Store assets and SNS demo handoff packs from a built browser extension with Playwright.", + "description": "Autonomously capture, validate, and retry CWS, X, and YouTube launch assets, then gate final user approval.", "main": "src/index.js", "exports": { ".": "./src/index.js", @@ -13,14 +13,17 @@ "files": [ "src", "bin", + "calibrator", + "campaign", "skills/capture", "docs/handoff-conventions.md", "schemas" ], "scripts": { - "lint": "eslint src/ bin/ scripts/ test/", + "lint": "eslint src/ bin/ calibrator/ campaign/ scripts/ test/", "research": "node scripts/research-to-product-fit.mjs", - "test": "jest --verbose --coverage", + "test": "npm run test:campaign && jest --verbose --coverage", + "test:campaign": "node --test test/campaign-ui.node.mjs", "pack:check": "node scripts/verify-pack.mjs", "install:browser": "playwright install chromium" }, @@ -37,6 +40,12 @@ "og-image", "store-assets", "demo-video", + "launch-assets", + "agent-ready", + "json-schema", + "product-evidence", + "publish-ready", + "video-automation", "storyboard", "handoff" ], @@ -46,27 +55,59 @@ "node": ">=22" }, "dependencies": { - "playwright": "^1.60.0" + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "fontkit": "^2.0.4", + "playwright": "^1.60.0", + "pngjs": "^7.0.0", + "subset-font": "^2.5.0" }, "devDependencies": { "@eslint/js": "^10.0.1", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", "eslint": "^10.4.1", "globals": "^17.5.0", "jest": "^30.3.0" }, "jest": { "collectCoverageFrom": [ + "src/channels.js", + "src/approval.js", + "src/calibration.js", + "src/calibrator-server.js", + "src/calibrator-http.js", + "src/calibrator-routes.js", + "src/calibrator-state.js", + "src/campaign.js", + "src/campaign-dashboard.js", + "src/calibration-verification.js", "src/presets.js", "src/describe.js", "src/extension.js", "src/serve.js", "src/cli.js", + "src/cli-runner.js", + "src/index.js", + "src/capture.js", + "src/capture-demo.js", + "src/capture-lifecycle.js", + "src/capture-plan.js", + "src/capture-static.js", "src/video.js", "src/demo.js", + "src/caption-language.js", + "src/caption-typography.js", + "src/demo-caption-focus.js", + "src/demo-caption-qa.js", + "src/demo-select.js", + "src/demo-time.js", + "src/demo-storyboard.js", "src/handoff.js", - "src/integrations.js" + "src/handoff-files.js", + "src/handoff-validator.js", + "src/image-qa.js", + "src/integrations.js", + "src/publish.js", + "src/review-request.js" ], "coverageReporters": [ "text", diff --git a/schemas/approval.schema.json b/schemas/approval.schema.json new file mode 100644 index 0000000..95f7cc5 --- /dev/null +++ b/schemas/approval.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:starter-series:shotkit:schema:approval:v1", + "title": "shotkit user approval decisions", + "type": "object", + "additionalProperties": false, + "required": ["$schema", "version", "kind", "decisions"], + "properties": { + "$schema": { "const": "urn:starter-series:shotkit:schema:approval:v1" }, + "version": { "const": 1 }, + "kind": { "const": "shotkit.approval" }, + "decisions": { + "type": "object", + "propertyNames": { + "pattern": "^(?!(?:__proto__|prototype|constructor)$).*\\S.*$" + }, + "additionalProperties": { + "type": "object", + "propertyNames": { + "pattern": "^(?!(?:__proto__|prototype|constructor)$).*\\S.*$" + }, + "additionalProperties": { "$ref": "#/$defs/decision" } + } + } + }, + "$defs": { + "decision": { + "type": "object", + "additionalProperties": false, + "required": ["status", "assetDigest", "decidedAt"], + "properties": { + "status": { "enum": ["approved", "changes-requested"] }, + "assetDigest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "profileHash": { "type": "string", "minLength": 1 }, + "decidedAt": { "type": "string", "format": "date-time" }, + "note": { "type": "string", "minLength": 1, "maxLength": 2000 } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "changes-requested" } } }, + "then": { "required": ["note"] } + } + ] + } + } +} diff --git a/schemas/calibration.schema.json b/schemas/calibration.schema.json new file mode 100644 index 0000000..6d49be1 --- /dev/null +++ b/schemas/calibration.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:starter-series:shotkit:schema:calibration:v1", + "title": "shotkit calibration profile", + "type": "object", + "additionalProperties": false, + "required": ["version", "profiles"], + "properties": { + "version": { "const": 1 }, + "profiles": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/profile" } + } + } + }, + "$defs": { + "profile": { + "type": "object", + "additionalProperties": false, + "properties": { + "layoutPreset": { "type": "string", "minLength": 1 }, + "framing": { + "type": "object", + "additionalProperties": false, + "required": ["scale", "focusX", "focusY"], + "properties": { + "scale": { "type": "number", "minimum": 1, "maximum": 1.2 }, + "focusX": { "type": "number", "minimum": 0, "maximum": 1 }, + "focusY": { "type": "number", "minimum": 0, "maximum": 1 } + } + }, + "captionOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "position": { "enum": ["bottom-left", "bottom"] }, + "appearance": { "enum": ["panel", "outline"] }, + "bottomOffset": { "type": "integer", "minimum": 0 } + } + }, + "protectedRegions": { + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "x", "y", "width", "height"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "x": { "type": "number", "minimum": 0 }, + "y": { "type": "number", "minimum": 0 }, + "width": { "type": "number", "exclusiveMinimum": 0 }, + "height": { "type": "number", "exclusiveMinimum": 0 } + } + } + }, + "verification": { + "type": "object", + "additionalProperties": false, + "required": ["profileHash", "status", "verifiedAt"], + "properties": { + "profileHash": { "type": "string", "minLength": 1 }, + "status": { "const": "publish-ready" }, + "verifiedAt": { "type": "string", "format": "date-time" } + } + } + } + } + } +} diff --git a/schemas/captions.schema.json b/schemas/captions.schema.json index 136ede8..f7b220c 100644 --- a/schemas/captions.schema.json +++ b/schemas/captions.schema.json @@ -19,6 +19,25 @@ "required": ["name", "captions"], "properties": { "name": { "type": "string" }, + "story": { "type": "string" }, + "target": { "type": "string" }, + "style": { + "type": "object", + "required": ["mode", "position"], + "properties": { + "mode": { "enum": ["static", "focus"] }, + "appearance": { "enum": ["panel", "outline"] }, + "position": { "enum": ["bottom-left", "bottom"] }, + "bottomOffset": { "type": "integer", "minimum": 0 }, + "wordsPerChunk": { "type": "integer", "minimum": 1, "maximum": 6 }, + "wordMs": { "type": "integer", "minimum": 120, "maximum": 2000 }, + "activeColor": { "type": "string" }, + "locale": { "type": "string", "minLength": 1 }, + "direction": { "enum": ["ltr", "rtl"] }, + "typography": { "$ref": "#/$defs/typography" } + } + }, + "qa": { "$ref": "#/$defs/captionQa" }, "captions": { "type": "array", "items": { @@ -27,12 +46,118 @@ "properties": { "at": { "type": "number", "minimum": 0 }, "atMs": { "type": "integer", "minimum": 0 }, - "text": { "type": "string" } + "text": { "type": "string" }, + "role": { "enum": ["result", "action", "proof", "safety", "restore", "cta"] } + } + } + }, + "timeline": { + "type": "array", + "items": { + "type": "object", + "required": ["at", "atMs", "text", "sourceText", "words", "activeWordIndex", "condensed"], + "properties": { + "at": { "type": "number", "minimum": 0 }, + "atMs": { "type": "integer", "minimum": 0 }, + "endAt": { "type": "number", "minimum": 0 }, + "endMs": { "type": "integer", "minimum": 0 }, + "text": { "type": "string" }, + "sourceText": { "type": "string" }, + "words": { "type": "array", "items": { "type": "string" } }, + "activeWordIndex": { "type": ["integer", "null"], "minimum": 0 }, + "condensed": { "type": "boolean" } + } + } + } + } + } + } + }, + "$defs": { + "captionQa": { + "type": "object", + "additionalProperties": true, + "required": ["scheduledFrameCount", "measuredFrameCount", "typography", "rendering"], + "properties": { + "scheduledFrameCount": { "type": "integer", "minimum": 0 }, + "measuredFrameCount": { "type": "integer", "minimum": 0 }, + "typography": { + "type": ["object", "null"], + "additionalProperties": true, + "properties": { + "enabled": { "type": "boolean" }, + "deterministic": { "type": "boolean" }, + "locale": { "type": "string" }, + "direction": { "enum": ["ltr", "rtl"] }, + "fontFiles": { "type": "array", "maxItems": 4, "items": { "type": "string" } }, + "fontOptimization": { + "type": "array", + "maxItems": 4, + "items": { + "type": "object", + "required": ["family", "sourceBytes", "embeddedBytes"], + "properties": { + "family": { "type": "string" }, + "sourceBytes": { "type": "integer", "minimum": 1 }, + "embeddedBytes": { "type": "integer", "minimum": 1 } + } + } + }, + "missingGlyphs": { + "type": "array", + "items": { + "type": "object", + "required": ["character", "codePoint"], + "properties": { + "character": { "type": "string" }, + "codePoint": { "type": "string", "pattern": "^U\\+[0-9A-F]+$" } + } } } } + }, + "rendering": { + "type": "object", + "additionalProperties": true, + "required": ["fontLoaded", "maxFontLoadMs", "fitStatuses", "resolvedFontSize", "maxLineCount", "minLineBalance"], + "properties": { + "fontLoaded": { "type": ["boolean", "null"] }, + "maxFontLoadMs": { "type": ["number", "null"], "minimum": 0 }, + "fitStatuses": { + "type": "array", + "uniqueItems": true, + "items": { "enum": ["not-requested", "fit", "overflow"] } + }, + "resolvedFontSize": { + "type": ["object", "null"], + "required": ["min", "max"], + "properties": { + "min": { "type": "number", "minimum": 1 }, + "max": { "type": "number", "minimum": 1 } + } + }, + "maxLineCount": { "type": "integer", "minimum": 0 }, + "minLineBalance": { "type": ["number", "null"], "minimum": 0, "maximum": 1 } + } } } + }, + "typography": { + "type": "object", + "additionalProperties": true, + "required": ["locale", "direction", "family", "minFontSize", "maxLines", "fit"], + "properties": { + "locale": { "type": "string", "minLength": 1 }, + "direction": { "enum": ["ltr", "rtl"] }, + "family": { "type": "string", "minLength": 1 }, + "weight": { "type": "string" }, + "minFontSize": { "type": "integer", "minimum": 12, "maximum": 96 }, + "maxFontSize": { "type": "integer", "minimum": 12, "maximum": 120 }, + "maxLines": { "type": "integer", "minimum": 1, "maximum": 3 }, + "fit": { "enum": ["none", "shrink"] }, + "minLineBalance": { "type": "number", "minimum": 0, "maximum": 1 }, + "fontFamilies": { "type": "array", "maxItems": 4, "items": { "type": "string" } } + } } } } diff --git a/schemas/shotkit-manifest.schema.json b/schemas/shotkit-manifest.schema.json index a79f92f..628ccbd 100644 --- a/schemas/shotkit-manifest.schema.json +++ b/schemas/shotkit-manifest.schema.json @@ -26,20 +26,77 @@ "project": { "$ref": "#/$defs/project" }, "outDir": { "type": "string" }, "flags": { "type": "object", "additionalProperties": true }, + "run": { + "type": "object", + "required": [ + "id", + "mode", + "requestedScenes", + "video", + "noBuild", + "mp4", + "configuredDemos", + "selectedDemos", + "capturedDemos", + "skippedDemos" + ], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "mode": { "enum": ["full", "partial"] }, + "requestedScenes": { + "type": "array", + "items": { "type": "string" } + }, + "requestedTargets": { "$ref": "#/$defs/names" }, + "attempt": { "type": "integer", "minimum": 1 }, + "video": { "type": "boolean" }, + "noBuild": { "type": "boolean" }, + "mp4": { "type": "boolean" }, + "configuredDemos": { "$ref": "#/$defs/names" }, + "configuredTargets": { "$ref": "#/$defs/names" }, + "configuredTargetDemos": { + "type": "array", + "items": { "$ref": "#/$defs/configuredTargetDemo" } + }, + "selectedDemos": { "$ref": "#/$defs/names" }, + "capturedDemos": { "$ref": "#/$defs/names" }, + "skippedDemos": { "$ref": "#/$defs/names" } + } + }, "positioning": { "const": "capture-and-handoff-kit" }, + "category": { "const": "agent-ready-launch-asset-pipeline" }, "handoff": { "type": "object", "additionalProperties": true, - "required": ["contractVersion", "schemas", "storyboards", "captions", "recommendedFlow", "adapterHints"], + "required": [ + "contractVersion", + "schemas", + "storyboards", + "captions", + "recommendedFlow", + "adapterHints" + ], "properties": { "contractVersion": { "const": 1 }, + "entrypoint": { "const": "shotkit-manifest.json" }, "schemas": { "type": "object", "required": ["manifest", "storyboard", "captions"], "properties": { "manifest": { "type": "string" }, "storyboard": { "type": "string" }, - "captions": { "type": "string" } + "captions": { "type": "string" }, + "approval": { "const": "urn:starter-series:shotkit:schema:approval:v1" } + } + }, + "schemaFiles": { + "type": "object", + "required": ["manifest", "storyboard", "captions"], + "properties": { + "manifest": { "const": "schemas/shotkit-manifest.schema.json" }, + "storyboard": { "const": "schemas/storyboard.schema.json" }, + "captions": { "const": "schemas/captions.schema.json" }, + "approval": { "const": "schemas/approval.schema.json" } } }, "storyboards": { "const": "storyboard.json" }, @@ -51,6 +108,35 @@ "adapterHints": { "type": "array", "items": { "$ref": "#/$defs/adapterHint" } + }, + "automation": { "$ref": "#/$defs/automation" }, + "approval": { "$ref": "#/$defs/approvalGate" }, + "review": { + "type": "object", + "required": ["status", "warningCount", "warnings", "incomplete"], + "properties": { + "status": { "enum": ["ready", "needs-review", "incomplete"] }, + "warningCount": { "type": "integer", "minimum": 0 }, + "warnings": { + "type": "array", + "items": { "$ref": "#/$defs/reviewWarning" } + }, + "incomplete": { + "type": "array", + "items": { "$ref": "#/$defs/incompleteItem" } + } + } + }, + "summary": { + "type": "object", + "required": ["assetCount", "demoCount", "readyAdapterCount"], + "properties": { + "assetCount": { "type": "integer", "minimum": 0 }, + "demoCount": { "type": "integer", "minimum": 0 }, + "readyAdapterCount": { "type": "integer", "minimum": 0 }, + "publishReadyTargetCount": { "type": "integer", "minimum": 0 }, + "approvedTargetCount": { "type": "integer", "minimum": 0 } + } } } }, @@ -61,6 +147,10 @@ "config": { "type": "object", "additionalProperties": true } }, "$defs": { + "names": { + "type": "array", + "items": { "type": "string" } + }, "project": { "type": "object", "additionalProperties": true, @@ -84,17 +174,252 @@ "outPath": { "type": "string" }, "width": { "type": "integer", "minimum": 1 }, "height": { "type": "integer", "minimum": 1 }, + "target": { "type": "string" }, + "channel": { "type": "string" }, + "media": { "$ref": "#/$defs/mediaProbe" }, + "visual": { "$ref": "#/$defs/visualQa" }, + "runId": { "type": "string" }, + "capturedAt": { "type": "string", "format": "date-time" }, + "state": { "enum": ["produced", "retained", "modified"] }, + "bytes": { "type": "integer", "minimum": 0 }, + "integrity": { + "type": "object", + "required": ["algorithm", "digest"], + "properties": { + "algorithm": { "const": "sha256" }, + "digest": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + }, + "observed": { + "type": "object", + "required": ["bytes", "integrity"], + "properties": { + "bytes": { "type": "integer", "minimum": 0 }, + "integrity": { + "type": "object", + "required": ["algorithm", "digest"], + "properties": { + "algorithm": { "const": "sha256" }, + "digest": { "type": "string", "pattern": "^[a-f0-9]{64}$" } + } + } + } + }, "source": { "type": "object", "additionalProperties": true, "required": ["kind"], "properties": { "kind": { "type": "string" }, - "name": { "type": "string" } + "name": { "type": "string" }, + "story": { "type": "string" }, + "target": { "type": "string" } } } } }, + "configuredTargetDemo": { + "type": "object", + "required": ["name", "target"], + "properties": { + "name": { "type": "string" }, + "story": { "type": "string" }, + "profileHash": { "type": "string", "minLength": 1 }, + "target": { "type": "string" } + } + }, + "mediaProbe": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": { "type": "boolean" }, + "error": { "type": "string" }, + "codec": { "type": "string" }, + "pixelFormat": { "type": "string" }, + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 }, + "durationSeconds": { "type": ["number", "null"], "minimum": 0 } + } + }, + "visualQa": { + "type": "object", + "required": ["ok", "nonBlank"], + "properties": { + "ok": { "type": "boolean" }, + "nonBlank": { "type": "boolean" }, + "error": { "type": "string" }, + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 }, + "opaqueRatio": { "type": "number", "minimum": 0, "maximum": 1 }, + "luminanceStdDev": { "type": "number", "minimum": 0 }, + "colorBuckets": { "type": "integer", "minimum": 0 } + } + }, + "automation": { + "type": "object", + "required": [ + "policy", + "status", + "manualFallback", + "userActionRequired", + "maxAttempts", + "attempt", + "targets", + "actions", + "retryScenes" + ], + "properties": { + "policy": { "const": "exception-only" }, + "status": { "enum": ["not-requested", "needs-fix", "blocked", "publish-ready"] }, + "manualFallback": { "type": "boolean" }, + "userActionRequired": { "type": "boolean" }, + "maxAttempts": { "type": "integer", "minimum": 1 }, + "attempt": { "type": "integer", "minimum": 1 }, + "targets": { + "type": "array", + "items": { "$ref": "#/$defs/publishTarget" } + }, + "actions": { + "type": "array", + "items": { "$ref": "#/$defs/agentAction" } + }, + "retryScenes": { "$ref": "#/$defs/names" } + } + }, + "publishTarget": { + "type": "object", + "required": [ + "target", + "label", + "platform", + "delivery", + "demo", + "story", + "status", + "checks", + "actions", + "upload" + ], + "properties": { + "target": { "type": "string" }, + "label": { "type": "string" }, + "platform": { "type": "string" }, + "delivery": { "type": "string" }, + "demo": { "type": "string" }, + "story": { "type": "string" }, + "status": { "enum": ["needs-fix", "blocked", "publish-ready"] }, + "deliverable": { "type": ["object", "null"], "additionalProperties": true }, + "thumbnail": { "type": ["object", "null"], "additionalProperties": true }, + "checks": { + "type": "array", + "items": { "$ref": "#/$defs/publishCheck" } + }, + "actions": { + "type": "array", + "items": { "$ref": "#/$defs/agentAction" } + }, + "upload": { + "type": "object", + "required": ["connector", "requiresAuthorization", "specUrl"], + "properties": { + "connector": { "type": "string" }, + "requiresAuthorization": { "type": "boolean" }, + "specUrl": { "type": "string", "format": "uri" } + } + } + } + }, + "publishCheck": { + "type": "object", + "required": ["code", "status", "message"], + "properties": { + "code": { "type": "string" }, + "status": { "enum": ["pass", "fail"] }, + "message": { "type": "string" } + } + }, + "approvalGate": { + "type": "object", + "additionalProperties": false, + "required": ["required", "status", "file", "userActionRequired", "publishable", "targets"], + "properties": { + "required": { "type": "boolean" }, + "status": { "enum": ["not-requested", "not-ready", "awaiting-approval", "changes-requested", "approved"] }, + "file": { "const": "shotkit-approval.json" }, + "userActionRequired": { "type": "boolean" }, + "publishable": { "type": "boolean" }, + "targets": { + "type": "array", + "items": { "$ref": "#/$defs/approvalTarget" } + } + } + }, + "approvalTarget": { + "type": "object", + "additionalProperties": false, + "required": ["target", "demo", "story", "status", "assetDigest", "stale"], + "properties": { + "target": { "type": "string" }, + "demo": { "type": "string" }, + "story": { "type": "string" }, + "status": { "enum": ["not-ready", "awaiting-approval", "changes-requested", "approved"] }, + "assetDigest": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$" }, + "profileHash": { "type": "string", "minLength": 1 }, + "stale": { "type": "boolean" }, + "decision": { + "type": "object", + "additionalProperties": false, + "required": ["status", "decidedAt"], + "properties": { + "status": { "enum": ["approved", "changes-requested"] }, + "decidedAt": { "type": "string", "format": "date-time" }, + "note": { "type": "string", "minLength": 1, "maxLength": 2000 } + } + } + } + }, + "agentAction": { + "type": "object", + "required": ["code", "owner", "fix", "rerun"], + "properties": { + "code": { "type": "string" }, + "owner": { "const": "agent" }, + "demo": { "type": "string" }, + "target": { "type": "string" }, + "fix": { "type": "string" }, + "rerun": { + "type": "object", + "additionalProperties": true, + "properties": { + "scene": { "type": "string" }, + "target": { "type": "string" } + } + } + } + }, + "incompleteItem": { + "type": "object", + "required": ["code", "reason", "fix"], + "properties": { + "code": { "enum": ["demo-skipped", "asset-integrity-mismatch"] }, + "demo": { "type": "string" }, + "asset": { "type": "string" }, + "reason": { "enum": ["video-disabled", "not-captured", "retained-file-changed"] }, + "fix": { "type": "string" } + } + }, + "reviewWarning": { + "type": "object", + "additionalProperties": true, + "required": ["demo", "code", "severity", "message", "fix"], + "properties": { + "demo": { "type": "string" }, + "code": { "type": "string" }, + "severity": { "enum": ["warning"] }, + "message": { "type": "string" }, + "fix": { "type": "string" } + } + }, "adapterHint": { "type": "object", "additionalProperties": true, diff --git a/schemas/storyboard.schema.json b/schemas/storyboard.schema.json index 9d2bf2d..03e3056 100644 --- a/schemas/storyboard.schema.json +++ b/schemas/storyboard.schema.json @@ -37,12 +37,41 @@ "required": ["name", "audience", "viewport", "recommendedNextTool", "beats"], "properties": { "name": { "type": "string" }, + "story": { "type": "string" }, + "target": { "type": "string" }, + "lintEnabled": { "type": "boolean" }, "audience": { "type": "string" }, + "channelProfile": { + "type": "object", + "additionalProperties": true, + "required": ["id", "label", "platform", "delivery", "specUrl"], + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" }, + "platform": { "type": "string" }, + "delivery": { "type": "string" }, + "specUrl": { "type": "string", "format": "uri" } + } + }, "preset": { "type": "string" }, "viewport": { "$ref": "#/$defs/viewport" }, "recommendedNextTool": { "type": "string" }, "trim": { "type": ["object", "null"], "additionalProperties": true }, "framing": { "type": "object", "additionalProperties": true }, + "calibration": { + "type": ["object", "null"], + "required": ["profileHash", "protectedRegions"], + "properties": { + "profileHash": { "type": "string", "minLength": 1 }, + "layoutPreset": { "type": "string" }, + "protectedRegions": { + "type": "array", + "maxItems": 3, + "items": { "$ref": "#/$defs/protectedRegion" } + } + } + }, + "captionStyle": { "$ref": "#/$defs/captionStyle" }, "thumbnail": { "type": ["object", "boolean", "null"], "additionalProperties": true }, "recommendedStory": { "type": "object", "additionalProperties": true }, "beats": { @@ -60,13 +89,58 @@ "height": { "type": "integer", "minimum": 1 } } }, + "captionStyle": { + "type": "object", + "required": ["mode", "position"], + "properties": { + "mode": { "enum": ["static", "focus"] }, + "appearance": { "enum": ["panel", "outline"] }, + "position": { "enum": ["bottom-left", "bottom"] }, + "bottomOffset": { "type": "integer", "minimum": 0 }, + "wordsPerChunk": { "type": "integer", "minimum": 1, "maximum": 6 }, + "wordMs": { "type": "integer", "minimum": 120, "maximum": 2000 }, + "activeColor": { "type": "string" }, + "locale": { "type": "string", "minLength": 1 }, + "direction": { "enum": ["ltr", "rtl"] }, + "typography": { + "type": "object", + "additionalProperties": true, + "required": ["locale", "direction", "family", "minFontSize", "maxLines", "fit"], + "properties": { + "locale": { "type": "string", "minLength": 1 }, + "direction": { "enum": ["ltr", "rtl"] }, + "family": { "type": "string", "minLength": 1 }, + "weight": { "type": "string" }, + "minFontSize": { "type": "integer", "minimum": 12, "maximum": 96 }, + "maxFontSize": { "type": "integer", "minimum": 12, "maximum": 120 }, + "maxLines": { "type": "integer", "minimum": 1, "maximum": 3 }, + "fit": { "enum": ["none", "shrink"] }, + "minLineBalance": { "type": "number", "minimum": 0, "maximum": 1 }, + "fontFamilies": { "type": "array", "maxItems": 4, "items": { "type": "string" } } + } + } + } + }, + "protectedRegion": { + "type": "object", + "required": ["id", "x", "y", "width", "height"], + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" }, + "x": { "type": "number", "minimum": 0 }, + "y": { "type": "number", "minimum": 0 }, + "width": { "type": "number", "exclusiveMinimum": 0 }, + "height": { "type": "number", "exclusiveMinimum": 0 } + } + }, "beat": { "type": "object", "required": ["at", "atMs", "text"], "properties": { "at": { "type": "number", "minimum": 0 }, "atMs": { "type": "integer", "minimum": 0 }, - "text": { "type": "string" } + "text": { "type": "string" }, + "role": { "enum": ["result", "action", "proof", "safety", "restore", "cta"] } } }, "lintSummary": { diff --git a/scripts/verify-pack.mjs b/scripts/verify-pack.mjs index 93bda3e..451810a 100644 --- a/scripts/verify-pack.mjs +++ b/scripts/verify-pack.mjs @@ -17,9 +17,22 @@ const requiredFiles = [ "LICENSE", "src/index.js", "bin/shotkit.js", + "calibrator/index.html", + "calibrator/styles.css", + "calibrator/app.js", + "calibrator/model.js", + "calibrator/preview.js", + "calibrator/regions.js", + "campaign/index.html", + "campaign/styles.css", + "campaign/app.js", "skills/capture/SKILL.md", "docs/handoff-conventions.md", "schemas/shotkit-manifest.schema.json", + "schemas/storyboard.schema.json", + "schemas/captions.schema.json", + "schemas/calibration.schema.json", + "schemas/approval.schema.json", ]; for (const relpath of requiredFiles) { @@ -45,7 +58,7 @@ for (const relpath of requiredFiles) { for (const packedPath of packedPaths) { assert.ok( - /^(package\.json|README\.md|README\.ko\.md|LICENSE|src\/|bin\/|skills\/capture\/|docs\/handoff-conventions\.md|schemas\/)/.test(packedPath), + /^(package\.json|README\.md|README\.ko\.md|LICENSE|src\/|bin\/|calibrator\/|campaign\/|skills\/capture\/|docs\/handoff-conventions\.md|schemas\/)/.test(packedPath), `unexpected file in npm pack output: ${packedPath}`, ); } diff --git a/skills/capture/SKILL.md b/skills/capture/SKILL.md index e447125..db8075a 100644 --- a/skills/capture/SKILL.md +++ b/skills/capture/SKILL.md @@ -1,10 +1,10 @@ --- name: capture -description: Capture Chrome Web Store + social promo assets (screenshots, promo tiles, demo screencast, listing copy, privacy disclosure worksheet) from a built browser extension using shotkit. Use when asked to generate store screenshots, CWS assets, promo/OG images, listing/privacy handoff, or a demo video for a repo that has a shotkit.config.js (or store.config.js). -allowed-tools: Bash(shotkit*), Bash(node bin/shotkit.js*), Bash(npm run capture:store*), Bash(npm exec -- playwright install chromium), Read +description: Autonomously produce browser-extension launch assets with shotkit, then present the technically verified final media for explicit user approval. Use for CWS/YouTube promo video, X video, YouTube Shorts, store screenshots, listing/privacy evidence, or channel variants. Infer mechanical channel settings, capture, validate, fix, and retry without interrupting the user; bind Approve or Request changes to the exact final file digest. +allowed-tools: Bash(shotkit*), Bash(node bin/shotkit.js*), Bash(npm run capture:store*), Bash(npm exec -- playwright install chromium), Read, Edit, Write --- -# Capture store/social assets with shotkit +# Produce and approve launch assets with shotkit shotkit drives the repo's **built** extension with Playwright and writes assets into the config's `outDir` (default `store-assets/`). A successful run doubles @@ -12,36 +12,94 @@ as a real-bundle smoke test — a screenshot or clip only appears if that featur rendered from the shipped code. By default, it also writes a handoff pack: `storyboard.json`, `captions.json`, and `shotkit-manifest.json`. -## Steps +## Autonomous workflow -1. **Preconditions** — the repo has a `shotkit.config.js` (or legacy +1. **Translate intent into targets** — infer channel profiles from the user's + campaign request. Supported targets are `cws-youtube`, `x`, and + `youtube-shorts`. Do not ask the user to choose viewport, codec, duration, + thumbnail timing, or editor. +2. **Preconditions** — the repo has a `shotkit.config.js` (or legacy `store.config.js`); Chromium is installed (`npm exec -- playwright install chromium`, one-time); the config's `build` command succeeds. -2. **Run** (from the repo, or pass its path): +3. **Create or update one story** — keep product actions and captions in one + demo and declare channel variants through `targets`: + + ```js + demo: { + name: 'skillbridge', + targets: ['cws-youtube', 'x'], + captions: [ + { at: 0.5, text: 'Translate the lesson in place' }, + { at: 18, text: 'Restore the original anytime' }, + ], + async run({ page, env, demo, target }) { /* one reusable story */ }, + } + ``` + + Shotkit expands target-specific names, viewport, H.264 MP4, 30-second cap, + poster frame, and caption treatment. The `youtube-shorts` profile uses + three-word outline focus chunks with an animated current-word highlight and a + visual-guide-safe left/bottom placement. Use `targetOptions.` only when the shared + story genuinely needs target-specific framing or caption tuning. + If a desktop UI does not reflow at 720×1280, give Shorts a focused `run` + override and fixture layout that removes secondary panels and enlarges the + action/result. Never squeeze the complete desktop story into the vertical frame. + For every localized campaign variant, declare `captionOptions.typography` + with the exact locale and project-local licensed font files. Prefer a font + family designed for that script; include fallback faces until glyph QA is + complete. Do not rely on the operator machine's system fonts for a + publishable multilingual asset. +4. **Run attempt 1** (from the repo, or pass its path): ```bash - shotkit --json - shotkit --json # against another checkout + shotkit --json --attempt 1 + shotkit --json --attempt 1 ``` + If repeated composition fixes remain unresolved and the repo declares + `config.calibration`, start `shotkit --calibrate`. Keep adjustments inside + its authored presets, bounded framing/caption controls, and three protected + regions. Save the profile, trigger the real recapture, and continue only + from its resulting `publish-ready` or structured `needs-fix` state. Do not + ask the user to diagnose composition or operate the controls. Once technical + QA passes, open `shotkit --campaign` for the user's final media decision. + Before npm publication, run the source checkout with - `node bin/shotkit.js --json`, or use a project wrapper such as + `node bin/shotkit.js --json --attempt 1`, or use a project wrapper such as `npm run capture:store -- --json`. - Useful flags: `--scene ` (one scene/promoTile/demo/demos entry, + Useful flags: `--scene ` (one story, expanded variant, static scene, `description`, or `privacy`), + `--target ` (one channel target), `--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", …] }`. +5. **Read the result** — stdout is exactly one JSON object: + `{ "ok": true, "status": "awaiting-approval", "machineStatus": "publish-ready", "outDir": "...", "manifest": "/abs/path/shotkit-manifest.json", "produced": [...] }`. Progress logs go to stderr in `--json` mode. - For follow-up editing, read `shotkit-manifest.json` first; it lists the - mp4/webm, thumbnail, captions, storyboard, schema ids, recommended handoff - flow, and `handoff.adapterHints[]` for likely next tools/connectors. -4. **On failure** — exit code `2` = no config found, `1` = runtime failure; - stderr carries `{ "ok": false, "error": … }`. Common causes: build failure, - Chromium not installed, a scene's wait timing out (feature didn't render). + Read `handoff.automation` for technical repair work and `handoff.approval` + for the user decision. Do not use the legacy compatibility review summary. +6. **Fix, review, and publish through the gate**: + - `needs-fix`: apply every `automation.actions[]` item whose owner is + `agent`, edit the config, then rerun `automation.retryScenes[]` with + `--attempt 2`. Repeat through `automation.maxAttempts`. + - `blocked`: automated attempts are exhausted. Report only the concrete + technical blocker and attempted fixes; ask for technical input. + - `awaiting-approval`: technical QA passed. Open the Campaign Dashboard and + present the rendered candidate to the user. Keep the Calibrator under + Advanced for agent-owned composition work. Do not approve on the user's behalf. + - `changes-requested`: read the digest-bound decision note, implement it as + the next agent-owned edit, recapture, and return the new candidate for + another decision. + - `approved`: the exact recorded digest passed user review. An authorized + uploader may publish that digest; any recapture or profile edit invalidates + the decision. + - `not-requested`: legacy capture mode; no channel target was configured. +7. **On runtime failure** — exit code `2` = usage/no config found, `1` = runtime + failure; stdout still carries the single JSON payload + `{ "ok": false, "error": … }`. Common causes: build failure, Chromium not + installed, an unknown `--scene`, or a scene's wait timing out (feature didn't + render). ## Notes @@ -60,9 +118,32 @@ rendered from the shipped code. By default, it also writes a handoff pack: (`1200×675`) for static X card images. - Demo configs can use timed `demo.captions` plus the helper passed to `demo.run`: `demo.caption(text)`, `demo.step(text, async () => { ... })`, - `demo.wait(ms)`, and `demo.click(selectorOrLocator, { moveMs, beforeMs, holdMs })`. - Captions and click highlights render as DOM overlays during recording and - avoid the top-left disclaimer badge. + `demo.wait(ms)`, `demo.click(selectorOrLocator, { moveMs, beforeMs, holdMs })`, + and `demo.select(selectorOrLocator, value, { openMs, holdMs })`. Captions, + arrow-pointer clicks, and mirrored native-select options render as DOM + overlays during recording and avoid the top-left disclaimer badge. Always + use `demo.select()` for a native `