From 0c02f09b32532a88fada6ac11a9d5abebf552297 Mon Sep 17 00:00:00 2001 From: heznpc Date: Wed, 8 Jul 2026 21:21:26 +0900 Subject: [PATCH 01/13] fix: preserve caller-owned extension dirs --- src/capture.js | 75 +++++++++++++++++++++----- test/capture-demo-postprocess.test.js | 76 +++++++++++++++++++++++++++ test/capture-description.test.js | 18 +++++++ 3 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 test/capture-demo-postprocess.test.js diff --git a/src/capture.js b/src/capture.js index 40e2817..8dbab2d 100644 --- a/src/capture.js +++ b/src/capture.js @@ -49,6 +49,27 @@ function normalizeSetup(result) { return { env: result.env || result, teardown: async () => {} }; } +function isManagedTempDir(dir, prefix) { + const resolved = path.resolve(dir); + return path.dirname(resolved) === path.resolve(os.tmpdir()) && path.basename(resolved).startsWith(prefix); +} + +function normalizePreparedExtension(result) { + if (typeof result === 'string') { + return { + dir: result, + cleanup: isManagedTempDir(result, 'store-ext-') ? () => fs.rmSync(result, { recursive: true, force: true }) : null, + }; + } + if (result && typeof result.dir === 'string') { + return { + dir: result.dir, + cleanup: typeof result.cleanup === 'function' ? result.cleanup : null, + }; + } + throw new Error('prepareExtension() must return an extension directory string or { dir, cleanup }'); +} + /** * @param {object} config the project's shotkit config object (scenes, etc.) * @param {object} [opts] @@ -80,6 +101,16 @@ async function capture(config, opts = {}) { const demoViewports = {}; const demoWarnings = {}; const tempDirs = []; + let fatalDemoError = null; + let cleaned = false; + let extensionCleanup = null; + + const cleanupTempResources = async () => { + if (cleaned) return; + cleaned = true; + for (const d of tempDirs) fs.rmSync(d, { recursive: true, force: true }); + if (extensionCleanup) await extensionCleanup(); + }; // 1. Build — the smoke test starts here. `config.build` is a repo-committed // command string (same trust boundary as a package.json script), run through @@ -92,8 +123,9 @@ async function capture(config, opts = {}) { } // 2. Prepare the unpacked extension dir to load. - const extensionDir = await config.prepareExtension(passFlags); - tempDirs.push(extensionDir); + const preparedExtension = normalizePreparedExtension(await config.prepareExtension(passFlags)); + const extensionDir = preparedExtension.dir; + extensionCleanup = preparedExtension.cleanup; // 3. Screenshots + promo + description run in a no-video context. const ctx = await launchWithExtension({ extensionDir, viewport: defaultViewport }); @@ -306,15 +338,23 @@ async function capture(config, opts = {}) { log(`✓ ${demoConfig.name}.webm (${viewport.width}×${viewport.height})`); // SNS post-processing: mp4 (H.264) and/or trim — needs a real ffmpeg, // fails loudly if one was requested but none is installed. - const extra = postProcessDemo({ - webmPath: out, - mp4: demoConfig.mp4 || opts.mp4, - trim: demoConfig.trim, - crop: demoConfig.crop, - zoom: demoConfig.zoom, - thumbnail: demoConfig.thumbnail, - log, - }); + let extra; + try { + extra = postProcessDemo({ + webmPath: out, + mp4: demoConfig.mp4 || opts.mp4, + trim: demoConfig.trim, + crop: demoConfig.crop, + zoom: demoConfig.zoom, + thumbnail: demoConfig.thumbnail, + log, + }); + } catch (err) { + const msg = err && err.message ? err.message : String(err); + fatalDemoError = new Error(`demo "${demoConfig.name}" post-processing failed: ${msg}`, { cause: err }); + log(`❌ ${fatalDemoError.message}`); + break; + } produced.push(...extra); for (const extraPath of extra) { const format = path.extname(extraPath).toLowerCase(); @@ -333,8 +373,10 @@ async function capture(config, opts = {}) { } } } catch (err) { - // One demo failing (e.g. mp4 requested but no ffmpeg) must not abort the - // remaining demos, the handoff pack, or temp-dir cleanup below. + // One demo run/recording failure must not abort the remaining demos, the + // handoff pack, or temp-dir cleanup below. Requested post-processing + // failures are treated as fatal above because they mean an expected + // deliverable is missing. log(`❌ demo "${demoConfig.name}" failed: ${err.message} — continuing with the remaining demos`); } finally { if (!page.isClosed()) await page.close().catch(() => {}); @@ -343,6 +385,11 @@ async function capture(config, opts = {}) { } } + if (fatalDemoError) { + await cleanupTempResources(); + throw fatalDemoError; + } + // 5. Handoff contract — metadata for external editors, MCP adapters, and // agents. This is the starter-pack layer, not a competing editor surface. if (config.handoff !== false) { @@ -375,7 +422,7 @@ async function capture(config, opts = {}) { } // 6. Cleanup temp dirs. - for (const d of tempDirs) fs.rmSync(d, { recursive: true, force: true }); + await cleanupTempResources(); log(`done — ${produced.length} asset(s) in ${path.relative(cwd, outDir) || '.'}/`); return { produced, outDir }; diff --git a/test/capture-demo-postprocess.test.js b/test/capture-demo-postprocess.test.js new file mode 100644 index 0000000..0a76e01 --- /dev/null +++ b/test/capture-demo-postprocess.test.js @@ -0,0 +1,76 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +jest.mock('../src/launch', () => { + const mockFs = require('fs'); + + function makePage() { + let closed = false; + return { + setViewportSize: jest.fn(async () => {}), + on: jest.fn(), + off: jest.fn(), + waitForTimeout: jest.fn(async () => {}), + evaluate: jest.fn(async () => {}), + video: jest.fn(() => ({ + saveAs: jest.fn(async (out) => { + mockFs.writeFileSync(out, 'webm'); + }), + })), + close: jest.fn(async () => { + closed = true; + }), + isClosed: jest.fn(() => closed), + }; + } + + return { + launchWithExtension: jest.fn(async () => ({ + extensionId: 'test-extension', + context: { + addInitScript: jest.fn(async () => {}), + newPage: jest.fn(async () => makePage()), + }, + })), + closeContext: jest.fn(async () => {}), + }; +}); + +jest.mock('../src/video', () => ({ + postProcessDemo: jest.fn(() => { + throw new Error('ffmpeg unavailable'); + }), +})); + +const { capture } = require('../src/capture'); +const { postProcessDemo } = require('../src/video'); + +test('capture fails when requested demo post-processing fails', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-demo-postprocess-')); + const extensionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-extension-')); + const logs = []; + + await expect(capture({ + outDir: 'store-assets', + prepareExtension: async () => extensionDir, + demos: [{ + name: 'demo', + mp4: true, + run: async () => {}, + }], + }, { + cwd, + noBuild: true, + log: (msg) => logs.push(msg), + })).rejects.toThrow(/demo "demo" post-processing failed: ffmpeg unavailable/); + + const webmPath = path.join(cwd, 'store-assets', 'demo.webm'); + expect(postProcessDemo).toHaveBeenCalledWith(expect.objectContaining({ + webmPath, + mp4: true, + })); + expect(fs.existsSync(webmPath)).toBe(true); + expect(fs.existsSync(path.join(cwd, 'store-assets', 'shotkit-manifest.json'))).toBe(false); + expect(logs.some((line) => line.includes('post-processing failed'))).toBe(true); +}); diff --git a/test/capture-description.test.js b/test/capture-description.test.js index 4f41edf..47f574d 100644 --- a/test/capture-description.test.js +++ b/test/capture-description.test.js @@ -75,3 +75,21 @@ test('capture writes listing and privacy worksheet from product manifest', async expect(manifest.assets.some((asset) => asset.role === 'store-listing-copy')).toBe(true); expect(manifest.assets.some((asset) => asset.role === 'privacy-disclosure')).toBe(true); }); + +test('capture does not delete caller-owned extension directories', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-capture-owned-extension-')); + const extensionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-extension-')); + + await capture({ + outDir: 'store-assets', + handoff: false, + prepareExtension: async () => extensionDir, + }, { + cwd, + noBuild: true, + noVideo: true, + log: () => {}, + }); + + expect(fs.existsSync(extensionDir)).toBe(true); +}); From ec384e8ffd0a481a33e9341298c2d890bd649c8a Mon Sep 17 00:00:00 2001 From: heznpc Date: Thu, 9 Jul 2026 13:47:31 +0900 Subject: [PATCH 02/13] fix: harden shotkit capture lifecycle --- bin/shotkit.js | 34 +- package.json | 4 + scripts/verify-pack.mjs | 2 + src/capture.js | 578 +++++++++++++++++---------------- src/cli-runner.js | 60 ++++ src/demo-storyboard.js | 135 ++++++++ src/demo-time.js | 54 +++ src/demo.js | 186 +---------- src/handoff.js | 2 +- src/launch.js | 10 +- src/video.js | 40 ++- test/capture-lifecycle.test.js | 82 +++++ test/cli-runner.test.js | 70 ++++ test/cli.test.js | 6 +- test/launch.test.js | 37 +++ test/package-boundary.test.js | 64 ++++ test/video.test.js | 38 ++- 17 files changed, 892 insertions(+), 510 deletions(-) create mode 100644 src/cli-runner.js create mode 100644 src/demo-storyboard.js create mode 100644 src/demo-time.js create mode 100644 test/capture-lifecycle.test.js create mode 100644 test/cli-runner.test.js create mode 100644 test/launch.test.js diff --git a/bin/shotkit.js b/bin/shotkit.js index a159f61..8a1a292 100644 --- a/bin/shotkit.js +++ b/bin/shotkit.js @@ -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/package.json b/package.json index bcae8cb..5aaa305 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,12 @@ "src/extension.js", "src/serve.js", "src/cli.js", + "src/cli-runner.js", + "src/index.js", "src/video.js", "src/demo.js", + "src/demo-time.js", + "src/demo-storyboard.js", "src/handoff.js", "src/integrations.js" ], diff --git a/scripts/verify-pack.mjs b/scripts/verify-pack.mjs index 93bda3e..968f7a3 100644 --- a/scripts/verify-pack.mjs +++ b/scripts/verify-pack.mjs @@ -20,6 +20,8 @@ const requiredFiles = [ "skills/capture/SKILL.md", "docs/handoff-conventions.md", "schemas/shotkit-manifest.schema.json", + "schemas/storyboard.schema.json", + "schemas/captions.schema.json", ]; for (const relpath of requiredFiles) { diff --git a/src/capture.js b/src/capture.js index 8dbab2d..19da411 100644 --- a/src/capture.js +++ b/src/capture.js @@ -70,6 +70,24 @@ function normalizePreparedExtension(result) { throw new Error('prepareExtension() must return an extension directory string or { dir, cleanup }'); } +function wantsAny(only, names) { + if (only.size === 0) return names.length > 0; + return names.some((name) => only.has(name)); +} + +function staticOutputNames(config, cwd) { + const names = [ + ...(config.scenes || []).map((scene) => scene.name), + ...(config.promoTiles || []).map((tile) => tile.name), + ]; + if (config.description && config.description.from) { + const sourcePath = path.resolve(cwd, config.description.from); + names.push('description'); + if (hasJsonExtension(sourcePath)) names.push('privacy'); + } + return names.filter(Boolean); +} + /** * @param {object} config the project's shotkit config object (scenes, etc.) * @param {object} [opts] @@ -100,6 +118,7 @@ async function capture(config, opts = {}) { const capturedDemoConfigs = []; const demoViewports = {}; const demoWarnings = {}; + const shouldRunStaticPass = wantsAny(only, staticOutputNames(config, cwd)); const tempDirs = []; let fatalDemoError = null; let cleaned = false; @@ -112,320 +131,309 @@ async function capture(config, opts = {}) { if (extensionCleanup) await extensionCleanup(); }; - // 1. Build — the smoke test starts here. `config.build` is a repo-committed - // command string (same trust boundary as a package.json script), run through - // a shell so projects can write `npm run build:bundle`; never user input. - if (config.build && !opts.noBuild) { - log(`build: ${config.build}`); - // In --json mode stdout must stay a single JSON object, so route the build - // command's stdout to our stderr (fd 2) instead of inheriting it. - execSync(config.build, { stdio: opts.json ? ['ignore', 2, 2] : 'inherit', cwd }); - } - - // 2. Prepare the unpacked extension dir to load. - const preparedExtension = normalizePreparedExtension(await config.prepareExtension(passFlags)); - const extensionDir = preparedExtension.dir; - extensionCleanup = preparedExtension.cleanup; + const registerAsset = (filePath, meta, message) => { + produced.push(filePath); + assets.push(assetRecord({ cwd, outDir, filePath, ...meta })); + if (message) log(message); + }; + const writeAsset = (filePath, data, meta, message) => { + fs.writeFileSync(filePath, data); + registerAsset(filePath, meta, message); + }; - // 3. Screenshots + promo + description run in a no-video context. - const ctx = await launchWithExtension({ extensionDir, viewport: defaultViewport }); - const setup = normalizeSetup( - config.setup ? await config.setup({ context: ctx.context, extensionId: ctx.extensionId, flags: passFlags }) : null, - ); try { - for (const scene of config.scenes || []) { - if (!wants(scene.name)) continue; - const viewport = resolveSize(scene.preset || scene.viewport, defaultViewport); - const captioned = !!(config.disclaimer || scene.caption); - const captureHeight = captioned ? viewport.height - bandHeight : viewport.height; + // 1. Build — the smoke test starts here. `config.build` is a repo-committed + // command string (same trust boundary as a package.json script), run through + // a shell so projects can write `npm run build:bundle`; never user input. + if (config.build && !opts.noBuild) { + log(`build: ${config.build}`); + // In --json mode stdout must stay a single JSON object, so route the build + // command's stdout to our stderr (fd 2) instead of inheriting it. + execSync(config.build, { stdio: opts.json ? ['ignore', 2, 2] : 'inherit', cwd }); + } + + // 2. Prepare the unpacked extension dir to load. + const preparedExtension = normalizePreparedExtension(await config.prepareExtension(passFlags)); + const extensionDir = preparedExtension.dir; + extensionCleanup = preparedExtension.cleanup; - const page = await ctx.context.newPage(); + // 3. Screenshots + promo + description run in a no-video context. + if (shouldRunStaticPass) { + const ctx = await launchWithExtension({ extensionDir, viewport: defaultViewport }); + let setup = normalizeSetup(null); try { - await page.setViewportSize({ width: viewport.width, height: captureHeight }); - await scene.run({ page, context: ctx.context, extensionId: ctx.extensionId, env: setup.env, baseUrl: setup.env.baseUrl, flags: passFlags }); - let buf = await page.screenshot({ clip: { x: 0, y: 0, width: viewport.width, height: captureHeight } }); - if (captioned) { - buf = await compositeCaption({ - context: ctx.context, imageBuffer: buf, - width: viewport.width, height: viewport.height, bandHeight, - caption: scene.caption, disclaimer: config.disclaimer, - }); + setup = normalizeSetup( + config.setup ? await config.setup({ context: ctx.context, extensionId: ctx.extensionId, flags: passFlags }) : null, + ); + for (const scene of config.scenes || []) { + if (!wants(scene.name)) continue; + const viewport = resolveSize(scene.preset || scene.viewport, defaultViewport); + const captioned = !!(config.disclaimer || scene.caption); + const captureHeight = captioned ? viewport.height - bandHeight : viewport.height; + + const page = await ctx.context.newPage(); + try { + await page.setViewportSize({ width: viewport.width, height: captureHeight }); + await scene.run({ page, context: ctx.context, extensionId: ctx.extensionId, env: setup.env, baseUrl: setup.env.baseUrl, flags: passFlags }); + let buf = await page.screenshot({ clip: { x: 0, y: 0, width: viewport.width, height: captureHeight } }); + if (captioned) { + buf = await compositeCaption({ + context: ctx.context, imageBuffer: buf, + width: viewport.width, height: viewport.height, bandHeight, + caption: scene.caption, disclaimer: config.disclaimer, + }); + } + writeAsset( + path.join(outDir, `${scene.name}.png`), + buf, + { + name: scene.name, + type: 'image', + role: 'store-screenshot', + width: viewport.width, + height: viewport.height, + source: { kind: 'scene', name: scene.name }, + }, + `✓ ${scene.name}.png (${viewport.width}×${viewport.height})`, + ); + } finally { + await page.close(); + } } - const out = path.join(outDir, `${scene.name}.png`); - fs.writeFileSync(out, buf); - produced.push(out); - assets.push(assetRecord({ - cwd, - outDir, - filePath: out, - name: scene.name, - type: 'image', - role: 'store-screenshot', - width: viewport.width, - height: viewport.height, - source: { kind: 'scene', name: scene.name }, - })); - log(`✓ ${scene.name}.png (${viewport.width}×${viewport.height})`); - } finally { - await page.close(); - } - } - for (const tile of config.promoTiles || []) { - if (!wants(tile.name)) continue; - const { width, height } = resolveSize(tile.preset || { width: tile.width, height: tile.height }, defaultViewport); - const buf = await renderPromoTile({ context: ctx.context, template: tile.template, width, height, replacements: tile.replacements }); - const out = path.join(outDir, `${tile.name}.png`); - fs.writeFileSync(out, buf); - produced.push(out); - assets.push(assetRecord({ - cwd, - outDir, - filePath: out, - name: tile.name, - type: 'image', - role: 'promo-tile', - width, - height, - source: { kind: 'promoTile', name: tile.name }, - })); - log(`✓ ${tile.name}.png (${width}×${height})`); - } + for (const tile of config.promoTiles || []) { + if (!wants(tile.name)) continue; + const { width, height } = resolveSize(tile.preset || { width: tile.width, height: tile.height }, defaultViewport); + const buf = await renderPromoTile({ context: ctx.context, template: tile.template, width, height, replacements: tile.replacements }); + writeAsset( + path.join(outDir, `${tile.name}.png`), + buf, + { + name: tile.name, + type: 'image', + role: 'promo-tile', + width, + height, + source: { kind: 'promoTile', name: tile.name }, + }, + `✓ ${tile.name}.png (${width}×${height})`, + ); + } - if (config.description && config.description.from) { - const sourcePath = path.resolve(cwd, config.description.from); - if (hasJsonExtension(sourcePath)) { - const product = extractProductManifest(sourcePath, { channel: config.description.channel }); - if (wants('description')) { - const out = path.join(outDir, 'description.md'); - fs.writeFileSync(out, renderDescriptionDoc(product.listing)); - produced.push(out); - assets.push(assetRecord({ - cwd, - outDir, - filePath: out, - name: 'description', - type: 'text', - role: 'store-listing-copy', - source: { kind: 'productManifest', path: config.description.from }, - })); - if (product.listing.warnings.length) log(`⚠️ ${product.listing.warnings.join('; ')}`); - log('✓ description.md'); + if (config.description && config.description.from) { + const sourcePath = path.resolve(cwd, config.description.from); + if (hasJsonExtension(sourcePath)) { + const product = extractProductManifest(sourcePath, { channel: config.description.channel }); + if (wants('description')) { + writeAsset( + path.join(outDir, 'description.md'), + renderDescriptionDoc(product.listing), + { + name: 'description', + type: 'text', + role: 'store-listing-copy', + source: { kind: 'productManifest', path: config.description.from }, + }, + '✓ description.md', + ); + if (product.listing.warnings.length) log(`⚠️ ${product.listing.warnings.join('; ')}`); + } + if (wants('privacy')) { + writeAsset( + path.join(outDir, 'privacy-disclosure.md'), + renderPrivacyDisclosureDoc(product.privacy), + { + name: 'privacy', + type: 'text', + role: 'privacy-disclosure', + source: { kind: 'productManifest', path: config.description.from }, + }, + '✓ privacy-disclosure.md', + ); + if (product.privacy.warnings.length) log(`⚠️ ${product.privacy.warnings.join('; ')}`); + } + } else if (wants('description')) { + const listing = extractListing(sourcePath); + writeAsset( + path.join(outDir, 'description.md'), + renderDescriptionDoc(listing), + { + name: 'description', + type: 'text', + role: 'store-listing-copy', + source: { kind: 'description' }, + }, + '✓ description.md', + ); + if (listing.warnings.length) log(`⚠️ ${listing.warnings.join('; ')}`); + } } - if (wants('privacy')) { - const out = path.join(outDir, 'privacy-disclosure.md'); - fs.writeFileSync(out, renderPrivacyDisclosureDoc(product.privacy)); - produced.push(out); - assets.push(assetRecord({ - cwd, - outDir, - filePath: out, - name: 'privacy', - type: 'text', - role: 'privacy-disclosure', - source: { kind: 'productManifest', path: config.description.from }, - })); - if (product.privacy.warnings.length) log(`⚠️ ${product.privacy.warnings.join('; ')}`); - log('✓ privacy-disclosure.md'); + } finally { + // Close the context (drops the browser's sockets) BEFORE the fixture server: + // server.close() waits for open connections to drain, and a still-open page + // keeps a keep-alive socket that would otherwise deadlock the close. + try { + await closeContext(ctx); + } finally { + await setup.teardown(); } - } else if (wants('description')) { - const listing = extractListing(sourcePath); - const out = path.join(outDir, 'description.md'); - fs.writeFileSync(out, renderDescriptionDoc(listing)); - produced.push(out); - assets.push(assetRecord({ - cwd, - outDir, - filePath: out, - name: 'description', - type: 'text', - role: 'store-listing-copy', - source: { kind: 'description' }, - })); - if (listing.warnings.length) log(`⚠️ ${listing.warnings.join('; ')}`); - log('✓ description.md'); } } - } finally { - // Close the context (drops the browser's sockets) BEFORE the fixture server: - // server.close() waits for open connections to drain, and a still-open page - // keeps a keep-alive socket that would otherwise deadlock the close. - await closeContext(ctx); - await setup.teardown(); - } - - // 4. Demo screencasts — separate context per demo so only that walkthrough - // records video and each clip can choose its own viewport/captions/trim. - for (const demoConfig of demoConfigs) { - if (opts.noVideo || !wants(demoConfig.name)) continue; - const viewport = resolveSize(demoConfig.preset || demoConfig.viewport, defaultViewport); - capturedDemoConfigs.push(demoConfig); - demoViewports[demoConfig.name] = viewport; - const warnings = analyzeDemoStoryboard(demoConfig, { - viewport, - mp4Requested: !!(demoConfig.mp4 || opts.mp4 || demoConfig.crop || demoConfig.zoom), - }); - demoWarnings[demoConfig.name] = warnings; - for (const warning of warnings) { - log(`⚠️ ${demoConfig.name}: ${warning.message}${warning.fix ? `; ${warning.fix}` : ''}`); - } - const videoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-video-')); - tempDirs.push(videoDir); - const demoCtx = await launchWithExtension({ extensionDir, viewport, recordVideoDir: videoDir, recordVideoSize: viewport }); - await installDemoCaptionOverlay(demoCtx.context, demoConfig.captionOptions || {}); - // Keep a small "unofficial" badge on screen across navigations. - if (config.disclaimer) { - await demoCtx.context.addInitScript((text) => { - const add = () => { - if (document.getElementById('__shotkit_badge__') || !document.body) return; - const b = document.createElement('div'); - b.id = '__shotkit_badge__'; - b.textContent = text; - b.style.cssText = 'position:fixed;top:10px;left:10px;z-index:2147483647;background:rgba(20,21,26,.86);color:#fff;font:600 11px -apple-system,Segoe UI,Roboto,sans-serif;padding:5px 9px;border-radius:6px;pointer-events:none'; - document.body.appendChild(b); - }; - if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add, { once: true }); - else add(); - }, config.disclaimer); - } - - const setup2 = normalizeSetup( - config.setup ? await config.setup({ context: demoCtx.context, extensionId: demoCtx.extensionId, flags: passFlags }) : null, - ); - const page = await demoCtx.context.newPage(); - try { - await page.setViewportSize(viewport); - const demo = createDemoController({ - page, - captions: demoConfig.captions, - captionOptions: demoConfig.captionOptions, + // 4. Demo screencasts — separate context per demo so only that walkthrough + // records video and each clip can choose its own viewport/captions/trim. + for (const demoConfig of demoConfigs) { + if (opts.noVideo || !wants(demoConfig.name)) continue; + const viewport = resolveSize(demoConfig.preset || demoConfig.viewport, defaultViewport); + capturedDemoConfigs.push(demoConfig); + demoViewports[demoConfig.name] = viewport; + const warnings = analyzeDemoStoryboard(demoConfig, { + viewport, + mp4Requested: !!(demoConfig.mp4 || opts.mp4 || demoConfig.crop || demoConfig.zoom), }); + demoWarnings[demoConfig.name] = warnings; + for (const warning of warnings) { + log(`⚠️ ${demoConfig.name}: ${warning.message}${warning.fix ? `; ${warning.fix}` : ''}`); + } + const videoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-video-')); + tempDirs.push(videoDir); + const demoCtx = await launchWithExtension({ extensionDir, viewport, recordVideoDir: videoDir, recordVideoSize: viewport }); + let setup2 = normalizeSetup(null); + let page = null; try { - await demoConfig.run({ + await installDemoCaptionOverlay(demoCtx.context, demoConfig.captionOptions || {}); + + // Keep a small "unofficial" badge on screen across navigations. + if (config.disclaimer) { + await demoCtx.context.addInitScript((text) => { + const add = () => { + if (document.getElementById('__shotkit_badge__') || !document.body) return; + const b = document.createElement('div'); + b.id = '__shotkit_badge__'; + b.textContent = text; + b.style.cssText = 'position:fixed;top:10px;left:10px;z-index:2147483647;background:rgba(20,21,26,.86);color:#fff;font:600 11px -apple-system,Segoe UI,Roboto,sans-serif;padding:5px 9px;border-radius:6px;pointer-events:none'; + document.body.appendChild(b); + }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add, { once: true }); + else add(); + }, config.disclaimer); + } + + setup2 = normalizeSetup( + config.setup ? await config.setup({ context: demoCtx.context, extensionId: demoCtx.extensionId, flags: passFlags }) : null, + ); + page = await demoCtx.context.newPage(); + await page.setViewportSize(viewport); + const demo = createDemoController({ page, - context: demoCtx.context, - extensionId: demoCtx.extensionId, - env: setup2.env, - baseUrl: setup2.env.baseUrl, - flags: passFlags, - demo, + captions: demoConfig.captions, + captionOptions: demoConfig.captionOptions, }); - } finally { - demo.stop(); - } - // Ordering: grab the video handle, page.close() (finalizes recording + - // drops the page socket), video.saveAs() while the browser is still up, - // THEN closeContext, THEN server teardown (no page holds a socket → no - // deadlock). See the screenshots finally above. - const video = page.video(); - await page.close(); - if (video) { - const out = path.join(outDir, `${demoConfig.name}.webm`); - await video.saveAs(out); - produced.push(out); - assets.push(assetRecord({ - cwd, - outDir, - filePath: out, - name: demoConfig.name, - type: 'video', - role: 'source-demo-webm', - width: viewport.width, - height: viewport.height, - source: { kind: 'demo', name: demoConfig.name }, - })); - log(`✓ ${demoConfig.name}.webm (${viewport.width}×${viewport.height})`); - // SNS post-processing: mp4 (H.264) and/or trim — needs a real ffmpeg, - // fails loudly if one was requested but none is installed. - let extra; try { - extra = postProcessDemo({ - webmPath: out, - mp4: demoConfig.mp4 || opts.mp4, - trim: demoConfig.trim, - crop: demoConfig.crop, - zoom: demoConfig.zoom, - thumbnail: demoConfig.thumbnail, - log, + await demoConfig.run({ + page, + context: demoCtx.context, + extensionId: demoCtx.extensionId, + env: setup2.env, + baseUrl: setup2.env.baseUrl, + flags: passFlags, + demo, }); - } catch (err) { - const msg = err && err.message ? err.message : String(err); - fatalDemoError = new Error(`demo "${demoConfig.name}" post-processing failed: ${msg}`, { cause: err }); - log(`❌ ${fatalDemoError.message}`); - break; + } finally { + demo.stop(); } - produced.push(...extra); - for (const extraPath of extra) { - const format = path.extname(extraPath).toLowerCase(); - assets.push(assetRecord({ - cwd, - outDir, - filePath: extraPath, - name: path.basename(extraPath, path.extname(extraPath)), - type: format === '.png' ? 'image' : 'video', - role: format === '.png' ? 'thumbnail' : 'sns-demo-mp4', - // No width/height: crop/zoom change the output dimensions from the - // source viewport and we don't re-measure, so recording the viewport - // size here would be wrong. The manifest schema makes them optional. + // Ordering: grab the video handle, page.close() (finalizes recording + + // drops the page socket), video.saveAs() while the browser is still up, + // THEN closeContext, THEN server teardown (no page holds a socket → no + // deadlock). See the screenshots finally above. + const video = page.video(); + await page.close(); + if (video) { + const out = path.join(outDir, `${demoConfig.name}.webm`); + await video.saveAs(out); + registerAsset(out, { + name: demoConfig.name, + type: 'video', + role: 'source-demo-webm', + width: viewport.width, + height: viewport.height, source: { kind: 'demo', name: demoConfig.name }, - })); + }, `✓ ${demoConfig.name}.webm (${viewport.width}×${viewport.height})`); + // SNS post-processing: mp4 (H.264) and/or trim — needs a real ffmpeg, + // fails loudly if one was requested but none is installed. + let extra; + try { + extra = postProcessDemo({ + webmPath: out, + mp4: demoConfig.mp4 || opts.mp4, + trim: demoConfig.trim, + crop: demoConfig.crop, + zoom: demoConfig.zoom, + thumbnail: demoConfig.thumbnail, + log, + }); + } catch (err) { + const msg = err && err.message ? err.message : String(err); + fatalDemoError = new Error(`demo "${demoConfig.name}" post-processing failed: ${msg}`, { cause: err }); + log(`❌ ${fatalDemoError.message}`); + break; + } + for (const extraPath of extra) { + const format = path.extname(extraPath).toLowerCase(); + registerAsset(extraPath, { + name: path.basename(extraPath, path.extname(extraPath)), + type: format === '.png' ? 'image' : 'video', + role: format === '.png' ? 'thumbnail' : 'sns-demo-mp4', + // No width/height: crop/zoom change the output dimensions from the + // source viewport and we don't re-measure, so recording the viewport + // size here would be wrong. The manifest schema makes them optional. + source: { kind: 'demo', name: demoConfig.name }, + }); + } + } + } catch (err) { + // One demo run/recording failure must not abort the remaining demos, the + // handoff pack, or temp-dir cleanup below. Requested post-processing + // failures are treated as fatal above because they mean an expected + // deliverable is missing. + log(`❌ demo "${demoConfig.name}" failed: ${err.message} — continuing with the remaining demos`); + } finally { + if (page && !page.isClosed()) await page.close().catch(() => {}); + try { + await closeContext(demoCtx); + } finally { + await setup2.teardown(); } } - } catch (err) { - // One demo run/recording failure must not abort the remaining demos, the - // handoff pack, or temp-dir cleanup below. Requested post-processing - // failures are treated as fatal above because they mean an expected - // deliverable is missing. - log(`❌ demo "${demoConfig.name}" failed: ${err.message} — continuing with the remaining demos`); - } finally { - if (!page.isClosed()) await page.close().catch(() => {}); - await closeContext(demoCtx); - await setup2.teardown(); } - } - if (fatalDemoError) { - await cleanupTempResources(); - throw fatalDemoError; - } + if (fatalDemoError) throw fatalDemoError; - // 5. Handoff contract — metadata for external editors, MCP adapters, and - // agents. This is the starter-pack layer, not a competing editor surface. - if (config.handoff !== false) { - const handoffPaths = writeHandoffDocs({ - cwd, - outDir, - config, - assets, - demoConfigs: capturedDemoConfigs, - demoViewports, - demoWarnings, - flags: passFlags, - // Scene-filtered or --no-video runs only re-capture a subset; merge into - // the existing handoff contract rather than clobbering a prior full run. - partial: only.size > 0 || !!opts.noVideo, - }); - produced.push(...handoffPaths); - for (const out of handoffPaths) { - assets.push(assetRecord({ + // 5. Handoff contract — metadata for external editors, MCP adapters, and + // agents. This is the starter-pack layer, not a competing editor surface. + if (config.handoff !== false) { + const handoffPaths = writeHandoffDocs({ cwd, outDir, - filePath: out, - name: path.basename(out, '.json'), - type: 'json', - role: 'handoff-contract', - source: { kind: 'handoff' }, - })); - log(`✓ ${path.basename(out)}`); + config, + assets, + demoConfigs: capturedDemoConfigs, + demoViewports, + demoWarnings, + flags: passFlags, + // Scene-filtered or --no-video runs only re-capture a subset; merge into + // the existing handoff contract rather than clobbering a prior full run. + partial: only.size > 0 || !!opts.noVideo, + }); + produced.push(...handoffPaths); + for (const out of handoffPaths) log(`✓ ${path.basename(out)}`); } - } - - // 6. Cleanup temp dirs. - await cleanupTempResources(); - log(`done — ${produced.length} asset(s) in ${path.relative(cwd, outDir) || '.'}/`); - return { produced, outDir }; + log(`done — ${produced.length} asset(s) in ${path.relative(cwd, outDir) || '.'}/`); + return { produced, outDir }; + } finally { + await cleanupTempResources(); + } } module.exports = { capture, DEFAULT_VIEWPORT }; diff --git a/src/cli-runner.js b/src/cli-runner.js new file mode 100644 index 0000000..7ac5b37 --- /dev/null +++ b/src/cli-runner.js @@ -0,0 +1,60 @@ +const fs = require('fs'); +const path = require('path'); + +const { parseArgs, resolveConfigPath, USAGE } = require('./cli'); +const { capture: defaultCapture } = require('./capture'); + +function writeJson(stream, payload) { + stream.write(`${JSON.stringify(payload)}\n`); +} + +function errorPayload(error, code) { + return { ok: false, error, code }; +} + +async function runCli(argv, io = {}, deps = {}) { + const stdout = io.stdout || process.stdout; + const stderr = io.stderr || process.stderr; + const processCwd = io.processCwd || (() => process.cwd()); + const capture = deps.capture || defaultCapture; + const loadConfig = deps.loadConfig || ((configPath) => require(configPath)); + + const opts = parseArgs(argv); + if (opts.help) { + stdout.write(USAGE); + return 0; + } + if (opts.errors.length) { + const msg = opts.errors.join('; '); + if (opts.json) writeJson(stdout, errorPayload(msg, 2)); + else stderr.write(`[shotkit] ${msg}\n\n${USAGE}`); + return 2; + } + + const cwd = path.resolve(processCwd(), 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) writeJson(stdout, errorPayload(msg, 2)); + else stderr.write(`[shotkit] ${msg}\n`); + return 2; + } + + try { + const config = loadConfig(configPath); + const log = opts.json ? (m) => stderr.write(`[shotkit] ${m}\n`) : undefined; + const { produced, outDir } = await capture(config, { ...opts, cwd, log }); + if (opts.json) writeJson(stdout, { ok: true, outDir, produced }); + return 0; + } catch (err) { + const msg = err && err.message ? err.message : String(err); + if (opts.json) { + writeJson(stdout, errorPayload(msg, 1)); + } else { + stderr.write(`[shotkit] FAILED: ${err && err.stack ? err.stack : err}\n`); + } + return 1; + } +} + +module.exports = { runCli }; diff --git a/src/demo-storyboard.js b/src/demo-storyboard.js new file mode 100644 index 0000000..def61e9 --- /dev/null +++ b/src/demo-storyboard.js @@ -0,0 +1,135 @@ +const { normalizeDemoCaptions, parseTimeToMs } = require('./demo-time'); + +function storyboardWarning(code, message, fix, details) { + return { + code, + severity: 'warning', + message, + fix, + ...(details ? { details } : {}), + }; +} + +function formatStoryboardLint(item) { + return item.fix ? `${item.message}; ${item.fix}` : item.message; +} + +function analyzeDemoStoryboard(demoConfig, { viewport, mp4Requested } = {}) { + if (demoConfig.storyboardLint === false) return []; + const warnings = []; + // Lint must never throw — a malformed caption time should surface AS a lint + // warning, not crash the whole capture run (this runs before any try/catch). + let captions = []; + try { + captions = Array.isArray(demoConfig.captions) ? normalizeDemoCaptions(demoConfig.captions) : []; + } catch (e) { + warnings.push(storyboardWarning('invalid-captions', e.message, 'fix the caption time/text so the storyboard can be linted')); + } + if (!captions.length) { + warnings.push(storyboardWarning( + 'no-captions', + 'storyboard has no captions', + 'add short captions for SNS context', + )); + } + if (captions.length === 1) { + warnings.push(storyboardWarning( + 'single-caption', + 'storyboard has only one caption', + 'aim for before -> action -> result', + )); + } + if (captions[0] && captions[0].atMs > 3000) { + warnings.push(storyboardWarning( + 'late-first-caption', + 'first caption starts after 3s', + 'show the result sooner', + { atMs: captions[0].atMs }, + )); + } + for (const caption of captions) { + if (caption.text.length > 70) { + warnings.push(storyboardWarning( + 'long-caption', + `caption is ${caption.text.length} chars`, + 'keep captions under 70 chars when possible', + { text: caption.text, length: caption.text.length }, + )); + } + } + + const text = captions.map((caption) => caption.text).join(' ').toLowerCase(); + if (captions.length && !/(restore|original|safe|undo|revert|reset|복구|원문|되돌)/i.test(text)) { + warnings.push(storyboardWarning( + 'missing-safety-restore', + 'storyboard has no visible safety/restore beat', + 'show restore, undo, original text, or another safety path', + )); + } + + // Honor an explicit mp4Requested (only the caller knows about the CLI --mp4 + // flag) but also infer it from the demo config, so public callers like + // lintDemoStoryboard() don't emit a spurious warning when demo.mp4 is set. + const wantsMp4 = mp4Requested || !!(demoConfig.mp4 || demoConfig.crop || demoConfig.zoom); + if (!wantsMp4) { + warnings.push(storyboardWarning( + 'missing-mp4', + 'X/SNS demo clips should emit mp4', + 'set demo.mp4 or run shotkit --mp4', + )); + } + if ((demoConfig.crop || demoConfig.zoom) && captions.length) { + warnings.push(storyboardWarning( + 'edge-framing', + 'crop/zoom can cut edge captions or badges', + 'verify a frame after capture', + )); + } + if (viewport && (viewport.width % 2 || viewport.height % 2)) { + warnings.push(storyboardWarning( + 'odd-viewport', + `viewport ${viewport.width}x${viewport.height} is not even`, + 'use even dimensions for H.264', + { viewport }, + )); + } + + if (demoConfig.trim && demoConfig.trim.duration != null) { + let durationMs = null; + try { + durationMs = parseTimeToMs(demoConfig.trim.duration, 'trim.duration'); + } catch (e) { + warnings.push(storyboardWarning('invalid-duration', e.message, 'use a number of seconds or an "mm:ss" string')); + } + if (durationMs != null && durationMs < 20000) { + warnings.push(storyboardWarning( + 'short-duration', + 'trim.duration is under 20s', + 'make sure the story has enough context', + { durationMs }, + )); + } + if (durationMs != null && durationMs > 40000) { + warnings.push(storyboardWarning( + 'long-duration', + 'trim.duration is over 40s', + 'X clips usually perform better shorter', + { durationMs }, + )); + } + } else { + warnings.push(storyboardWarning( + 'missing-duration', + 'no trim.duration set', + 'target 20-40s for SNS clips', + )); + } + + return warnings; +} + +function lintDemoStoryboard(demoConfig, options = {}) { + return analyzeDemoStoryboard(demoConfig, options).map(formatStoryboardLint); +} + +module.exports = { analyzeDemoStoryboard, formatStoryboardLint, lintDemoStoryboard }; diff --git a/src/demo-time.js b/src/demo-time.js new file mode 100644 index 0000000..e9564e5 --- /dev/null +++ b/src/demo-time.js @@ -0,0 +1,54 @@ +function normalizeDelayMs(value, label) { + if (!Number.isFinite(value) || value < 0) { + throw new Error(`shotkit: demo ${label} must be a non-negative number of milliseconds`); + } + return Math.round(value); +} + +function parseTimeToMs(value, label = 'time') { + if (typeof value === 'number' && Number.isFinite(value)) { + if (value < 0) throw new Error(`shotkit: demo caption ${label} must be >= 0`); + return Math.round(value * 1000); + } + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`shotkit: demo caption ${label} must be a number of seconds or a time string`); + } + + const raw = value.trim(); + if (/^\d+(\.\d+)?$/.test(raw)) return parseTimeToMs(Number(raw), label); + + const parts = raw.split(':'); + if (parts.length < 2 || parts.length > 3) { + throw new Error(`shotkit: demo caption ${label} has invalid time string "${value}"`); + } + const nums = parts.map((part) => Number(part)); + if (nums.some((n) => !Number.isFinite(n) || n < 0)) { + throw new Error(`shotkit: demo caption ${label} has invalid time string "${value}"`); + } + const [hours, minutes, seconds] = parts.length === 3 ? nums : [0, nums[0], nums[1]]; + if (minutes >= 60 || seconds >= 60) { + throw new Error(`shotkit: demo caption ${label} has invalid time string "${value}"`); + } + return Math.round(((hours * 3600) + (minutes * 60) + seconds) * 1000); +} + +function normalizeDemoCaptions(captions = []) { + if (!captions) return []; + if (!Array.isArray(captions)) throw new Error('shotkit: demo.captions must be an array'); + return captions + .map((caption, index) => { + if (!caption || caption.at == null) { + throw new Error(`shotkit: demo.captions[${index}] needs an at time`); + } + if (caption.text == null) { + throw new Error(`shotkit: demo.captions[${index}] needs text`); + } + return { + atMs: parseTimeToMs(caption.at, `at for captions[${index}]`), + text: String(caption.text), + }; + }) + .sort((a, b) => a.atMs - b.atMs); +} + +module.exports = { normalizeDelayMs, normalizeDemoCaptions, parseTimeToMs }; diff --git a/src/demo.js b/src/demo.js index ff78eaf..abf0130 100644 --- a/src/demo.js +++ b/src/demo.js @@ -11,58 +11,8 @@ const DEFAULT_CLICK_MOVE_MS = 360; const DEFAULT_CLICK_BEFORE_MS = 120; const DEFAULT_STEP_HOLD_MS = 800; -function normalizeDelayMs(value, label) { - if (!Number.isFinite(value) || value < 0) { - throw new Error(`shotkit: demo ${label} must be a non-negative number of milliseconds`); - } - return Math.round(value); -} - -function parseTimeToMs(value, label = 'time') { - if (typeof value === 'number' && Number.isFinite(value)) { - if (value < 0) throw new Error(`shotkit: demo caption ${label} must be >= 0`); - return Math.round(value * 1000); - } - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`shotkit: demo caption ${label} must be a number of seconds or a time string`); - } - - const raw = value.trim(); - if (/^\d+(\.\d+)?$/.test(raw)) return parseTimeToMs(Number(raw), label); - - const parts = raw.split(':'); - if (parts.length < 2 || parts.length > 3) { - throw new Error(`shotkit: demo caption ${label} has invalid time string "${value}"`); - } - const nums = parts.map((part) => Number(part)); - if (nums.some((n) => !Number.isFinite(n) || n < 0)) { - throw new Error(`shotkit: demo caption ${label} has invalid time string "${value}"`); - } - const [hours, minutes, seconds] = parts.length === 3 ? nums : [0, nums[0], nums[1]]; - if (minutes >= 60 || seconds >= 60) { - throw new Error(`shotkit: demo caption ${label} has invalid time string "${value}"`); - } - return Math.round(((hours * 3600) + (minutes * 60) + seconds) * 1000); -} - -function normalizeDemoCaptions(captions = []) { - if (!captions) return []; - if (!Array.isArray(captions)) throw new Error('shotkit: demo.captions must be an array'); - return captions - .map((caption, index) => { - if (!caption || caption.at == null) { - throw new Error(`shotkit: demo.captions[${index}] needs an at time`); - } - if (caption.text == null) { - throw new Error(`shotkit: demo.captions[${index}] needs text`); - } - return { - atMs: parseTimeToMs(caption.at, `at for captions[${index}]`), - text: String(caption.text), - }; - }) - .sort((a, b) => a.atMs - b.atMs); -} +const { normalizeDelayMs, normalizeDemoCaptions, parseTimeToMs } = require('./demo-time'); +const { analyzeDemoStoryboard, formatStoryboardLint, lintDemoStoryboard } = require('./demo-storyboard'); function normalizeDemoConfigs(config = {}) { const demos = []; @@ -88,138 +38,6 @@ function normalizeDemoConfigs(config = {}) { }); } -function storyboardWarning(code, message, fix, details) { - return { - code, - severity: 'warning', - message, - fix, - ...(details ? { details } : {}), - }; -} - -function formatStoryboardLint(item) { - return item.fix ? `${item.message}; ${item.fix}` : item.message; -} - -function analyzeDemoStoryboard(demoConfig, { viewport, mp4Requested } = {}) { - if (demoConfig.storyboardLint === false) return []; - const warnings = []; - // Lint must never throw — a malformed caption time should surface AS a lint - // warning, not crash the whole capture run (this runs before any try/catch). - let captions = []; - try { - captions = Array.isArray(demoConfig.captions) ? normalizeDemoCaptions(demoConfig.captions) : []; - } catch (e) { - warnings.push(storyboardWarning('invalid-captions', e.message, 'fix the caption time/text so the storyboard can be linted')); - } - if (!captions.length) { - warnings.push(storyboardWarning( - 'no-captions', - 'storyboard has no captions', - 'add short captions for SNS context', - )); - } - if (captions.length === 1) { - warnings.push(storyboardWarning( - 'single-caption', - 'storyboard has only one caption', - 'aim for before -> action -> result', - )); - } - if (captions[0] && captions[0].atMs > 3000) { - warnings.push(storyboardWarning( - 'late-first-caption', - 'first caption starts after 3s', - 'show the result sooner', - { atMs: captions[0].atMs }, - )); - } - for (const caption of captions) { - if (caption.text.length > 70) { - warnings.push(storyboardWarning( - 'long-caption', - `caption is ${caption.text.length} chars`, - 'keep captions under 70 chars when possible', - { text: caption.text, length: caption.text.length }, - )); - } - } - - const text = captions.map((caption) => caption.text).join(' ').toLowerCase(); - if (captions.length && !/(restore|original|safe|undo|revert|reset|복구|원문|되돌)/i.test(text)) { - warnings.push(storyboardWarning( - 'missing-safety-restore', - 'storyboard has no visible safety/restore beat', - 'show restore, undo, original text, or another safety path', - )); - } - - // Honor an explicit mp4Requested (only the caller knows about the CLI --mp4 - // flag) but also infer it from the demo config, so public callers like - // lintDemoStoryboard() don't emit a spurious warning when demo.mp4 is set. - const wantsMp4 = mp4Requested || !!(demoConfig.mp4 || demoConfig.crop || demoConfig.zoom); - if (!wantsMp4) { - warnings.push(storyboardWarning( - 'missing-mp4', - 'X/SNS demo clips should emit mp4', - 'set demo.mp4 or run shotkit --mp4', - )); - } - if ((demoConfig.crop || demoConfig.zoom) && captions.length) { - warnings.push(storyboardWarning( - 'edge-framing', - 'crop/zoom can cut edge captions or badges', - 'verify a frame after capture', - )); - } - if (viewport && (viewport.width % 2 || viewport.height % 2)) { - warnings.push(storyboardWarning( - 'odd-viewport', - `viewport ${viewport.width}x${viewport.height} is not even`, - 'use even dimensions for H.264', - { viewport }, - )); - } - - if (demoConfig.trim && demoConfig.trim.duration != null) { - let durationMs = null; - try { - durationMs = parseTimeToMs(demoConfig.trim.duration, 'trim.duration'); - } catch (e) { - warnings.push(storyboardWarning('invalid-duration', e.message, 'use a number of seconds or an "mm:ss" string')); - } - if (durationMs != null && durationMs < 20000) { - warnings.push(storyboardWarning( - 'short-duration', - 'trim.duration is under 20s', - 'make sure the story has enough context', - { durationMs }, - )); - } - if (durationMs != null && durationMs > 40000) { - warnings.push(storyboardWarning( - 'long-duration', - 'trim.duration is over 40s', - 'X clips usually perform better shorter', - { durationMs }, - )); - } - } else { - warnings.push(storyboardWarning( - 'missing-duration', - 'no trim.duration set', - 'target 20-40s for SNS clips', - )); - } - - return warnings; -} - -function lintDemoStoryboard(demoConfig, options = {}) { - return analyzeDemoStoryboard(demoConfig, options).map(formatStoryboardLint); -} - function demoCaptionInitScript(options = {}) { const rootId = '__shotkit_demo_caption__'; const pointerId = '__shotkit_demo_pointer__'; diff --git a/src/handoff.js b/src/handoff.js index c5e19de..ba6dcfd 100644 --- a/src/handoff.js +++ b/src/handoff.js @@ -8,7 +8,7 @@ const fs = require('fs'); const path = require('path'); -const { normalizeDemoCaptions, parseTimeToMs } = require('./demo'); +const { normalizeDemoCaptions, parseTimeToMs } = require('./demo-time'); const { buildHandoffRecommendations } = require('./integrations'); const HANDOFF_VERSION = 1; diff --git a/src/launch.js b/src/launch.js index a6eceee..4028d54 100644 --- a/src/launch.js +++ b/src/launch.js @@ -56,7 +56,15 @@ async function launchWithExtension({ extensionDir, viewport, recordVideoDir, rec launchOpts.recordVideo = { dir: recordVideoDir, size: recordVideoSize || launchOpts.viewport }; } - const context = await chromium.launchPersistentContext(userDataDir, launchOpts); + let context; + try { + context = await chromium.launchPersistentContext(userDataDir, launchOpts); + } catch (err) { + if (userDataDir && fs.existsSync(userDataDir)) { + fs.rmSync(userDataDir, { recursive: true, force: true }); + } + throw err; + } // Wait for the service worker so we can read the extension ID off its URL. // A content-script-only extension (no MV3 worker) or a slow worker makes this diff --git a/src/video.js b/src/video.js index 7741b63..1c96f98 100644 --- a/src/video.js +++ b/src/video.js @@ -148,6 +148,17 @@ function buildThumbnailArgs({ input, output, at = 1 }) { return ['-hide_banner', '-loglevel', 'error', '-y', '-ss', String(at), '-i', input, '-frames:v', '1', output]; } +function replaceFile(tmpPath, finalPath) { + fs.rmSync(finalPath, { force: true }); + fs.renameSync(tmpPath, finalPath); +} + +function tempSibling(filePath) { + const ext = path.extname(filePath); + const base = filePath.slice(0, -ext.length); + return `${base}.tmp-${process.pid}-${Date.now()}${ext}`; +} + /** * Post-process a recorded demo webm per config: * - mp4 requested → write `.mp4` next to the webm (trim applied there) @@ -176,7 +187,14 @@ function postProcessDemo({ webmPath, mp4, trim, crop, zoom, thumbnail, log, env if (mp4 || crop || zoom) { const crf = typeof mp4 === 'object' && mp4.crf != null ? mp4.crf : undefined; const mp4Path = webmPath.replace(/\.webm$/, '.mp4'); - runFfmpeg(bin, buildFfmpegArgs({ input: webmPath, output: mp4Path, trim, crf, crop, zoom }), timeoutMs); + const tmpMp4Path = tempSibling(mp4Path); + try { + runFfmpeg(bin, buildFfmpegArgs({ input: webmPath, output: tmpMp4Path, trim, crf, crop, zoom }), timeoutMs); + replaceFile(tmpMp4Path, mp4Path); + } catch (err) { + fs.rmSync(tmpMp4Path, { force: true }); + throw err; + } produced.push(mp4Path); finalVideoPath = mp4Path; const notes = ['H.264']; @@ -186,9 +204,14 @@ function postProcessDemo({ webmPath, mp4, trim, crop, zoom, thumbnail, log, env log(`✓ ${path.basename(mp4Path)} (${notes.join(', ')})`); } else if (trim) { // Trim-only: stream-copy to a sibling temp file, then swap in place. - const tmp = `${webmPath}.trim.webm`; - runFfmpeg(bin, buildFfmpegArgs({ input: webmPath, output: tmp, trim, copy: true }), timeoutMs); - fs.renameSync(tmp, webmPath); + const tmp = tempSibling(webmPath); + try { + runFfmpeg(bin, buildFfmpegArgs({ input: webmPath, output: tmp, trim, copy: true }), timeoutMs); + replaceFile(tmp, webmPath); + } catch (err) { + fs.rmSync(tmp, { force: true }); + throw err; + } log(`✓ ${path.basename(webmPath)} trimmed in place`); } // else: thumbnail-only (no mp4/crop/zoom/trim) — nothing to re-encode; the @@ -198,7 +221,14 @@ function postProcessDemo({ webmPath, mp4, trim, crop, zoom, thumbnail, log, env const thumbPath = typeof thumbnail === 'object' && thumbnail.name ? path.join(path.dirname(webmPath), thumbnail.name) : webmPath.replace(/\.webm$/, '-thumbnail.png'); - runFfmpeg(bin, buildThumbnailArgs({ input: finalVideoPath, output: thumbPath, at }), timeoutMs); + const tmpThumbPath = tempSibling(thumbPath); + try { + runFfmpeg(bin, buildThumbnailArgs({ input: finalVideoPath, output: tmpThumbPath, at }), timeoutMs); + if (fs.existsSync(tmpThumbPath)) replaceFile(tmpThumbPath, thumbPath); + } catch (err) { + fs.rmSync(tmpThumbPath, { force: true }); + throw err; + } // ffmpeg exits 0 even when `at` seeks past the end of a (trimmed) clip, // writing no file. Only record the thumbnail when it was actually produced, // so the manifest never references a phantom asset. diff --git a/test/capture-lifecycle.test.js b/test/capture-lifecycle.test.js new file mode 100644 index 0000000..0bc3c3f --- /dev/null +++ b/test/capture-lifecycle.test.js @@ -0,0 +1,82 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +jest.mock('../src/launch', () => ({ + launchWithExtension: jest.fn(async () => ({ + extensionId: 'test-extension', + context: { + addInitScript: jest.fn(async () => {}), + newPage: jest.fn(async () => { + throw new Error('newPage should not be called'); + }), + }, + })), + closeContext: jest.fn(async () => {}), +})); + +const { launchWithExtension, closeContext } = require('../src/launch'); +const { capture } = require('../src/capture'); + +function tempCwd() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'sk-capture-lifecycle-')); +} + +function preparedExtension(cleanup) { + return { dir: fs.mkdtempSync(path.join(os.tmpdir(), 'sk-extension-')), cleanup }; +} + +describe('capture lifecycle cleanup', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('closes the static context and prepared extension when setup throws', async () => { + const cleanup = jest.fn(); + const cwd = tempCwd(); + + await expect(capture({ + handoff: false, + prepareExtension: async () => preparedExtension(cleanup), + setup: async () => { + throw new Error('setup failed'); + }, + scenes: [{ name: 'scene', run: async () => {} }], + }, { + cwd, + noBuild: true, + noVideo: true, + log: () => {}, + })).rejects.toThrow(/setup failed/); + + expect(launchWithExtension).toHaveBeenCalledTimes(1); + expect(closeContext).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + test('closes the demo context and prepared extension when demo setup throws', async () => { + const cleanup = jest.fn(); + const cwd = tempCwd(); + const logs = []; + + const result = await capture({ + handoff: false, + prepareExtension: async () => preparedExtension(cleanup), + setup: async () => { + throw new Error('demo setup failed'); + }, + demos: [{ name: 'demo', run: async () => {} }], + }, { + cwd, + scenes: ['demo'], + noBuild: true, + log: (msg) => logs.push(msg), + }); + + expect(result.produced).toEqual([]); + expect(launchWithExtension).toHaveBeenCalledTimes(1); + expect(closeContext).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(logs.join('\n')).toContain('demo "demo" failed: demo setup failed'); + }); +}); diff --git a/test/cli-runner.test.js b/test/cli-runner.test.js new file mode 100644 index 0000000..ff2968f --- /dev/null +++ b/test/cli-runner.test.js @@ -0,0 +1,70 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { runCli } = require('../src/cli-runner'); + +function streamBuffer() { + let value = ''; + return { + stream: { write: (chunk) => { value += chunk; } }, + read: () => value, + }; +} + +function tmpProject() { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sk-cli-runner-')); + const configPath = path.join(cwd, 'shotkit.config.js'); + fs.writeFileSync(configPath, 'module.exports = {};'); + return { cwd, configPath }; +} + +describe('runCli', () => { + test('json success writes exactly one parseable stdout object and routes progress to stderr', async () => { + const { cwd, configPath } = tmpProject(); + const stdout = streamBuffer(); + const stderr = streamBuffer(); + const capture = jest.fn(async (_config, opts) => { + opts.log('capturing'); + return { outDir: path.join(cwd, 'store-assets'), produced: [path.join(cwd, 'store-assets', 'a.png')] }; + }); + + const code = await runCli(['--json'], { + stdout: stdout.stream, + stderr: stderr.stream, + processCwd: () => cwd, + }, { + capture, + loadConfig: jest.fn(() => ({ loadedFrom: configPath })), + }); + + expect(code).toBe(0); + expect(JSON.parse(stdout.read())).toEqual({ + ok: true, + outDir: path.join(cwd, 'store-assets'), + produced: [path.join(cwd, 'store-assets', 'a.png')], + }); + expect(stderr.read()).toContain('[shotkit] capturing'); + expect(capture).toHaveBeenCalledWith(expect.objectContaining({ loadedFrom: configPath }), expect.objectContaining({ cwd, json: true })); + }); + + test('json runtime failures write the error payload to stdout', async () => { + const { cwd } = tmpProject(); + const stdout = streamBuffer(); + const stderr = streamBuffer(); + + const code = await runCli(['--json'], { + stdout: stdout.stream, + stderr: stderr.stream, + processCwd: () => cwd, + }, { + capture: jest.fn(async () => { + throw new Error('boom'); + }), + loadConfig: jest.fn(() => ({})), + }); + + expect(code).toBe(1); + expect(JSON.parse(stdout.read())).toEqual({ ok: false, error: 'boom', code: 1 }); + expect(stderr.read()).toBe(''); + }); +}); diff --git a/test/cli.test.js b/test/cli.test.js index a7795d8..5d24ae8 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -79,16 +79,16 @@ describe('shotkit CLI usage errors', () => { expect(res.stderr).toContain('Usage: shotkit'); }); - test('--json usage errors keep stdout empty and write JSON to stderr', () => { + test('--json usage errors write one JSON object to stdout', () => { const res = run(['--config', '--json']); expect(res.status).toBe(2); - expect(res.stdout).toBe(''); - expect(JSON.parse(res.stderr)).toEqual({ + expect(JSON.parse(res.stdout)).toEqual({ ok: false, error: '--config requires a config path', code: 2, }); + expect(res.stderr).toBe(''); }); }); diff --git a/test/launch.test.js b/test/launch.test.js new file mode 100644 index 0000000..4daaabb --- /dev/null +++ b/test/launch.test.js @@ -0,0 +1,37 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +jest.mock('playwright', () => ({ + chromium: { + launchPersistentContext: jest.fn(), + }, +})); + +const { chromium } = require('playwright'); +const { launchWithExtension } = require('../src/launch'); + +function tmpExtension() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sk-launch-ext-')); + fs.writeFileSync(path.join(dir, 'manifest.json'), '{}'); + return dir; +} + +describe('launchWithExtension', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('removes the temp profile when Chromium fails before returning a context', async () => { + let userDataDir; + chromium.launchPersistentContext.mockImplementation(async (dir) => { + userDataDir = dir; + throw new Error('missing chromium'); + }); + + await expect(launchWithExtension({ extensionDir: tmpExtension() })).rejects.toThrow(/missing chromium/); + + expect(userDataDir).toContain(`${path.sep}store-profile-`); + expect(fs.existsSync(userDataDir)).toBe(false); + }); +}); diff --git a/test/package-boundary.test.js b/test/package-boundary.test.js index 6cbbc9c..b286afe 100644 --- a/test/package-boundary.test.js +++ b/test/package-boundary.test.js @@ -1,4 +1,5 @@ const pkg = require('../package.json'); +const publicApi = require('../src'); describe('npm package boundary', () => { test('ships the public capture and handoff surface only', () => { @@ -17,4 +18,67 @@ describe('npm package boundary', () => { expect(pkg.files).not.toContain('examples'); expect(pkg.files).not.toContain('docs'); }); + + test('exports the root API and schema subpath contract', () => { + expect(pkg.exports).toEqual({ + '.': './src/index.js', + './schemas/*': './schemas/*', + }); + }); + + test('locks the public root API names', () => { + expect(Object.keys(publicApi).sort()).toEqual([ + 'DEFAULT_BAND_HEIGHT', + 'DEFAULT_TARGETS', + 'DEFAULT_VIEWPORT', + 'FIXTURE_CSP', + 'HANDOFF_KINDS', + 'HANDOFF_SCHEMA_IDS', + 'HANDOFF_VERSION', + 'LOCALHOST_MATCHES', + 'PRESETS', + 'analyzeDemoStoryboard', + 'assetRecord', + 'buildFfmpegArgs', + 'buildHandoffDocs', + 'buildHandoffRecommendations', + 'buildThumbnailArgs', + 'buildVideoFilter', + 'capture', + 'closeContext', + 'compositeCaption', + 'createDemoController', + 'demoCaptionInitScript', + 'demoStoryboard', + 'ensureDemoCaptionOverlay', + 'extractListing', + 'extractPrivacyDisclosure', + 'extractProductListing', + 'extractProductManifest', + 'findFfmpeg', + 'formatStoryboardLint', + 'hideDemoCaption', + 'hideDemoPointer', + 'installDemoCaptionOverlay', + 'launchWithExtension', + 'lintDemoStoryboard', + 'moveDemoPointer', + 'normalizeDelayMs', + 'normalizeDemoCaptions', + 'normalizeDemoConfigs', + 'parseTimeToMs', + 'patchManifestForLocalhost', + 'postProcessDemo', + 'pulseDemoPointer', + 'renderDescriptionDoc', + 'renderPrivacyDisclosureDoc', + 'renderPromoTile', + 'resolveSize', + 'serveDirectory', + 'setDemoCaption', + 'splitSections', + 'stageExtension', + 'writeHandoffDocs', + ]); + }); }); diff --git a/test/video.test.js b/test/video.test.js index 1648c03..cd836b3 100644 --- a/test/video.test.js +++ b/test/video.test.js @@ -1,7 +1,14 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { findFfmpeg, buildFfmpegArgs, buildThumbnailArgs, buildVideoFilter, INSTALL_HINT } = require('../src/video'); +const { + findFfmpeg, + buildFfmpegArgs, + buildThumbnailArgs, + buildVideoFilter, + postProcessDemo, + INSTALL_HINT, +} = require('../src/video'); describe('buildFfmpegArgs', () => { test('mp4 conversion defaults: libx264, yuv420p, faststart, silent, even-dims', () => { @@ -81,3 +88,32 @@ describe('findFfmpeg', () => { expect(INSTALL_HINT).toMatch(/SHOTKIT_FFMPEG/); }); }); + +describe('postProcessDemo cleanup', () => { + test('removes temp mp4 output when ffmpeg fails mid-encode', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sk-postprocess-')); + const fake = path.join(dir, 'fake-ffmpeg'); + const webmPath = path.join(dir, 'demo.webm'); + fs.writeFileSync(webmPath, 'webm'); + fs.writeFileSync(fake, `#!/usr/bin/env node +const fs = require('fs'); +if (process.argv.includes('-version')) { + console.log('ffmpeg version fake'); + process.exit(0); +} +fs.writeFileSync(process.argv[process.argv.length - 1], 'partial'); +process.exit(1); +`); + fs.chmodSync(fake, 0o755); + + expect(() => postProcessDemo({ + webmPath, + mp4: true, + log: () => {}, + env: { SHOTKIT_FFMPEG: fake, PATH: '' }, + })).toThrow(); + + expect(fs.existsSync(path.join(dir, 'demo.mp4'))).toBe(false); + expect(fs.readdirSync(dir).filter((name) => name.includes('.tmp-'))).toEqual([]); + }); +}); From 6ef6d7eaec0c1bcf1da33db6e12309bd7e345862 Mon Sep 17 00:00:00 2001 From: heznpc Date: Thu, 9 Jul 2026 14:32:17 +0900 Subject: [PATCH 03/13] fix: reject missing shotkit outputs --- CHANGELOG.md | 7 +- README.ko.md | 4 +- README.md | 5 +- skills/capture/SKILL.md | 8 +- src/capture.js | 267 ++++++++++++++++++------------- src/cli-runner.js | 8 +- test/capture-description.test.js | 40 ++++- test/capture-lifecycle.test.js | 73 ++++++++- test/cli.test.js | 22 +++ 9 files changed, 306 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c6587..3e978ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. - 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. -## [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 +64,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 +74,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..59a3b06 100644 --- a/README.ko.md +++ b/README.ko.md @@ -201,8 +201,8 @@ result → safety/restore, 짧은 캡션, 느린 cursor/click/typing, X용 mp4 `shotkit [path] --json`은 stdout에 **정확히 하나의 JSON 객체**를 출력합니다 (진행 로그는 stderr로 이동): `{ "ok": true, "outDir": …, "produced": [절대경로…] }`. -종료 코드: `0` 정상 · `1` 런타임 실패(stderr에 `{"ok":false,"error":…}`) · -`2` 사용법 오류/설정 없음. 에이전트 연결은 [`AGENTS.md`](AGENTS.md) 실행 블록 +종료 코드: `0` 정상 · `1` 런타임 실패 · `2` 사용법 오류/설정 없음입니다. +실패 payload도 stdout의 단일 JSON 객체(`{"ok":false,"error":…}`)를 사용합니다. 에이전트 연결은 [`AGENTS.md`](AGENTS.md) 실행 블록 (Claude Code·Codex·Cursor·Gemini CLI 등이 읽음)과 [`skills/capture/`](skills/capture/SKILL.md) skill(Agent Skills 표준 — 호환 도구의 skills 디렉터리에 폴더째 복사)을 참고하십시오. diff --git a/README.md b/README.md index 6e2a2b9..e9f7985 100644 --- a/README.md +++ b/README.md @@ -244,8 +244,9 @@ logs move to stderr): { "ok": true, "outDir": "/abs/store-assets", "produced": ["/abs/store-assets/01-popup.png"] } ``` -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":…}`). +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). diff --git a/skills/capture/SKILL.md b/skills/capture/SKILL.md index e447125..5afe867 100644 --- a/skills/capture/SKILL.md +++ b/skills/capture/SKILL.md @@ -39,9 +39,11 @@ rendered from the shipped code. By default, it also writes a handoff pack: 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). +4. **On 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 diff --git a/src/capture.js b/src/capture.js index 19da411..3837a25 100644 --- a/src/capture.js +++ b/src/capture.js @@ -75,11 +75,15 @@ function wantsAny(only, names) { return names.some((name) => only.has(name)); } -function staticOutputNames(config, cwd) { - const names = [ +function visualOutputNames(config) { + return [ ...(config.scenes || []).map((scene) => scene.name), ...(config.promoTiles || []).map((tile) => tile.name), - ]; + ].filter(Boolean); +} + +function textOutputNames(config, cwd) { + const names = []; if (config.description && config.description.from) { const sourcePath = path.resolve(cwd, config.description.from); names.push('description'); @@ -88,6 +92,20 @@ function staticOutputNames(config, cwd) { return names.filter(Boolean); } +function outputNames(config, cwd, demoConfigs) { + return [ + ...visualOutputNames(config), + ...textOutputNames(config, cwd), + ...demoConfigs.map((demo) => demo.name), + ]; +} + +function usageError(message) { + const err = new Error(message); + err.exitCode = 2; + return err; +} + /** * @param {object} config the project's shotkit config object (scenes, etc.) * @param {object} [opts] @@ -109,7 +127,6 @@ async function capture(config, opts = {}) { const passFlags = { liveGt: !!opts.liveGt, freeze: !!opts.freeze }; const outDir = path.resolve(cwd, config.outDir || 'store-assets'); - fs.mkdirSync(outDir, { recursive: true }); const defaultViewport = resolveSize(config.viewport, DEFAULT_VIEWPORT); const bandHeight = config.bandHeight || DEFAULT_BAND_HEIGHT; const produced = []; @@ -118,17 +135,41 @@ async function capture(config, opts = {}) { const capturedDemoConfigs = []; const demoViewports = {}; const demoWarnings = {}; - const shouldRunStaticPass = wantsAny(only, staticOutputNames(config, cwd)); + const shouldRunVisualPass = wantsAny(only, visualOutputNames(config)); + const shouldRunTextPass = wantsAny(only, textOutputNames(config, cwd)); + const selectedDemoConfigs = demoConfigs.filter((demoConfig) => !opts.noVideo && wants(demoConfig.name)); + const needsBrowser = shouldRunVisualPass || selectedDemoConfigs.length > 0; + if (only.size > 0) { + const knownNames = new Set(outputNames(config, cwd, demoConfigs)); + const unknownNames = [...only].filter((name) => !knownNames.has(name)); + if (unknownNames.length) { + throw usageError(`unknown scene: ${unknownNames.join(', ')}. Known: ${[...knownNames].join(', ') || '(none)'}`); + } + } + fs.mkdirSync(outDir, { recursive: true }); const tempDirs = []; let fatalDemoError = null; + const demoErrors = []; let cleaned = false; let extensionCleanup = null; const cleanupTempResources = async () => { if (cleaned) return; cleaned = true; - for (const d of tempDirs) fs.rmSync(d, { recursive: true, force: true }); - if (extensionCleanup) await extensionCleanup(); + for (const d of tempDirs) { + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch (err) { + log(`⚠️ cleanup failed for ${d}: ${err.message}`); + } + } + if (extensionCleanup) { + try { + await extensionCleanup(); + } catch (err) { + log(`⚠️ extension cleanup failed: ${err.message}`); + } + } }; const registerAsset = (filePath, meta, message) => { @@ -145,20 +186,74 @@ async function capture(config, opts = {}) { // 1. Build — the smoke test starts here. `config.build` is a repo-committed // command string (same trust boundary as a package.json script), run through // a shell so projects can write `npm run build:bundle`; never user input. - if (config.build && !opts.noBuild) { + if (config.build && !opts.noBuild && needsBrowser) { log(`build: ${config.build}`); // In --json mode stdout must stay a single JSON object, so route the build // command's stdout to our stderr (fd 2) instead of inheriting it. execSync(config.build, { stdio: opts.json ? ['ignore', 2, 2] : 'inherit', cwd }); } - // 2. Prepare the unpacked extension dir to load. - const preparedExtension = normalizePreparedExtension(await config.prepareExtension(passFlags)); - const extensionDir = preparedExtension.dir; - extensionCleanup = preparedExtension.cleanup; + if (shouldRunTextPass && config.description && config.description.from) { + const sourcePath = path.resolve(cwd, config.description.from); + if (hasJsonExtension(sourcePath)) { + const product = extractProductManifest(sourcePath, { channel: config.description.channel }); + if (wants('description')) { + writeAsset( + path.join(outDir, 'description.md'), + renderDescriptionDoc(product.listing), + { + name: 'description', + type: 'text', + role: 'store-listing-copy', + source: { kind: 'productManifest', path: config.description.from }, + }, + '✓ description.md', + ); + if (product.listing.warnings.length) log(`⚠️ ${product.listing.warnings.join('; ')}`); + } + if (wants('privacy')) { + writeAsset( + path.join(outDir, 'privacy-disclosure.md'), + renderPrivacyDisclosureDoc(product.privacy), + { + name: 'privacy', + type: 'text', + role: 'privacy-disclosure', + source: { kind: 'productManifest', path: config.description.from }, + }, + '✓ privacy-disclosure.md', + ); + if (product.privacy.warnings.length) log(`⚠️ ${product.privacy.warnings.join('; ')}`); + } + } else if (wants('description')) { + const listing = extractListing(sourcePath); + writeAsset( + path.join(outDir, 'description.md'), + renderDescriptionDoc(listing), + { + name: 'description', + type: 'text', + role: 'store-listing-copy', + source: { kind: 'description' }, + }, + '✓ description.md', + ); + if (listing.warnings.length) log(`⚠️ ${listing.warnings.join('; ')}`); + } + } - // 3. Screenshots + promo + description run in a no-video context. - if (shouldRunStaticPass) { + // 2. Prepare the unpacked extension dir to load only when a browser capture + // is required. Text-only description/privacy runs should not depend on + // Chromium, build artifacts, or extension manifests. + let extensionDir = null; + if (needsBrowser) { + const preparedExtension = normalizePreparedExtension(await config.prepareExtension(passFlags)); + extensionDir = preparedExtension.dir; + extensionCleanup = preparedExtension.cleanup; + } + + // 3. Screenshots + promo run in a no-video context. + if (shouldRunVisualPass) { const ctx = await launchWithExtension({ extensionDir, viewport: defaultViewport }); let setup = normalizeSetup(null); try { @@ -220,54 +315,6 @@ async function capture(config, opts = {}) { ); } - if (config.description && config.description.from) { - const sourcePath = path.resolve(cwd, config.description.from); - if (hasJsonExtension(sourcePath)) { - const product = extractProductManifest(sourcePath, { channel: config.description.channel }); - if (wants('description')) { - writeAsset( - path.join(outDir, 'description.md'), - renderDescriptionDoc(product.listing), - { - name: 'description', - type: 'text', - role: 'store-listing-copy', - source: { kind: 'productManifest', path: config.description.from }, - }, - '✓ description.md', - ); - if (product.listing.warnings.length) log(`⚠️ ${product.listing.warnings.join('; ')}`); - } - if (wants('privacy')) { - writeAsset( - path.join(outDir, 'privacy-disclosure.md'), - renderPrivacyDisclosureDoc(product.privacy), - { - name: 'privacy', - type: 'text', - role: 'privacy-disclosure', - source: { kind: 'productManifest', path: config.description.from }, - }, - '✓ privacy-disclosure.md', - ); - if (product.privacy.warnings.length) log(`⚠️ ${product.privacy.warnings.join('; ')}`); - } - } else if (wants('description')) { - const listing = extractListing(sourcePath); - writeAsset( - path.join(outDir, 'description.md'), - renderDescriptionDoc(listing), - { - name: 'description', - type: 'text', - role: 'store-listing-copy', - source: { kind: 'description' }, - }, - '✓ description.md', - ); - if (listing.warnings.length) log(`⚠️ ${listing.warnings.join('; ')}`); - } - } } finally { // Close the context (drops the browser's sockets) BEFORE the fixture server: // server.close() waits for open connections to drain, and a still-open page @@ -282,10 +329,8 @@ async function capture(config, opts = {}) { // 4. Demo screencasts — separate context per demo so only that walkthrough // records video and each clip can choose its own viewport/captions/trim. - for (const demoConfig of demoConfigs) { - if (opts.noVideo || !wants(demoConfig.name)) continue; + for (const demoConfig of selectedDemoConfigs) { const viewport = resolveSize(demoConfig.preset || demoConfig.viewport, defaultViewport); - capturedDemoConfigs.push(demoConfig); demoViewports[demoConfig.name] = viewport; const warnings = analyzeDemoStoryboard(demoConfig, { viewport, @@ -348,54 +393,54 @@ async function capture(config, opts = {}) { // deadlock). See the screenshots finally above. const video = page.video(); await page.close(); - if (video) { - const out = path.join(outDir, `${demoConfig.name}.webm`); - await video.saveAs(out); - registerAsset(out, { - name: demoConfig.name, - type: 'video', - role: 'source-demo-webm', - width: viewport.width, - height: viewport.height, + if (!video) throw new Error(`demo "${demoConfig.name}" did not produce a video recording`); + const out = path.join(outDir, `${demoConfig.name}.webm`); + await video.saveAs(out); + capturedDemoConfigs.push(demoConfig); + registerAsset(out, { + name: demoConfig.name, + type: 'video', + role: 'source-demo-webm', + width: viewport.width, + height: viewport.height, + source: { kind: 'demo', name: demoConfig.name }, + }, `✓ ${demoConfig.name}.webm (${viewport.width}×${viewport.height})`); + // SNS post-processing: mp4 (H.264) and/or trim — needs a real ffmpeg, + // fails loudly if one was requested but none is installed. + let extra; + try { + extra = postProcessDemo({ + webmPath: out, + mp4: demoConfig.mp4 || opts.mp4, + trim: demoConfig.trim, + crop: demoConfig.crop, + zoom: demoConfig.zoom, + thumbnail: demoConfig.thumbnail, + log, + }); + } catch (err) { + const msg = err && err.message ? err.message : String(err); + fatalDemoError = new Error(`demo "${demoConfig.name}" post-processing failed: ${msg}`, { cause: err }); + log(`❌ ${fatalDemoError.message}`); + break; + } + for (const extraPath of extra) { + const format = path.extname(extraPath).toLowerCase(); + registerAsset(extraPath, { + name: path.basename(extraPath, path.extname(extraPath)), + type: format === '.png' ? 'image' : 'video', + role: format === '.png' ? 'thumbnail' : 'sns-demo-mp4', + // No width/height: crop/zoom change the output dimensions from the + // source viewport and we don't re-measure, so recording the viewport + // size here would be wrong. The manifest schema makes them optional. source: { kind: 'demo', name: demoConfig.name }, - }, `✓ ${demoConfig.name}.webm (${viewport.width}×${viewport.height})`); - // SNS post-processing: mp4 (H.264) and/or trim — needs a real ffmpeg, - // fails loudly if one was requested but none is installed. - let extra; - try { - extra = postProcessDemo({ - webmPath: out, - mp4: demoConfig.mp4 || opts.mp4, - trim: demoConfig.trim, - crop: demoConfig.crop, - zoom: demoConfig.zoom, - thumbnail: demoConfig.thumbnail, - log, - }); - } catch (err) { - const msg = err && err.message ? err.message : String(err); - fatalDemoError = new Error(`demo "${demoConfig.name}" post-processing failed: ${msg}`, { cause: err }); - log(`❌ ${fatalDemoError.message}`); - break; - } - for (const extraPath of extra) { - const format = path.extname(extraPath).toLowerCase(); - registerAsset(extraPath, { - name: path.basename(extraPath, path.extname(extraPath)), - type: format === '.png' ? 'image' : 'video', - role: format === '.png' ? 'thumbnail' : 'sns-demo-mp4', - // No width/height: crop/zoom change the output dimensions from the - // source viewport and we don't re-measure, so recording the viewport - // size here would be wrong. The manifest schema makes them optional. - source: { kind: 'demo', name: demoConfig.name }, - }); - } + }); } } catch (err) { // One demo run/recording failure must not abort the remaining demos, the - // handoff pack, or temp-dir cleanup below. Requested post-processing - // failures are treated as fatal above because they mean an expected - // deliverable is missing. + // later teardown, but it must still make the run fail. A requested demo + // clip missing from produced[] is a false-positive success signal. + demoErrors.push({ name: demoConfig.name, error: err }); log(`❌ demo "${demoConfig.name}" failed: ${err.message} — continuing with the remaining demos`); } finally { if (page && !page.isClosed()) await page.close().catch(() => {}); @@ -408,6 +453,10 @@ async function capture(config, opts = {}) { } if (fatalDemoError) throw fatalDemoError; + if (demoErrors.length) { + const names = demoErrors.map((item) => item.name).join(', '); + throw new Error(`demo capture failed for: ${names}`, { cause: demoErrors[0].error }); + } // 5. Handoff contract — metadata for external editors, MCP adapters, and // agents. This is the starter-pack layer, not a competing editor surface. diff --git a/src/cli-runner.js b/src/cli-runner.js index 7ac5b37..9e5c494 100644 --- a/src/cli-runner.js +++ b/src/cli-runner.js @@ -48,12 +48,14 @@ async function runCli(argv, io = {}, deps = {}) { return 0; } catch (err) { const msg = err && err.message ? err.message : String(err); + const code = Number.isInteger(err && err.exitCode) ? err.exitCode : 1; if (opts.json) { - writeJson(stdout, errorPayload(msg, 1)); + writeJson(stdout, errorPayload(msg, code)); } else { - stderr.write(`[shotkit] FAILED: ${err && err.stack ? err.stack : err}\n`); + const detail = code === 2 ? msg : (err && err.stack ? err.stack : err); + stderr.write(`[shotkit] ${code === 2 ? msg : `FAILED: ${detail}`}\n`); } - return 1; + return code; } } diff --git a/test/capture-description.test.js b/test/capture-description.test.js index 47f574d..d767722 100644 --- a/test/capture-description.test.js +++ b/test/capture-description.test.js @@ -10,8 +10,13 @@ jest.mock('../src/launch', () => ({ closeContext: jest.fn(async () => {}), })); +const { launchWithExtension } = require('../src/launch'); const { capture } = require('../src/capture'); +afterEach(() => { + jest.clearAllMocks(); +}); + function writeProductManifest(cwd) { fs.writeFileSync(path.join(cwd, 'product.manifest.json'), JSON.stringify({ product: { @@ -48,13 +53,13 @@ function writeProductManifest(cwd) { test('capture writes listing and privacy worksheet from product manifest', async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-capture-product-')); - const extensionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-extension-')); + const prepareExtension = jest.fn(async () => fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-extension-'))); writeProductManifest(cwd); const result = await capture({ outDir: 'store-assets', description: { from: 'product.manifest.json', channel: 'chromeWebStore' }, - prepareExtension: async () => extensionDir, + prepareExtension, }, { cwd, noBuild: true, @@ -74,6 +79,37 @@ test('capture writes listing and privacy worksheet from product manifest', async const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); expect(manifest.assets.some((asset) => asset.role === 'store-listing-copy')).toBe(true); expect(manifest.assets.some((asset) => asset.role === 'privacy-disclosure')).toBe(true); + expect(prepareExtension).not.toHaveBeenCalled(); + expect(launchWithExtension).not.toHaveBeenCalled(); +}); + +test('description-only scene does not run build, prepareExtension, or Chromium', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-capture-description-only-')); + const prepareExtension = jest.fn(() => { + throw new Error('prepareExtension should not run'); + }); + writeProductManifest(cwd); + + const result = await capture({ + build: `${process.execPath} -e "process.exit(7)"`, + outDir: 'store-assets', + description: { from: 'product.manifest.json', channel: 'chromeWebStore' }, + prepareExtension, + }, { + cwd, + scenes: ['description'], + noVideo: true, + log: () => {}, + }); + + expect(result.produced.map((filePath) => path.basename(filePath))).toEqual([ + 'description.md', + 'storyboard.json', + 'captions.json', + 'shotkit-manifest.json', + ]); + expect(prepareExtension).not.toHaveBeenCalled(); + expect(launchWithExtension).not.toHaveBeenCalled(); }); test('capture does not delete caller-owned extension directories', async () => { diff --git a/test/capture-lifecycle.test.js b/test/capture-lifecycle.test.js index 0bc3c3f..6364cb1 100644 --- a/test/capture-lifecycle.test.js +++ b/test/capture-lifecycle.test.js @@ -54,12 +54,12 @@ describe('capture lifecycle cleanup', () => { expect(cleanup).toHaveBeenCalledTimes(1); }); - test('closes the demo context and prepared extension when demo setup throws', async () => { + test('closes the demo context and prepared extension then fails when demo setup throws', async () => { const cleanup = jest.fn(); const cwd = tempCwd(); const logs = []; - const result = await capture({ + await expect(capture({ handoff: false, prepareExtension: async () => preparedExtension(cleanup), setup: async () => { @@ -71,12 +71,77 @@ describe('capture lifecycle cleanup', () => { scenes: ['demo'], noBuild: true, log: (msg) => logs.push(msg), - }); + })).rejects.toThrow(/demo capture failed for: demo/); - expect(result.produced).toEqual([]); expect(launchWithExtension).toHaveBeenCalledTimes(1); expect(closeContext).toHaveBeenCalledTimes(1); expect(cleanup).toHaveBeenCalledTimes(1); expect(logs.join('\n')).toContain('demo "demo" failed: demo setup failed'); }); + + test('fails when a requested demo does not produce a video recording', async () => { + const cleanup = jest.fn(); + const cwd = tempCwd(); + const logs = []; + let closed = false; + const page = { + setViewportSize: jest.fn(async () => {}), + on: jest.fn(), + off: jest.fn(), + video: jest.fn(() => null), + close: jest.fn(async () => { closed = true; }), + isClosed: jest.fn(() => closed), + }; + launchWithExtension.mockResolvedValueOnce({ + extensionId: 'test-extension', + context: { + addInitScript: jest.fn(async () => {}), + newPage: jest.fn(async () => page), + }, + }); + + await expect(capture({ + handoff: false, + prepareExtension: async () => preparedExtension(cleanup), + demos: [{ name: 'demo', run: async () => {} }], + }, { + cwd, + scenes: ['demo'], + noBuild: true, + log: (msg) => logs.push(msg), + })).rejects.toThrow(/demo capture failed for: demo/); + + expect(page.video).toHaveBeenCalledTimes(1); + expect(page.close).toHaveBeenCalledTimes(1); + expect(closeContext).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(logs.join('\n')).toContain('demo "demo" failed: demo "demo" did not produce a video recording'); + expect(fs.existsSync(path.join(cwd, 'store-assets', 'demo.webm'))).toBe(false); + }); + + test('rejects unknown scene names before launching Chromium or preparing an extension', async () => { + const cleanup = jest.fn(); + const prepareExtension = jest.fn(async () => preparedExtension(cleanup)); + const cwd = tempCwd(); + + await expect(capture({ + handoff: false, + prepareExtension, + scenes: [{ name: 'known-scene', run: async () => {} }], + demos: [{ name: 'known-demo', run: async () => {} }], + }, { + cwd, + scenes: ['missing-scene'], + noBuild: true, + log: () => {}, + })).rejects.toMatchObject({ + message: 'unknown scene: missing-scene. Known: known-scene, known-demo', + exitCode: 2, + }); + + expect(prepareExtension).not.toHaveBeenCalled(); + expect(launchWithExtension).not.toHaveBeenCalled(); + expect(cleanup).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(cwd, 'store-assets'))).toBe(false); + }); }); diff --git a/test/cli.test.js b/test/cli.test.js index 5d24ae8..a3b87e7 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -90,6 +90,28 @@ describe('shotkit CLI usage errors', () => { }); expect(res.stderr).toBe(''); }); + + test('--json unknown scenes fail with usage code 2 before capture work', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sk-cli-scene-')); + fs.writeFileSync(path.join(dir, 'shotkit.config.js'), ` +module.exports = { + prepareExtension() { throw new Error('prepareExtension should not run'); }, + scenes: [{ name: 'known-scene', run: async () => {} }], +}; +`); + + const res = spawnSync(process.execPath, [BIN, dir, '--scene', 'missing-scene', '--json', '--no-build', '--no-video'], { + encoding: 'utf8', + }); + + expect(res.status).toBe(2); + expect(JSON.parse(res.stdout)).toEqual({ + ok: false, + error: 'unknown scene: missing-scene. Known: known-scene', + code: 2, + }); + expect(res.stderr).toBe(''); + }); }); describe('resolveConfigPath', () => { From cd9377d7dd1640a07d816cf347b2025946d16c27 Mon Sep 17 00:00:00 2001 From: heznpc Date: Fri, 10 Jul 2026 12:11:13 +0900 Subject: [PATCH 04/13] feat: make handoff packs agent-ready --- AGENTS.md | 5 +- CHANGELOG.md | 22 +++ README.ko.md | 30 ++-- README.md | 30 ++-- docs/handoff-conventions.md | 58 +++++-- package-lock.json | 10 +- package.json | 12 +- schemas/shotkit-manifest.schema.json | 129 +++++++++++++++- skills/capture/SKILL.md | 20 ++- src/capture.js | 18 ++- src/cli-runner.js | 4 +- src/cli.js | 8 +- src/handoff-files.js | 143 ++++++++++++++++++ src/handoff-validator.js | 50 ++++++ src/handoff.js | 175 ++++++++++++++++----- src/index.js | 8 +- src/integrations.js | 11 +- test/capture-description.test.js | 4 + test/cli-runner.test.js | 7 +- test/handoff.test.js | 218 ++++++++++++++++++++++++++- test/schema-validation.test.js | 20 +++ test/schema.test.js | 9 ++ 22 files changed, 888 insertions(+), 103 deletions(-) create mode 100644 src/handoff-files.js create mode 100644 src/handoff-validator.js diff --git a/AGENTS.md b/AGENTS.md index f12b9d8..0efc4b7 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 agent-ready launch asset capture and handoff pipeline for browser extensions. +Playwright drives the shipped product; 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e978ca..354021a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,21 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +### Added +- Every handoff pack now bundles its three 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 agent-ready launch asset capture and + handoff pipeline; Playwright remains the implementation mechanism. ### Fixed - `step(text, fn, options)` now honors flat caption display options (e.g. @@ -20,6 +31,17 @@ 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 (source-staged) diff --git a/README.ko.md b/README.ko.md index 59a3b06..1b0779a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,9 +2,10 @@ # shotkit -**빌드된 브라우저 익스텐션에서 스토어 자산과 데모 handoff pack을 캡처 — Playwright 기반.** +**브라우저 익스텐션을 위한 에이전트 친화적 출시 자산 캡처·handoff 파이프라인.** -스크린샷 · 프로모 이미지 · 데모 클립 · storyboard · handoff manifest. 한 커맨드. +실제 출하 빌드를 실행해 CWS/SNS 원본 자산과 버전·스키마가 있는 +자기완결형 evidence pack을 만듭니다. [![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,11 +16,11 @@ --- -> **[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 실행 블록**. +- **현재 구현된 것** — Playwright로 *실제 출하 빌드*를 실행하는 캡처 엔진과, 그 결과를 에이전트가 이어받을 수 있게 묶는 출시 자산 **파이프라인**입니다. CWS 스크린샷/프로모 타일, SNS 데모 클립, listing/privacy 문안과 함께 `shotkit-manifest.json`, storyboard, captions, 로컬 schema, 구조화된 review 상태, adapter hint, 파일별 SHA-256을 산출합니다. CLI의 `--json` 계약은 선택적 repo `path`, `0/1/2` 종료 코드, manifest entrypoint를 반환하며, 같은 엔진을 `capture()`, Claude Code skill, 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 캠페인 컷으로 바꾸기 쉬운 정도까지만 제공합니다. - **설계 의도** — *엔진 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를 이어받게 합니다. @@ -63,7 +64,7 @@ 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을 끕니다). ### Handoff Pack @@ -74,8 +75,10 @@ layer입니다. 실제 빌드된 확장을 캡처하고, source clip과 “이 - `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`. +- `shotkit-manifest.json` — entrypoint. asset inventory/integrity, 실행·freshness + metadata, review 요약, 로컬 schema path, 다음 도구 후보 `adapterHints`. +- `schemas/*.schema.json` — 설치된 npm 패키지 없이도 검증할 수 있도록 + 모든 pack에 함께 복사되는 계약. 이렇게 하면 에이전트나 MCP connector가 manifest를 읽고 mp4/webm, thumbnail, captions를 Screen Studio, Canva, Supademo 또는 다른 편집 도구로 @@ -88,8 +91,10 @@ manifest는 downstream 연결 후보도 제안합니다. 예를 들어 thumbnail 추가 입력이 필요한 경우 `needs-input`으로 표시됩니다. shotkit은 다음 도구를 제안하고, 실제 연결은 에이전트의 MCP/tool 환경이 수행합니다. -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/)를 보세요. @@ -200,9 +205,12 @@ result → safety/restore, 짧은 캡션, 느린 cursor/click/typing, X용 mp4 ### 에이전트 계약 (`--json`) `shotkit [path] --json`은 stdout에 **정확히 하나의 JSON 객체**를 출력합니다 -(진행 로그는 stderr로 이동): `{ "ok": true, "outDir": …, "produced": [절대경로…] }`. +(진행 로그는 stderr로 이동): `{ "ok": true, "outDir": …, "manifest": …, "produced": [절대경로…] }`. 종료 코드: `0` 정상 · `1` 런타임 실패 · `2` 사용법 오류/설정 없음입니다. -실패 payload도 stdout의 단일 JSON 객체(`{"ok":false,"error":…}`)를 사용합니다. 에이전트 연결은 [`AGENTS.md`](AGENTS.md) 실행 블록 +`ok:true`는 요청 단계가 끝나고 파일이 기록됐다는 뜻이며, 채널 완성도·시각 +승인·법률 준수·외부 connector 가용성을 인증하지는 않습니다. 반환된 manifest의 +`handoff.review`와 `handoff.adapterHints[]`에서 다음 판단을 이어갑니다. 실패 +payload도 stdout의 단일 JSON 객체(`{"ok":false,"error":…}`)를 사용합니다. 에이전트 연결은 [`AGENTS.md`](AGENTS.md) 실행 블록 (Claude Code·Codex·Cursor·Gemini CLI 등이 읽음)과 [`skills/capture/`](skills/capture/SKILL.md) skill(Agent Skills 표준 — 호환 도구의 skills 디렉터리에 폴더째 복사)을 참고하십시오. diff --git a/README.md b/README.md index e9f7985..11af8d4 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ # shotkit -**Capture browser-extension store assets and demo handoff packs — with Playwright.** +**Agent-ready launch asset capture and handoff for browser extensions.** -Screenshots · promo images · demo clips · storyboard · handoff manifest. One command. +Build the shipped extension. Produce CWS/SNS source assets. Hand off a versioned, +self-contained evidence pack. [![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,13 +16,13 @@ Screenshots · promo images · demo clips · storyboard · handoff manifest. One --- -> **Part of [Starter Series](https://github.com/starter-series)** — reusable tooling, not just clone-templates. `shotkit` is the unscoped package identity for this capture engine; public npm install is a release gate, not assumed by the repo README. +> **Part of [Starter Series](https://github.com/starter-series)** — reusable tooling, not just clone-templates. `shotkit` is the unscoped package identity for this launch asset pipeline; public npm install is a release gate, not assumed by the repo README. --- ## Status & Scope -- **Currently implemented** — A Playwright capture **engine** (build → launch the *built* extension via `launchPersistentContext(--load-extension)` → drive scenes → screenshot → caption/disclaimer band → promo tile from HTML → demo `webm` with DOM caption overlays → listing copy from `STORE_LISTING.md` or `product.manifest.json` → optional `privacy-disclosure.md` worksheet → `storyboard.json` / `captions.json` / `shotkit-manifest.json` handoff pack), a **CLI** (`shotkit`) with an **agent contract** (`--json` machine output, optional `path` argument, `0/1/2` exit codes), **size presets** for both audiences (CWS `1280×800`/`440×280`, SNS `1200×675`/`1280×720`/`1200×630`/`1080×1080`), a **path-traversal-safe** localhost fixture server, a programmatic API (`capture()`), a **Claude Code plugin + skill** ([`skills/capture/`](skills/capture/SKILL.md); `/plugin install shotkit@starter-series`), an **AGENTS.md run-block** so any shell-having coding agent can invoke it, and **demo post-processing** for SNS (`webm → H.264 mp4` with `+faststart`, frame-accurate **trim**, static crop/zoom framing, thumbnails — needs an ffmpeg on PATH or `SHOTKIT_FFMPEG`; GitHub ubuntu runners ship one). +- **Currently implemented** — An agent-ready launch asset **pipeline** whose capture engine builds and launches the *shipped* extension via Playwright, drives repo-owned scenes, and emits CWS screenshots/promo tiles, SNS demo clips, listing/privacy copy, plus a schema-backed evidence pack (`shotkit-manifest.json`, storyboard, captions, bundled schemas, structured review state, adapter hints, and per-file SHA-256). Its **CLI agent contract** provides `--json`, an optional repo `path`, explicit `0/1/2` exit codes, and the manifest entrypoint. The same engine is available through `capture()`, the Claude Code plugin + skill ([`skills/capture/`](skills/capture/SKILL.md); `/plugin install shotkit@starter-series`), and an AGENTS.md run-block. SNS post-processing covers H.264 mp4, trim, static crop/zoom, and thumbnails with a real ffmpeg. - **Story renderer** — Demo configs can use one `demo` or several `demos: []` entries, timed `captions`, pointer-highlighted clicks, paced cursor movement, static zoom/crop framing, thumbnail frames, storyboard lint, and a small `demo` helper (`caption`, `step`, `wait`, `click`) so an agent can turn a feature checklist into 20-40 second before → action → result stories without pulling in a general video editor. - **Design intent** — *One engine, many surfaces — matched to the tool's nature.* shotkit is a heavy, file-producing build tool, so its surfaces are CLI (+`--json`), skill, and CI — not MCP (see Non-goals). Captures are **deterministic** (login-free fixtures, frozen data) and the run **doubles as a real-bundle smoke test** — a screenshot only appears if that feature rendered from the shipped code. **Trademark-safe** by construction: a disclaimer band is composited onto every shot. - **Non-goals** — An **MCP server** inside shotkit (dropped by design: agents with a shell get a better contract from `--json` + the skill). Removing the per-repo **scene config** (which screens are *your* money shots is irreducible intent — it lives in your `shotkit.config.js`). A general-purpose video editor or hosted demo platform. shotkit creates source evidence and a handoff pack; Screen Studio, Canva, Supademo, or future MCP connectors can do polish later. @@ -72,7 +73,7 @@ shotkit --no-build # use an already-built bundle shotkit ../my-extension --json # run against another checkout; JSON result on stdout ``` -Outputs land in `outDir` (default `store-assets/`): `.png`, `.png`, `.webm`, optional `.mp4`, optional `-thumbnail.png`, `description.md`, optional `privacy-disclosure.md`, and, by default, `storyboard.json`, `captions.json`, and `shotkit-manifest.json` (`handoff: false` disables the handoff files). +Outputs land in `outDir` (default `store-assets/`): `.png`, `.png`, `.webm`, optional `.mp4`, optional `-thumbnail.png`, `description.md`, optional `privacy-disclosure.md`, and, by default, `storyboard.json`, `captions.json`, `shotkit-manifest.json`, plus the three schemas under `schemas/` (`handoff: false` disables the handoff pack). ### Handoff Pack @@ -83,8 +84,11 @@ the clips mean. - `storyboard.json` — demo names, audience, viewport, trim/framing hints, beats, structured storyboard lint warnings, and suggested next tool. - `captions.json` — portable caption timings and text per demo. -- `shotkit-manifest.json` — asset list, output paths, roles, project info, and - recommended handoff flow plus `adapterHints` for likely next tools. +- `shotkit-manifest.json` — the entrypoint: asset inventory and integrity, + run/freshness metadata, review summary, bundled schema paths, and + `adapterHints` for likely next tools. +- `schemas/*.schema.json` — local validation contracts, copied into every pack + so a downstream agent does not need the installed npm package. This makes external polish easier: an agent or MCP connector can read the manifest, open the mp4/webm + thumbnail + captions in Screen Studio, Canva, @@ -98,8 +102,10 @@ design handoff, `higgsfield` appears for AI-video campaign variants, and captured assets are not enough by themselves. shotkit suggests the next tool; the agent's own MCP/tool environment performs the connection. -The convention is versioned and schema-backed. `$schema` values are URN -identifiers; load the actual schema files from the installed package. See +The convention is versioned and schema-backed. `$schema` values are stable URN +identifiers; `handoff.schemaFiles` resolves them to files inside the output pack. +Every delivered file except the self-referential manifest carries byte size and +SHA-256 integrity metadata. See [`docs/handoff-conventions.md`](docs/handoff-conventions.md) and the packaged schemas under [`schemas/`](schemas/). @@ -241,11 +247,15 @@ 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, "outDir": "/abs/store-assets", "manifest": "/abs/store-assets/shotkit-manifest.json", "produced": ["/abs/store-assets/01-popup.png"] } ``` 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 the requested stages completed and the files were written. It +does not certify channel completeness, visual approval, legal compliance, or +availability of a downstream connector; read `handoff.review` and +`handoff.adapterHints[]` from the returned manifest for those next decisions. 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 — diff --git a/docs/handoff-conventions.md b/docs/handoff-conventions.md index 4fcd65c..4139037 100644 --- a/docs/handoff-conventions.md +++ b/docs/handoff-conventions.md @@ -1,9 +1,10 @@ # 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 agent-ready launch asset capture and handoff layer for browser +extensions. It should not try to become Screen Studio, Canva, Supademo, or a +hosted demo editor. Instead, it turns the shipped product into repeatable source +evidence and a self-contained contract that agents, tools, or MCP adapters can +consume. ## Files @@ -13,10 +14,20 @@ Every successful run writes these files unless `handoff: false` is set: - `storyboard.json` — demo intent, beats, viewport, trim/framing hints, and structured storyboard lint. - `captions.json` — portable caption timing and text. +- `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. 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 three-document pack with these +same schemas before publishing the manifest. + +The CLI's `ok:true` only means the requested stages completed and files were +written. It does not certify visual approval, store-policy compliance, channel +completeness, legal compliance, or connector availability. ## Manifest Roles @@ -25,15 +36,40 @@ 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.status` is `ready` when the captured storyboard has no warnings, +`needs-review` when lint found improvements, and `incomplete` when a selected +demo was skipped or retained evidence changed. This is a quality-review signal, +not launch certification. `handoff.summary` reports asset, demo, and ready- +adapter counts. + +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 assets were visually approved. ## Tool Handoff @@ -56,7 +92,8 @@ Each hint includes: Recommended downstream flow: -1. Read `shotkit-manifest.json`. +1. Read the CLI result's `manifest` path and, when needed, validate it with + `handoff.schemaFiles.manifest`. 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. @@ -130,5 +167,6 @@ The handoff contract is versioned independently from the npm package: - 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/package-lock.json b/package-lock.json index 4e445db..7e00926 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.3.0", "license": "MIT", "dependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "playwright": "^1.60.0" }, "bin": { @@ -16,8 +18,6 @@ }, "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" @@ -1797,7 +1797,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 +1813,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" @@ -2712,7 +2710,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 +2730,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", @@ -3878,7 +3874,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": { @@ -4525,7 +4520,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" diff --git a/package.json b/package.json index 5aaa305..4239b7f 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": "Agent-ready browser-extension launch asset pipeline for CWS screenshots, social demos, and schema-backed handoff packs.", "main": "src/index.js", "exports": { ".": "./src/index.js", @@ -37,6 +37,10 @@ "og-image", "store-assets", "demo-video", + "launch-assets", + "agent-ready", + "json-schema", + "product-evidence", "storyboard", "handoff" ], @@ -46,12 +50,12 @@ "node": ">=22" }, "dependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "playwright": "^1.60.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" @@ -70,6 +74,8 @@ "src/demo-time.js", "src/demo-storyboard.js", "src/handoff.js", + "src/handoff-files.js", + "src/handoff-validator.js", "src/integrations.js" ], "coverageReporters": [ diff --git a/schemas/shotkit-manifest.schema.json b/schemas/shotkit-manifest.schema.json index a79f92f..d209e64 100644 --- a/schemas/shotkit-manifest.schema.json +++ b/schemas/shotkit-manifest.schema.json @@ -26,13 +26,52 @@ "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" } + }, + "video": { "type": "boolean" }, + "noBuild": { "type": "boolean" }, + "mp4": { "type": "boolean" }, + "configuredDemos": { "$ref": "#/$defs/names" }, + "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"], @@ -42,6 +81,15 @@ "captions": { "type": "string" } } }, + "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" } + } + }, "storyboards": { "const": "storyboard.json" }, "captions": { "const": "captions.json" }, "recommendedFlow": { @@ -51,6 +99,31 @@ "adapterHints": { "type": "array", "items": { "$ref": "#/$defs/adapterHint" } + }, + "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 } + } } } }, @@ -61,6 +134,10 @@ "config": { "type": "object", "additionalProperties": true } }, "$defs": { + "names": { + "type": "array", + "items": { "type": "string" } + }, "project": { "type": "object", "additionalProperties": true, @@ -84,6 +161,33 @@ "outPath": { "type": "string" }, "width": { "type": "integer", "minimum": 1 }, "height": { "type": "integer", "minimum": 1 }, + "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, @@ -95,6 +199,29 @@ } } }, + "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/skills/capture/SKILL.md b/skills/capture/SKILL.md index 5afe867..d30c765 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). +description: Build, inspect, iterate, and hand off an agent-ready browser-extension launch asset pack with shotkit. Use for CWS screenshots, promo/OG images, social demos, listing/privacy evidence, storyboard review, or downstream editor handoff in a repo with 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 --- -# Capture store/social assets with shotkit +# Build and hand off 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 @@ -34,11 +34,11 @@ rendered from the shipped code. By default, it also writes a handoff pack: 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", …] }`. + `{ "ok": true, "outDir": "...", "manifest": "/abs/path/shotkit-manifest.json", "produced": ["/abs/path/01-….png", …] }`. 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. + Read the returned `manifest` path first. It lists the mp4/webm, thumbnail, + captions, storyboard, bundled schema paths, integrity metadata, review + warnings, run freshness, and `handoff.adapterHints[]` for likely next tools. 4. **On 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 @@ -82,3 +82,11 @@ rendered from the shipped code. By default, it also writes a handoff pack: `handoff.adapterHints[]`. Prefer `readiness:"ready"` hints first; treat `needs-input` as a prompt to ask for missing avatar/audio/brand inputs; treat `needs-assets` as a prompt to rerun shotkit with mp4/thumbnail/captions. +- Treat CLI `ok:true` as execution success only. Report + `handoff.review.status`, warning and incomplete counts, produced roles, + whether the run is full or partial, and the first asset-ready adapter. Do not + describe the pack as launch-approved without a separate visual/policy review. +- Validate a received pack through `handoff.schemaFiles`; schema paths are + relative to the manifest directory. On partial runs, compare each asset's + `runId` with `manifest.run.id` and inspect `state` before assuming it was + refreshed. diff --git a/src/capture.js b/src/capture.js index 3837a25..c3d5bb0 100644 --- a/src/capture.js +++ b/src/capture.js @@ -117,7 +117,7 @@ function usageError(message) { * @param {boolean} [opts.freeze] passed to config hooks as flags.freeze * @param {string} [opts.cwd] project root for build / outDir / listing sources * @param {(msg:string)=>void} [opts.log] - * @returns {Promise<{produced: string[], outDir: string}>} + * @returns {Promise<{produced: string[], outDir: string, manifest: string|null}>} */ async function capture(config, opts = {}) { const cwd = opts.cwd || process.cwd(); @@ -130,6 +130,7 @@ async function capture(config, opts = {}) { const defaultViewport = resolveSize(config.viewport, DEFAULT_VIEWPORT); const bandHeight = config.bandHeight || DEFAULT_BAND_HEIGHT; const produced = []; + let manifest = null; const assets = []; const demoConfigs = normalizeDemoConfigs(config); const capturedDemoConfigs = []; @@ -473,13 +474,26 @@ async function capture(config, opts = {}) { // Scene-filtered or --no-video runs only re-capture a subset; merge into // the existing handoff contract rather than clobbering a prior full run. partial: only.size > 0 || !!opts.noVideo, + run: { + requestedScenes: [...only], + video: !opts.noVideo, + noBuild: !!opts.noBuild, + mp4: !!opts.mp4, + configuredDemos: demoConfigs.map((demoConfig) => demoConfig.name), + selectedDemos: demoConfigs.filter((demoConfig) => wants(demoConfig.name)).map((demoConfig) => demoConfig.name), + capturedDemos: capturedDemoConfigs.map((demoConfig) => demoConfig.name), + skippedDemos: demoConfigs + .filter((demoConfig) => wants(demoConfig.name) && !capturedDemoConfigs.includes(demoConfig)) + .map((demoConfig) => demoConfig.name), + }, }); produced.push(...handoffPaths); + manifest = path.join(outDir, 'shotkit-manifest.json'); for (const out of handoffPaths) log(`✓ ${path.basename(out)}`); } log(`done — ${produced.length} asset(s) in ${path.relative(cwd, outDir) || '.'}/`); - return { produced, outDir }; + return { produced, outDir, manifest }; } finally { await cleanupTempResources(); } diff --git a/src/cli-runner.js b/src/cli-runner.js index 9e5c494..d5d821a 100644 --- a/src/cli-runner.js +++ b/src/cli-runner.js @@ -43,8 +43,8 @@ async function runCli(argv, io = {}, deps = {}) { try { const config = loadConfig(configPath); const log = opts.json ? (m) => stderr.write(`[shotkit] ${m}\n`) : undefined; - const { produced, outDir } = await capture(config, { ...opts, cwd, log }); - if (opts.json) writeJson(stdout, { ok: true, outDir, produced }); + const { produced, outDir, manifest = null } = await capture(config, { ...opts, cwd, log }); + if (opts.json) writeJson(stdout, { ok: true, outDir, manifest, produced }); return 0; } catch (err) { const msg = err && err.message ? err.message : String(err); diff --git a/src/cli.js b/src/cli.js index 5f7359f..7f9a40a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -10,7 +10,7 @@ const fs = require('fs'); const path = require('path'); -const USAGE = `shotkit — capture store/social assets from a built extension +const USAGE = `shotkit — build an agent-ready launch asset pack from a browser extension Usage: shotkit [path] [options] @@ -24,7 +24,7 @@ Options: "description", or "privacy"; repeatable, or comma-separated. When given, nothing else runs. --json machine-readable mode: stdout gets one JSON object - {ok, outDir, produced[]}; progress logs move to stderr + {ok, outDir, manifest, produced[]}; progress logs move to stderr --no-video skip the demo screencast --mp4 also convert the demo to H.264 mp4 (needs ffmpeg on PATH or SHOTKIT_FFMPEG; SNS uploaders want mp4, not webm) @@ -33,8 +33,8 @@ Options: --freeze pass flags.freeze to config hooks -h, --help show this help -Handoff: successful runs also write storyboard.json, captions.json, and -shotkit-manifest.json unless the config sets handoff:false. +Handoff: successful runs also write a self-contained schema-backed pack with +shotkit-manifest.json as its entrypoint unless the config sets handoff:false. Exit codes: 0 ok · 1 runtime failure · 2 usage / no config found `; diff --git a/src/handoff-files.js b/src/handoff-files.js new file mode 100644 index 0000000..c6d4d0f --- /dev/null +++ b/src/handoff-files.js @@ -0,0 +1,143 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +function writeJson(filePath, data) { + const tempPath = path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`, + ); + try { + fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`); + fs.renameSync(tempPath, filePath); + } catch (err) { + fs.rmSync(tempPath, { force: true }); + throw err; + } +} + +function readJsonIfExists(filePath) { + try { + return fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, 'utf8')) : null; + } catch (_err) { + return null; + } +} + +function mergeByKey(previous, current, keyOf) { + if (!Array.isArray(previous) || !previous.length) return current; + const currentKeys = new Set(current.map(keyOf)); + const retained = previous.filter((item) => item && !currentKeys.has(keyOf(item))); + return [...retained, ...current]; +} + +function namesMatch(left, right) { + const a = new Set((left || []).map((item) => item && item.name).filter(Boolean)); + const b = new Set((right || []).map((item) => item && item.name).filter(Boolean)); + return a.size === b.size && [...a].every((name) => b.has(name)); +} + +function copyHandoffSchemas(outDir, schemaFiles) { + const schemaDir = path.join(outDir, 'schemas'); + fs.mkdirSync(schemaDir, { recursive: true }); + return Object.values(schemaFiles).map((relativePath) => { + const target = path.join(outDir, relativePath); + const source = path.join(__dirname, '..', 'schemas', path.basename(relativePath)); + fs.copyFileSync(source, target); + return target; + }); +} + +function safeAssetPath(outDir, asset) { + if (!asset || typeof asset.outPath !== 'string') return null; + const target = path.resolve(outDir, asset.outPath); + const relative = path.relative(outDir, target); + if (relative.startsWith('..') || path.isAbsolute(relative)) return null; + return target; +} + +function sha256File(filePath) { + const hash = crypto.createHash('sha256'); + const buffer = Buffer.allocUnsafe(64 * 1024); + const fd = fs.openSync(filePath, 'r'); + try { + let bytesRead; + do { + bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null); + if (bytesRead) hash.update(buffer.subarray(0, bytesRead)); + } while (bytesRead); + } finally { + fs.closeSync(fd); + } + return hash.digest('hex'); +} + +function hydrateManifestAssets(assets, outDir, manifestPath) { + const manifestTarget = path.resolve(manifestPath); + return assets.flatMap((asset) => { + const filePath = safeAssetPath(outDir, asset); + if (!filePath) return []; + // A manifest cannot contain a stable digest of itself. + if (filePath === manifestTarget) return [asset]; + if (!fs.existsSync(filePath)) return []; + const stat = fs.statSync(filePath); + if (!stat.isFile()) return []; + const digest = sha256File(filePath); + if (asset.state === 'retained' && asset.integrity && asset.integrity.digest !== digest) { + return [{ + ...asset, + state: 'modified', + observed: { + bytes: stat.size, + integrity: { algorithm: 'sha256', digest }, + }, + }]; + } + return [{ + ...asset, + bytes: stat.size, + integrity: { algorithm: 'sha256', digest }, + }]; + }); +} + +function assertUnique(items, key, label) { + const seen = new Set(); + for (const item of items) { + const value = item[key]; + if (seen.has(value)) throw new Error(`handoff has duplicate asset ${label}: ${value}`); + seen.add(value); + } +} + +function validateFinalPack(docs, outDir, manifestPath) { + if (!namesMatch(docs.storyboard.demos, docs.captions.demos)) { + throw new Error('handoff storyboard and captions demo sets do not match'); + } + assertUnique(docs.manifest.assets, 'id', 'id'); + assertUnique(docs.manifest.assets, 'outPath', 'outPath'); + const ids = new Set(docs.manifest.assets.map((asset) => asset.id)); + for (const asset of docs.manifest.assets) { + const filePath = safeAssetPath(outDir, asset); + if (!filePath) throw new Error(`handoff asset escapes outDir: ${asset.outPath}`); + if (path.resolve(filePath) === path.resolve(manifestPath)) continue; + if (!fs.existsSync(filePath) || !asset.integrity || !Number.isInteger(asset.bytes)) { + throw new Error(`handoff asset is missing integrity metadata: ${asset.id}`); + } + } + for (const hint of docs.manifest.handoff.adapterHints) { + for (const asset of hint.useAssets) { + if (!ids.has(asset.id)) throw new Error(`adapter hint references unknown asset: ${asset.id}`); + } + } +} + +module.exports = { + copyHandoffSchemas, + hydrateManifestAssets, + mergeByKey, + namesMatch, + readJsonIfExists, + validateFinalPack, + writeJson, +}; diff --git a/src/handoff-validator.js b/src/handoff-validator.js new file mode 100644 index 0000000..ab93b08 --- /dev/null +++ b/src/handoff-validator.js @@ -0,0 +1,50 @@ +const fs = require('fs'); +const path = require('path'); +const Ajv = require('ajv/dist/2020'); +const addFormats = require('ajv-formats'); + +const SCHEMAS = Object.freeze({ + manifest: 'shotkit-manifest.schema.json', + storyboard: 'storyboard.schema.json', + captions: 'captions.schema.json', +}); + +let validators; + +function handoffValidators() { + if (validators) return validators; + const ajv = new Ajv({ strict: false, allErrors: true }); + addFormats(ajv); + validators = Object.fromEntries(Object.entries(SCHEMAS).map(([key, filename]) => { + const schema = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'schemas', filename), 'utf8')); + return [key, ajv.compile(schema)]; + })); + return validators; +} + +function validationMessage(key, errors) { + const detail = (errors || []).map((error) => ( + `${error.instancePath || '/'} ${error.message}` + )).join('; '); + return `${key} does not match its packaged schema${detail ? `: ${detail}` : ''}`; +} + +function validateHandoffDocs(docs) { + for (const [key, validate] of Object.entries(handoffValidators())) { + if (!validate(docs && docs[key])) { + throw new Error(validationMessage(key, validate.errors)); + } + } + return true; +} + +function isValidHandoffDocs(docs) { + try { + validateHandoffDocs(docs); + return true; + } catch (_err) { + return false; + } +} + +module.exports = { isValidHandoffDocs, validateHandoffDocs }; diff --git a/src/handoff.js b/src/handoff.js index ba6dcfd..afaaee5 100644 --- a/src/handoff.js +++ b/src/handoff.js @@ -1,15 +1,26 @@ /* * shotkit — handoff contract exports. * - * These JSON files are the "starter pack" layer: not a video editor, but a - * clean bundle of captured assets, captions, story intent, and next-tool hints - * that Screen Studio / Canva / Supademo / future MCP adapters can consume. + * These files are the agent-ready product boundary: a self-contained bundle of + * captured evidence, captions, story intent, integrity, review state, and + * next-tool hints for editors or future MCP adapters. */ const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const { normalizeDemoCaptions, parseTimeToMs } = require('./demo-time'); const { buildHandoffRecommendations } = require('./integrations'); +const { isValidHandoffDocs, validateHandoffDocs } = require('./handoff-validator'); +const { + copyHandoffSchemas, + hydrateManifestAssets, + mergeByKey, + namesMatch, + readJsonIfExists, + validateFinalPack, + writeJson, +} = require('./handoff-files'); const HANDOFF_VERSION = 1; const HANDOFF_KINDS = Object.freeze({ @@ -22,6 +33,11 @@ const HANDOFF_SCHEMA_IDS = Object.freeze({ storyboard: 'urn:starter-series:shotkit:schema:storyboard:v1', captions: 'urn:starter-series:shotkit:schema:captions:v1', }); +const HANDOFF_SCHEMA_FILES = Object.freeze({ + manifest: 'schemas/shotkit-manifest.schema.json', + storyboard: 'schemas/storyboard.schema.json', + captions: 'schemas/captions.schema.json', +}); function readProjectInfo(cwd) { const packagePath = path.join(cwd, 'package.json'); @@ -157,10 +173,68 @@ function storyboardLintSummary(warnings) { })); } -function buildHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewports, demoWarnings, flags }) { +function handoffReview(storyboardLint, run = {}, assets = []) { + const warnings = (storyboardLint || []).flatMap((summary) => ( + (summary.warnings || []).map((warning) => ({ demo: summary.name, ...warning })) + )); + const incomplete = (run.skippedDemos || []).map((name) => ({ + code: 'demo-skipped', + demo: name, + reason: run.video ? 'not-captured' : 'video-disabled', + fix: run.video ? `capture the ${name} demo` : 'rerun without --no-video', + })); + for (const asset of assets.filter((item) => item.state === 'modified')) { + incomplete.push({ + code: 'asset-integrity-mismatch', + asset: asset.id, + reason: 'retained-file-changed', + fix: `rerun the source for ${asset.id}`, + }); + } + return { + status: incomplete.length ? 'incomplete' : warnings.length ? 'needs-review' : 'ready', + warningCount: warnings.length, + warnings, + incomplete, + }; +} + +function refreshManifestHandoff(manifest, storyboard, config) { + const adapterHints = buildHandoffRecommendations({ + assets: manifest.assets, + config, + context: { storyboardDemoCount: storyboard.demos.length }, + }); + manifest.handoff.adapterHints = adapterHints; + manifest.handoff.review = handoffReview(storyboard.storyboardLint, manifest.run, manifest.assets); + manifest.handoff.summary = { + assetCount: manifest.assets.length, + demoCount: storyboard.demos.length, + readyAdapterCount: adapterHints.filter((hint) => hint.readiness === 'ready').length, + }; +} + +function buildHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewports, demoWarnings, flags, run = {} }) { const generatedAt = new Date().toISOString(); const project = readProjectInfo(cwd); - const adapterHints = buildHandoffRecommendations({ assets, config }); + const runInfo = { + id: crypto.randomUUID(), + mode: run.mode || 'full', + requestedScenes: run.requestedScenes || [], + video: run.video !== false, + noBuild: !!run.noBuild, + mp4: !!run.mp4, + configuredDemos: run.configuredDemos || [], + selectedDemos: run.selectedDemos || [], + capturedDemos: run.capturedDemos || [], + skippedDemos: run.skippedDemos || [], + }; + const currentAssets = assets.map((asset) => ({ + ...asset, + runId: runInfo.id, + capturedAt: generatedAt, + state: 'produced', + })); const storyboard = { $schema: HANDOFF_SCHEMA_IDS.storyboard, kind: HANDOFF_KINDS.storyboard, @@ -188,10 +262,14 @@ function buildHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewpo project, outDir: rel(cwd, outDir), flags, + run: runInfo, positioning: 'capture-and-handoff-kit', + category: 'agent-ready-launch-asset-pipeline', handoff: { contractVersion: HANDOFF_VERSION, + entrypoint: 'shotkit-manifest.json', schemas: HANDOFF_SCHEMA_IDS, + schemaFiles: HANDOFF_SCHEMA_FILES, storyboards: 'storyboard.json', captions: 'captions.json', recommendedFlow: [ @@ -199,42 +277,47 @@ function buildHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewpo 'polish in Screen Studio, Canva, Supademo, or another editor', 'keep repo fixtures and storyboard as the repeatable source of truth', ], - adapterHints, + adapterHints: [], + review: handoffReview(storyboard.storyboardLint, runInfo), + summary: { + assetCount: currentAssets.length, + demoCount: storyboard.demos.length, + readyAdapterCount: 0, + }, }, - assets, + assets: currentAssets, config: { disclaimer: config.disclaimer || null, description: config.description || null, }, }; + refreshManifestHandoff(manifest, storyboard, config); return { storyboard, captions, manifest }; } -function writeJson(filePath, data) { - fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`); +function compatiblePreviousPack(previous, current) { + const { manifest, storyboard, captions } = previous; + if (!manifest || !storyboard || !captions) return false; + if (!isValidHandoffDocs(previous)) return false; + if (manifest.kind !== HANDOFF_KINDS.manifest || manifest.version !== HANDOFF_VERSION) return false; + if (storyboard.kind !== HANDOFF_KINDS.storyboard || storyboard.version !== HANDOFF_VERSION) return false; + if (captions.kind !== HANDOFF_KINDS.captions || captions.version !== HANDOFF_VERSION) return false; + if (!Array.isArray(manifest.assets) || !Array.isArray(storyboard.demos) + || !Array.isArray(storyboard.storyboardLint) || !Array.isArray(captions.demos)) return false; + if (!namesMatch(storyboard.demos, captions.demos)) return false; + const previousProject = manifest.project && manifest.project.name; + const currentProject = current.manifest.project && current.manifest.project.name; + return !previousProject || !currentProject || previousProject === currentProject; } -function readJsonIfExists(filePath) { - try { - return fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, 'utf8')) : null; - } catch (_e) { - return null; - } -} - -// Union by key, with the current run's entries winning — preserves prior -// entries this run did not touch, so a partial re-run does not clobber them. -function mergeByKey(prev, next, keyOf) { - if (!Array.isArray(prev) || !prev.length) return next; - const nextKeys = new Set(next.map(keyOf)); - const kept = prev.filter((item) => item && !nextKeys.has(keyOf(item))); - return [...kept, ...next]; -} - -function writeHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewports, demoWarnings, flags, partial = false }) { +function writeHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewports, demoWarnings, flags, partial = false, run = {} }) { const storyboardPath = path.join(outDir, 'storyboard.json'); const captionsPath = path.join(outDir, 'captions.json'); const manifestPath = path.join(outDir, 'shotkit-manifest.json'); + const refreshedAssetKeys = new Set(assets.map((asset) => ( + (asset.source && asset.source.name) || asset.name + ))); + const schemaPaths = copyHandoffSchemas(outDir, HANDOFF_SCHEMA_FILES); const contractAssets = [ assetRecord({ cwd, outDir, filePath: storyboardPath, @@ -251,6 +334,15 @@ function writeHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewpo name: 'shotkit-manifest', type: 'json', role: 'handoff-manifest', source: { kind: 'handoff' }, }), + ...schemaPaths.map((filePath) => assetRecord({ + cwd, + outDir, + filePath, + name: path.basename(filePath, path.extname(filePath)), + type: 'json', + role: 'handoff-schema', + source: { kind: 'handoff-schema' }, + })), ]; const docs = buildHandoffDocs({ cwd, @@ -261,6 +353,7 @@ function writeHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewpo demoViewports, demoWarnings, flags, + run: { ...run, mode: partial ? 'partial' : 'full' }, }); // A partial run (scene filter or --no-video) only re-captures a subset, so // merge into the existing contract instead of overwriting a prior full run's @@ -269,21 +362,35 @@ function writeHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewpo const prevStoryboard = readJsonIfExists(storyboardPath); const prevCaptions = readJsonIfExists(captionsPath); const prevManifest = readJsonIfExists(manifestPath); - if (prevStoryboard) { + const previous = { manifest: prevManifest, storyboard: prevStoryboard, captions: prevCaptions }; + if (compatiblePreviousPack(previous, docs)) { docs.storyboard.demos = mergeByKey(prevStoryboard.demos, docs.storyboard.demos, (d) => d.name); docs.storyboard.storyboardLint = mergeByKey(prevStoryboard.storyboardLint, docs.storyboard.storyboardLint, (l) => l.name); - } - if (prevCaptions) { docs.captions.demos = mergeByKey(prevCaptions.demos, docs.captions.demos, (d) => d.name); - } - if (prevManifest) { - docs.manifest.assets = mergeByKey(prevManifest.assets, docs.manifest.assets, (a) => a.id); + const previousRunId = prevManifest.run && prevManifest.run.id + ? prevManifest.run.id + : `legacy:${prevManifest.generatedAt}`; + const previousAssets = (prevManifest.assets || []) + .filter((asset) => !refreshedAssetKeys.has((asset.source && asset.source.name) || asset.name)) + .map((asset) => ({ + ...asset, + runId: asset.runId || previousRunId, + capturedAt: asset.capturedAt || prevManifest.generatedAt, + state: 'retained', + })); + docs.manifest.assets = mergeByKey(previousAssets, docs.manifest.assets, (a) => a.id); } } writeJson(storyboardPath, docs.storyboard); writeJson(captionsPath, docs.captions); + // Partial runs can inherit an older inventory. Prune entries whose files no + // longer exist, then recompute recommendations from the actual final bundle. + docs.manifest.assets = hydrateManifestAssets(docs.manifest.assets, outDir, manifestPath); + refreshManifestHandoff(docs.manifest, docs.storyboard, config); + validateHandoffDocs(docs); + validateFinalPack(docs, outDir, manifestPath); writeJson(manifestPath, docs.manifest); - return [storyboardPath, captionsPath, manifestPath]; + return [storyboardPath, captionsPath, manifestPath, ...schemaPaths]; } module.exports = { diff --git a/src/index.js b/src/index.js index 98eb5bf..c5d5dea 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,10 @@ /* * shotkit — public API. * - * shotkit drives a BUILT browser extension (or any HTML) with Playwright and - * captures store/social assets: screenshots, promo images, and captioned demo - * screencasts. One engine, used via the CLI (`shotkit`), programmatically - * (`capture()`), or through agent-readable docs/skills. + * shotkit turns a BUILT browser extension (or any HTML) into source launch + * assets plus a schema-backed handoff pack for agents and downstream editors. + * Playwright is the capture engine; the product boundary is exposed through + * the CLI (`shotkit`), `capture()`, and agent-readable docs/skills. * * Config authors typically use `capture` indirectly (via the CLI) and import * the helpers below inside their `shotkit.config.js` to set up scenes: diff --git a/src/integrations.js b/src/integrations.js index de64d7b..236c657 100644 --- a/src/integrations.js +++ b/src/integrations.js @@ -77,7 +77,7 @@ const DEFAULT_TARGETS = Object.freeze([ function roleMap(assets = []) { const map = new Map(); for (const asset of assets) { - if (!asset || !asset.role) continue; + if (!asset || !asset.role || asset.state === 'modified') continue; if (!map.has(asset.role)) map.set(asset.role, []); map.get(asset.role).push(asset); } @@ -108,8 +108,11 @@ function targetAllowed(target, targetConfig) { return targetConfig.include.size === 0 || targetConfig.include.has(target.id); } -function readinessFor(target, byRole) { +function readinessFor(target, byRole, context = {}) { const missingRoles = target.requiredRoles.filter((role) => !byRole.has(role)); + if (target.requiredRoles.includes('storyboard-contract') && context.storyboardDemoCount === 0) { + missingRoles.push('storyboard-content'); + } if (missingRoles.length) return { readiness: 'needs-assets', confidence: 'low', missingRoles }; if (target.extraInputs && target.extraInputs.length) { return { readiness: 'needs-input', confidence: 'medium', missingRoles: [] }; @@ -121,14 +124,14 @@ function readinessFor(target, byRole) { return { readiness: 'ready', confidence: hasClip ? 'high' : 'medium', missingRoles: [] }; } -function buildHandoffRecommendations({ assets = [], config = {} } = {}) { +function buildHandoffRecommendations({ assets = [], config = {}, context = {} } = {}) { const byRole = roleMap(assets); const targetConfig = normalizeTargetConfig(config); const recommendations = []; for (const target of DEFAULT_TARGETS) { if (!targetAllowed(target, targetConfig)) continue; - const { readiness, confidence, missingRoles } = readinessFor(target, byRole); + const { readiness, confidence, missingRoles } = readinessFor(target, byRole, context); const useRoles = [...target.requiredRoles, ...(target.optionalRoles || [])]; recommendations.push({ id: target.id, diff --git a/test/capture-description.test.js b/test/capture-description.test.js index d767722..230ea1c 100644 --- a/test/capture-description.test.js +++ b/test/capture-description.test.js @@ -73,6 +73,7 @@ test('capture writes listing and privacy worksheet from product manifest', async expect(result.produced).toContain(descriptionPath); expect(result.produced).toContain(privacyPath); + expect(result.manifest).toBe(manifestPath); expect(fs.readFileSync(descriptionPath, 'utf8')).toContain('Demo Extension'); expect(fs.readFileSync(privacyPath, 'utf8')).toContain('Privacy disclosure worksheet'); @@ -107,6 +108,9 @@ test('description-only scene does not run build, prepareExtension, or Chromium', 'storyboard.json', 'captions.json', 'shotkit-manifest.json', + 'shotkit-manifest.schema.json', + 'storyboard.schema.json', + 'captions.schema.json', ]); expect(prepareExtension).not.toHaveBeenCalled(); expect(launchWithExtension).not.toHaveBeenCalled(); diff --git a/test/cli-runner.test.js b/test/cli-runner.test.js index ff2968f..97b4ac8 100644 --- a/test/cli-runner.test.js +++ b/test/cli-runner.test.js @@ -25,7 +25,11 @@ describe('runCli', () => { const stderr = streamBuffer(); const capture = jest.fn(async (_config, opts) => { opts.log('capturing'); - return { outDir: path.join(cwd, 'store-assets'), produced: [path.join(cwd, 'store-assets', 'a.png')] }; + return { + outDir: path.join(cwd, 'store-assets'), + manifest: path.join(cwd, 'store-assets', 'shotkit-manifest.json'), + produced: [path.join(cwd, 'store-assets', 'a.png')], + }; }); const code = await runCli(['--json'], { @@ -41,6 +45,7 @@ describe('runCli', () => { expect(JSON.parse(stdout.read())).toEqual({ ok: true, outDir: path.join(cwd, 'store-assets'), + manifest: path.join(cwd, 'store-assets', 'shotkit-manifest.json'), produced: [path.join(cwd, 'store-assets', 'a.png')], }); expect(stderr.read()).toContain('[shotkit] capturing'); diff --git a/test/handoff.test.js b/test/handoff.test.js index c0e95d0..0cf2595 100644 --- a/test/handoff.test.js +++ b/test/handoff.test.js @@ -102,8 +102,18 @@ describe('handoff contract', () => { project: { name: 'demo-ext', version: '1.0.0', private: true }, handoff: { contractVersion: HANDOFF_VERSION, + entrypoint: 'shotkit-manifest.json', + schemaFiles: { + manifest: 'schemas/shotkit-manifest.schema.json', + storyboard: 'schemas/storyboard.schema.json', + captions: 'schemas/captions.schema.json', + }, storyboards: 'storyboard.json', captions: 'captions.json', + review: { + status: 'needs-review', + warningCount: 1, + }, }, }); expect(docs.manifest.handoff.adapterHints.map((item) => item.id)).toContain('screen-studio'); @@ -136,13 +146,219 @@ describe('handoff contract', () => { flags: {}, }); - expect(paths.map((p) => path.basename(p))).toEqual(['storyboard.json', 'captions.json', 'shotkit-manifest.json']); + expect(paths.map((p) => path.relative(outDir, p))).toEqual([ + 'storyboard.json', + 'captions.json', + 'shotkit-manifest.json', + 'schemas/shotkit-manifest.schema.json', + 'schemas/storyboard.schema.json', + 'schemas/captions.schema.json', + ]); for (const filePath of paths) expect(fs.existsSync(filePath)).toBe(true); const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'shotkit-manifest.json'), 'utf8')); expect(manifest.assets.map((asset) => asset.role)).toEqual([ 'storyboard-contract', 'captions-contract', 'handoff-manifest', + 'handoff-schema', + 'handoff-schema', + 'handoff-schema', ]); + expect(manifest.handoff.summary).toEqual({ + assetCount: 6, + demoCount: 0, + readyAdapterCount: 0, + }); + const storyboard = manifest.assets.find((asset) => asset.role === 'storyboard-contract'); + expect(storyboard.bytes).toBeGreaterThan(0); + expect(storyboard.integrity).toMatchObject({ algorithm: 'sha256' }); + expect(storyboard.integrity.digest).toMatch(/^[a-f0-9]{64}$/); + }); + + test('partial runs prune missing files and recompute adapter readiness from preserved assets', () => { + const { cwd, outDir } = tmpProject(); + const mp4Path = path.join(outDir, 'demo.mp4'); + fs.writeFileSync(mp4Path, 'video-proof'); + const mp4 = assetRecord({ + cwd, + outDir, + filePath: mp4Path, + name: 'demo', + type: 'video', + role: 'sns-demo-mp4', + source: { kind: 'demo', name: 'demo' }, + }); + + const write = (partial) => writeHandoffDocs({ + cwd, + outDir, + config: {}, + assets: partial ? [] : [mp4], + demoConfigs: [], + demoViewports: {}, + demoWarnings: {}, + flags: {}, + partial, + }); + + write(false); + const firstManifest = JSON.parse(fs.readFileSync(path.join(outDir, 'shotkit-manifest.json'), 'utf8')); + const originalRunId = firstManifest.assets.find((asset) => asset.id === mp4.id).runId; + write(true); + let manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'shotkit-manifest.json'), 'utf8')); + expect(manifest.assets.some((asset) => asset.id === mp4.id)).toBe(true); + expect(manifest.assets.find((asset) => asset.id === mp4.id)).toMatchObject({ + runId: originalRunId, + state: 'retained', + }); + expect(manifest.run.id).not.toBe(originalRunId); + expect(manifest.handoff.adapterHints.find((hint) => hint.id === 'screen-studio').readiness).toBe('ready'); + + fs.writeFileSync(mp4Path, 'tampered-video-proof'); + write(true); + manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'shotkit-manifest.json'), 'utf8')); + expect(manifest.assets.find((asset) => asset.id === mp4.id)).toMatchObject({ + state: 'modified', + observed: { integrity: { algorithm: 'sha256' } }, + }); + expect(manifest.handoff.review).toMatchObject({ + status: 'incomplete', + incomplete: [{ code: 'asset-integrity-mismatch', asset: mp4.id }], + }); + expect(manifest.handoff.adapterHints.find((hint) => hint.id === 'screen-studio').readiness).toBe('needs-assets'); + + fs.rmSync(mp4Path); + write(true); + manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'shotkit-manifest.json'), 'utf8')); + expect(manifest.assets.some((asset) => asset.id === mp4.id)).toBe(false); + expect(manifest.handoff.adapterHints.find((hint) => hint.id === 'screen-studio').readiness).toBe('needs-assets'); + }); + + test('a refreshed logical output replaces all retained formats from that source', () => { + const { cwd, outDir } = tmpProject(); + const mp4Path = path.join(outDir, 'demo.mp4'); + const webmPath = path.join(outDir, 'demo.webm'); + fs.writeFileSync(mp4Path, 'old-mp4'); + const record = (filePath, role) => assetRecord({ + cwd, + outDir, + filePath, + name: 'demo', + type: 'video', + role, + source: { kind: 'demo', name: 'demo' }, + }); + const write = (assets, partial) => writeHandoffDocs({ + cwd, + outDir, + config: {}, + assets, + demoConfigs: [], + demoViewports: {}, + demoWarnings: {}, + flags: {}, + partial, + }); + + write([record(mp4Path, 'sns-demo-mp4')], false); + fs.writeFileSync(webmPath, 'fresh-webm'); + write([record(webmPath, 'source-demo-webm')], true); + + const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'shotkit-manifest.json'), 'utf8')); + expect(manifest.assets.some((asset) => asset.outPath === 'demo.mp4')).toBe(false); + expect(manifest.assets.find((asset) => asset.outPath === 'demo.webm')).toMatchObject({ state: 'produced' }); + }); + + test('does not merge a prior pack that fails its published schema', () => { + const { cwd, outDir } = tmpProject(); + const mp4Path = path.join(outDir, 'demo.mp4'); + fs.writeFileSync(mp4Path, 'prior-video'); + const mp4 = assetRecord({ + cwd, + outDir, + filePath: mp4Path, + name: 'demo', + type: 'video', + role: 'sns-demo-mp4', + source: { kind: 'demo', name: 'demo' }, + }); + const args = { + cwd, + outDir, + config: {}, + demoConfigs: [], + demoViewports: {}, + demoWarnings: {}, + flags: {}, + }; + writeHandoffDocs({ ...args, assets: [mp4] }); + const storyboardPath = path.join(outDir, 'storyboard.json'); + const storyboard = JSON.parse(fs.readFileSync(storyboardPath, 'utf8')); + storyboard.purpose = 'not-a-shotkit-purpose'; + fs.writeFileSync(storyboardPath, JSON.stringify(storyboard)); + + writeHandoffDocs({ ...args, assets: [], partial: true }); + + const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'shotkit-manifest.json'), 'utf8')); + expect(manifest.assets.some((asset) => asset.id === mp4.id)).toBe(false); + }); + + test('rejects duplicate asset ids or output paths before publishing the manifest', () => { + const { cwd, outDir } = tmpProject(); + const filePath = path.join(outDir, 'same.png'); + fs.writeFileSync(filePath, 'image'); + const shared = { + cwd, + outDir, + filePath, + name: 'same', + type: 'image', + source: { kind: 'scene', name: 'same' }, + }; + expect(() => writeHandoffDocs({ + cwd, + outDir, + config: {}, + assets: [ + assetRecord({ ...shared, role: 'store-screenshot' }), + assetRecord({ ...shared, role: 'promo-tile' }), + ], + demoConfigs: [], + demoViewports: {}, + demoWarnings: {}, + flags: {}, + })).toThrow(/duplicate asset outPath/); + }); + + test('a fresh no-video run reports configured demos as incomplete', async () => { + const { cwd } = tmpProject(); + const { capture } = require('../src/capture'); + const result = await capture({ + demo: { + name: 'demo-launch', + captions: [{ at: 1, text: 'Show the result' }], + run: async () => {}, + }, + }, { + cwd, + noBuild: true, + noVideo: true, + log: () => {}, + }); + + const manifest = JSON.parse(fs.readFileSync(result.manifest, 'utf8')); + expect(manifest.run).toMatchObject({ + mode: 'partial', + configuredDemos: ['demo-launch'], + selectedDemos: ['demo-launch'], + capturedDemos: [], + skippedDemos: ['demo-launch'], + }); + expect(manifest.handoff.review).toMatchObject({ + status: 'incomplete', + incomplete: [{ code: 'demo-skipped', demo: 'demo-launch', reason: 'video-disabled' }], + }); + const supademo = manifest.handoff.adapterHints.find((hint) => hint.id === 'supademo'); + expect(supademo).toMatchObject({ readiness: 'needs-assets', missingRoles: ['storyboard-content'] }); }); }); diff --git a/test/schema-validation.test.js b/test/schema-validation.test.js index eda991d..f0f794e 100644 --- a/test/schema-validation.test.js +++ b/test/schema-validation.test.js @@ -100,4 +100,24 @@ describe('handoff docs conform to the published JSON schemas', () => { expect(tc.captions).toHaveLength(1); expect(tc.captions[0].atMs).toBe(2000); }); + + it('keeps additive manifest fields compatible with the original v1 shape', () => { + const legacy = JSON.parse(JSON.stringify(docs.manifest)); + delete legacy.category; + delete legacy.run; + delete legacy.handoff.entrypoint; + delete legacy.handoff.schemaFiles; + delete legacy.handoff.review; + delete legacy.handoff.summary; + for (const asset of legacy.assets) { + delete asset.runId; + delete asset.capturedAt; + delete asset.state; + delete asset.bytes; + delete asset.integrity; + } + const schema = loadSchema('shotkit-manifest.schema.json'); + const validate = ajv.getSchema(schema.$id) || ajv.compile(schema); + expect(validate(legacy)).toBe(true); + }); }); diff --git a/test/schema.test.js b/test/schema.test.js index 37c18be..47ebe02 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -18,4 +18,13 @@ describe('packaged handoff schemas', () => { expect(readSchema('storyboard.schema.json').properties.kind.const).toBe('shotkit.storyboard'); expect(readSchema('captions.schema.json').properties.kind.const).toBe('shotkit.captions'); }); + + test('v1 keeps its constants and adds the agent-ready category compatibly', () => { + expect(readSchema('shotkit-manifest.schema.json').properties.positioning.const) + .toBe('capture-and-handoff-kit'); + expect(readSchema('shotkit-manifest.schema.json').properties.category.const) + .toBe('agent-ready-launch-asset-pipeline'); + expect(readSchema('storyboard.schema.json').properties.purpose.const) + .toBe('browser-extension-demo-starter-pack'); + }); }); From a025d74f19d2c8eee8f76a4c4cf0dddca14d8893 Mon Sep 17 00:00:00 2001 From: heznpc Date: Fri, 10 Jul 2026 13:20:02 +0900 Subject: [PATCH 05/13] feat: automate publish-ready channel variants --- AGENTS.md | 40 +++-- CHANGELOG.md | 15 +- README.ko.md | 67 +++++--- README.md | 107 ++++++++----- docs/handoff-conventions.md | 76 +++++---- package-lock.json | 18 ++- package.json | 12 +- schemas/captions.schema.json | 2 + schemas/shotkit-manifest.schema.json | 157 ++++++++++++++++++- schemas/storyboard.schema.json | 15 ++ skills/capture/SKILL.md | 90 +++++++---- src/capture.js | 72 ++++++--- src/channels.js | 122 +++++++++++++++ src/cli-runner.js | 4 +- src/cli.js | 31 +++- src/demo.js | 13 +- src/handoff.js | 49 +++++- src/image-qa.js | 47 ++++++ src/index.js | 13 +- src/presets.js | 1 + src/publish.js | 226 +++++++++++++++++++++++++++ src/video.js | 75 ++++++++- test/capture-lifecycle.test.js | 50 ++++++ test/cli-runner.test.js | 2 + test/cli.test.js | 17 +- test/demo.test.js | 45 ++++++ test/handoff.test.js | 85 ++++++++++ test/image-qa.test.js | 43 +++++ test/package-boundary.test.js | 3 + test/presets.test.js | 2 + test/publish.test.js | 200 ++++++++++++++++++++++++ test/video.test.js | 21 +++ 32 files changed, 1536 insertions(+), 184 deletions(-) create mode 100644 src/channels.js create mode 100644 src/image-qa.js create mode 100644 src/publish.js create mode 100644 test/image-qa.test.js create mode 100644 test/publish.test.js diff --git a/AGENTS.md b/AGENTS.md index 0efc4b7..b044c8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # shotkit -An agent-ready launch asset capture and handoff pipeline for browser extensions. -Playwright drives the shipped product; the `shotkit` CLI, programmatic +An autonomous publish-ready launch asset pipeline 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. @@ -23,7 +23,9 @@ 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 `status`: `publish-ready`, `needs-fix`, `blocked`, or +`not-requested`. Agents own `needs-fix` actions and retry without user review. Every run also writes `storyboard.json`, `captions.json`, and `shotkit-manifest.json` unless `handoff:false` is set in config. @@ -37,11 +39,15 @@ src/ 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) + 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) @@ -68,14 +74,13 @@ test/ → unit tests for the pure/safe modules (no browser) disclaimer badge stays top-left. Keep this lightweight: one `demo` or several `demos[]` entries, timed captions, `demo.caption/step/wait/click`, 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. +- **Handoff JSON is the machine boundary**: target workflows use + `handoff.automation` to fix and retry until `publish-ready`; users do not read + manifests or review media. 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. - **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. @@ -120,8 +125,11 @@ 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. +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. +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[]`. Do not propose iMovie, manual recapture, or media +review unless the user explicitly requests fallback editing. diff --git a/CHANGELOG.md b/CHANGELOG.md index 354021a..a8c4b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] ### Added +- 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`. +- 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 three JSON Schemas, exposes their manifest-relative paths, and records byte size plus SHA-256 integrity for each delivered file except the self-referential manifest. @@ -19,8 +28,10 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. - 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 agent-ready launch asset capture and - handoff pipeline; Playwright remains the implementation mechanism. +- Public messaging now leads with the autonomous publish-ready launch asset + pipeline; Playwright remains the implementation mechanism. +- Target workflows no longer route routine work to human review or manual + editors. Manual adapter hints require `automation.manualFallback:true`. ### Fixed - `step(text, fn, options)` now honors flat caption display options (e.g. diff --git a/README.ko.md b/README.ko.md index 1b0779a..bf7b29d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,10 +2,10 @@ # shotkit -**브라우저 익스텐션을 위한 에이전트 친화적 출시 자산 캡처·handoff 파이프라인.** +**브라우저 익스텐션용 게시 가능 자산을 자동 제작하는 파이프라인.** -실제 출하 빌드를 실행해 CWS/SNS 원본 자산과 버전·스키마가 있는 -자기완결형 evidence pack을 만듭니다. +스토리와 채널만 정하면 에이전트가 촬영·편집·검증·재시도를 수행하고, +사람에게는 최종 파일 또는 자동화 소진 후 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) @@ -20,10 +20,10 @@ ## 상태와 범위 (Status & Scope) -- **현재 구현된 것** — Playwright로 *실제 출하 빌드*를 실행하는 캡처 엔진과, 그 결과를 에이전트가 이어받을 수 있게 묶는 출시 자산 **파이프라인**입니다. CWS 스크린샷/프로모 타일, SNS 데모 클립, listing/privacy 문안과 함께 `shotkit-manifest.json`, storyboard, captions, 로컬 schema, 구조화된 review 상태, adapter hint, 파일별 SHA-256을 산출합니다. CLI의 `--json` 계약은 선택적 repo `path`, `0/1/2` 종료 코드, manifest entrypoint를 반환하며, 같은 엔진을 `capture()`, Claude Code skill, AGENTS.md 실행 블록에서도 사용합니다. +- **현재 구현된 것** — Playwright로 *실제 출하 빌드*를 실행하고 하나의 story를 `cws-youtube`, `x`, `youtube-shorts` variant로 확장합니다. target별 viewport/H.264/trim/caption/thumbnail을 자동 적용하고, 최종 MP4를 ffprobe로 검사하며, thumbnail 픽셀의 blank-frame 여부까지 확인해 `publish-ready`, `needs-fix`, `blocked`를 산출합니다. manifest에는 에이전트가 실행할 retry action과 source evidence가 함께 남습니다. - **스토리 렌더러** — 데모 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 캠페인 컷으로 바꾸기 쉬운 정도까지만 제공합니다. - **설계 의도** — *엔진 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일 때만 노출합니다. - **공개하지 않음** — 없음. ## 설치 @@ -59,6 +59,8 @@ npx shotkit ```bash shotkit # outDir에 전부 산출 shotkit --scene 01-feature # 특정 scene/타일/데모/demos 항목 또는 "description"만 +shotkit --target x # 설정된 X variant만 제작/재시도 +shotkit --attempt 2 --json # 두 번째 자동 수정 시도 shotkit --no-video # 스크린캐스트 생략 shotkit --no-build # 이미 빌드된 번들 사용 shotkit ../my-extension --json # 다른 체크아웃 대상 실행; 결과 JSON을 stdout에 @@ -68,28 +70,20 @@ shotkit ../my-extension --json # 다른 체크아웃 대상 실행; 결과 JSON ### 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` — entrypoint. asset inventory/integrity, 실행·freshness - metadata, review 요약, 로컬 schema path, 다음 도구 후보 `adapterHints`. + metadata, 로컬 schema path와 `handoff.automation`의 target 검사/retry action. - `schemas/*.schema.json` — 설치된 npm 패키지 없이도 검증할 수 있도록 모든 pack에 함께 복사되는 계약. -이렇게 하면 에이전트나 MCP connector가 manifest를 읽고 mp4/webm, -thumbnail, captions를 Screen Studio, Canva, Supademo 또는 다른 편집 도구로 -넘기기 쉽습니다. repo fixture와 storyboard는 반복 가능한 source of truth로 -남습니다. - -manifest는 downstream 연결 후보도 제안합니다. 예를 들어 thumbnail/storyboard -재료가 충분하면 `figma-mcp`가 나오고, AI video campaign variant에는 -`higgsfield`, avatar/presenter 계열에는 `longcat-video-avatar`가 나옵니다. -추가 입력이 필요한 경우 `needs-input`으로 표시됩니다. shotkit은 다음 도구를 -제안하고, 실제 연결은 에이전트의 MCP/tool 환경이 수행합니다. +target workflow에서는 수동 editor hint를 기본으로 숨기며, +`automation.manualFallback:true`일 때만 명시적으로 다시 노출합니다. handoff 규약은 버전과 schema를 갖습니다. `$schema` 값은 안정적인 URN 식별자이고, `handoff.schemaFiles`가 output pack 안의 실제 schema로 연결합니다. @@ -101,6 +95,28 @@ handoff 규약은 버전과 schema를 갖습니다. `$schema` 값은 안정적 프로젝트별 적용 계획 문서는 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`로 사용자 입력을 요청합니다. + ### CWS 자산과 SNS 데모 클립 Chrome Web Store 자산은 검사 가능한 표면입니다. 선명한 스크린샷, 프로모 @@ -158,7 +174,7 @@ demo: { crop: { x: 120, y: 0, width: 1040, height: 720 }, zoom: { scale: 1.08 }, thumbnail: { at: 1.5 }, - storyboardLint: false, + storyboardLint: false, // legacy/짧은 smoke clip 전용 } ``` @@ -168,6 +184,8 @@ storyboard lint는 기본으로 켜져 있으며 실패 대신 warning을 남깁 쉽습니다. mp4 누락, 3초 이후 첫 캡션, 홀수 영상 크기, 너무 긴 캡션, safety/restore beat 누락, crop/zoom edge risk, 20~40초 바깥 trim을 잡아줍니다. +자동 channel target은 lint가 켜져 있어야 하며, `storyboardLint:false`이면 +automation status가 `needs-fix`가 됩니다. 여러 홍보 컷이 필요하면 단일 `demo` 대신 `demos: []`를 쓰십시오. 각 항목은 `.webm`과 선택적 `.mp4`를 만들고, `--scene `으로 하나만 @@ -205,11 +223,12 @@ result → safety/restore, 짧은 캡션, 느린 cursor/click/typing, X용 mp4 ### 에이전트 계약 (`--json`) `shotkit [path] --json`은 stdout에 **정확히 하나의 JSON 객체**를 출력합니다 -(진행 로그는 stderr로 이동): `{ "ok": true, "outDir": …, "manifest": …, "produced": [절대경로…] }`. +(진행 로그는 stderr로 이동): `{ "ok": true, "status": "publish-ready", "outDir": …, "manifest": …, "produced": [절대경로…] }`. 종료 코드: `0` 정상 · `1` 런타임 실패 · `2` 사용법 오류/설정 없음입니다. -`ok:true`는 요청 단계가 끝나고 파일이 기록됐다는 뜻이며, 채널 완성도·시각 -승인·법률 준수·외부 connector 가용성을 인증하지는 않습니다. 반환된 manifest의 -`handoff.review`와 `handoff.adapterHints[]`에서 다음 판단을 이어갑니다. 실패 +`ok:true`는 실행 완료, `status:publish-ready`는 story lint, H.264/yuv420p, +실제 해상도·길이, thumbnail, nonblank-frame, integrity와 target profile 검사를 +통과했다는 뜻입니다. 외부 업로드 완료를 뜻하지는 않으며 권한 있는 connector나 +외부 쓰기 승인은 별도로 필요합니다. 실패 payload도 stdout의 단일 JSON 객체(`{"ok":false,"error":…}`)를 사용합니다. 에이전트 연결은 [`AGENTS.md`](AGENTS.md) 실행 블록 (Claude Code·Codex·Cursor·Gemini CLI 등이 읽음)과 [`skills/capture/`](skills/capture/SKILL.md) skill(Agent Skills 표준 — 호환 도구의 skills 디렉터리에 폴더째 복사)을 참고하십시오. diff --git a/README.md b/README.md index 11af8d4..244b4e2 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ # shotkit -**Agent-ready launch asset capture and handoff for browser extensions.** +**Autonomous, publish-ready launch assets for browser extensions.** -Build the shipped extension. Produce CWS/SNS source assets. Hand off a versioned, -self-contained evidence pack. +Name the story and channels. Agents capture, edit, validate, and retry. Humans +only see final target files or a blocker after automation is exhausted. [![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) @@ -22,10 +22,10 @@ self-contained evidence pack. ## Status & Scope -- **Currently implemented** — An agent-ready launch asset **pipeline** whose capture engine builds and launches the *shipped* extension via Playwright, drives repo-owned scenes, and emits CWS screenshots/promo tiles, SNS demo clips, listing/privacy copy, plus a schema-backed evidence pack (`shotkit-manifest.json`, storyboard, captions, bundled schemas, structured review state, adapter hints, and per-file SHA-256). Its **CLI agent contract** provides `--json`, an optional repo `path`, explicit `0/1/2` exit codes, and the manifest entrypoint. The same engine is available through `capture()`, the Claude Code plugin + skill ([`skills/capture/`](skills/capture/SKILL.md); `/plugin install shotkit@starter-series`), and an AGENTS.md run-block. SNS post-processing covers H.264 mp4, trim, static crop/zoom, and thumbnails with a real ffmpeg. +- **Currently implemented** — An autonomous launch asset **pipeline** whose Playwright engine builds and drives the *shipped* extension, expands one story into `cws-youtube`, `x`, and `youtube-shorts` variants, applies target viewport/H.264/trim/caption/thumbnail defaults, probes the final MP4 with ffprobe, checks the poster pixels for blank-frame failures, and emits `publish-ready`, `needs-fix`, or `blocked`. The schema-backed pack still carries source evidence, captions, run provenance, integrity, and agent-owned retry actions. The same engine is exposed through the CLI, `capture()`, skill, and AGENTS.md run-block. - **Story renderer** — Demo configs can use one `demo` or several `demos: []` entries, timed `captions`, pointer-highlighted clicks, paced cursor movement, static zoom/crop framing, thumbnail frames, storyboard lint, and a small `demo` helper (`caption`, `step`, `wait`, `click`) so an agent can turn a feature checklist into 20-40 second before → action → result stories without pulling in a general video editor. - **Design intent** — *One engine, many surfaces — matched to the tool's nature.* shotkit is a heavy, file-producing build tool, so its surfaces are CLI (+`--json`), skill, and CI — not MCP (see Non-goals). Captures are **deterministic** (login-free fixtures, frozen data) and the run **doubles as a real-bundle smoke test** — a screenshot only appears if that feature rendered from the shipped code. **Trademark-safe** by construction: a disclaimer band is composited onto every shot. -- **Non-goals** — An **MCP server** inside shotkit (dropped by design: agents with a shell get a better contract from `--json` + the skill). Removing the per-repo **scene config** (which screens are *your* money shots is irreducible intent — it lives in your `shotkit.config.js`). A general-purpose video editor or hosted demo platform. shotkit creates source evidence and a handoff pack; Screen Studio, Canva, Supademo, or future MCP connectors can do polish later. +- **Non-goals** — An **MCP server** inside shotkit (agents with a shell get a better contract from `--json` + the skill). Removing the per-repo **story/action config** (which product state proves the claim is irreducible intent). A general-purpose timeline editor or hosted demo platform. Repeatable channel work is automated; manual editors are fallback-only and disabled unless explicitly requested. - **Redacted** — none. Ships no private data, credentials, or third-party identifiers. ## Install @@ -68,6 +68,8 @@ Add a `shotkit.config.js` (the per-repo capture contract), then: ```bash shotkit # produce everything into outDir shotkit --scene 01-feature # just one scene/promoTile/demo/demos entry, "description", or "privacy" +shotkit --target x # render/retry only the configured X variants +shotkit --attempt 2 --json # next autonomous fix attempt shotkit --no-video # skip the screencast (faster/CI) shotkit --no-build # use an already-built bundle shotkit ../my-extension --json # run against another checkout; JSON result on stdout @@ -77,30 +79,24 @@ Outputs land in `outDir` (default `store-assets/`): `.png`, `. ### Handoff Pack -shotkit is not trying to beat video editors. It is the starter layer before -them: capture the real built extension, write source clips, and describe what -the clips mean. +The handoff pack is primarily an internal machine boundary: agents use it to +fix and retry until channel assets are publish-ready. It is not a request for a +human to inspect JSON or edit media. - `storyboard.json` — demo names, audience, viewport, trim/framing hints, beats, structured storyboard lint warnings, and suggested next tool. - `captions.json` — portable caption timings and text per demo. - `shotkit-manifest.json` — the entrypoint: asset inventory and integrity, - run/freshness metadata, review summary, bundled schema paths, and - `adapterHints` for likely next tools. + run/freshness metadata, bundled schema paths, and + `handoff.automation` with target checks and agent-owned retry actions. - `schemas/*.schema.json` — local validation contracts, copied into every pack so a downstream agent does not need the installed npm package. -This makes external polish easier: an agent or MCP connector can read the -manifest, open the mp4/webm + thumbnail + captions in Screen Studio, Canva, -Supademo, or another editor, and keep the repo fixture/storyboard as the source -of truth. - -The manifest also recommends possible downstream connections. For example, -`figma-mcp` appears when the run has enough thumbnail/storyboard material for a -design handoff, `higgsfield` appears for AI-video campaign variants, and -`longcat-video-avatar` is marked as needing extra avatar/voice input when the -captured assets are not enough by themselves. shotkit suggests the next tool; -the agent's own MCP/tool environment performs the connection. +Target workflows omit manual editor recommendations by default. Targetless +legacy runs may still expose `adapterHints`; setting +`automation.manualFallback:true` restores them for an explicitly requested +manual path. Repo fixtures and the story/action script remain the repeatable +source of truth in either mode. The convention is versioned and schema-backed. `$schema` values are stable URN identifiers; `handoff.schemaFiles` resolves them to files inside the output pack. @@ -109,6 +105,38 @@ SHA-256 integrity metadata. See [`docs/handoff-conventions.md`](docs/handoff-conventions.md) and the packaged schemas under [`schemas/`](schemas/). +### Autonomous channel targets + +Keep one product story and declare destinations. Do not set viewport, codec, +thumbnail, or duration mechanically unless a target genuinely needs an +override: + +```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 }) { + // Reusable product actions. target contains the current channel profile. + }, +} +``` + +The story expands to `skillbridge-cws-youtube`, `skillbridge-x`, and +`skillbridge-youtube-shorts`. Landscape targets use 1280×720; Shorts uses +720×1280. All target variants receive H.264/yuv420p, a 30-second cap, a poster +frame, and automated final-file checks. `targetOptions.` is available for a +channel-specific framing override. + +The default policy is exception-only: `needs-fix` actions belong to the agent, +which edits the config and reruns `automation.retryScenes[]` with an incremented +`--attempt`. User input is requested only when `blocked` is reached after +`automation.maxAttempts` (default 3). Manual editor hints appear only with +`automation: { manualFallback: true }`. + Project-specific application plans stay repo-internal and are not included in the npm package. @@ -120,9 +148,9 @@ legible at store dimensions. SNS demo clips are story surfaces: short, captioned walkthroughs that show the result quickly, then the action and safety/restore path. For X demo video, -prefer `preset: 'sns-video'` (`1280×720`, 16:9) plus H.264 mp4 because H.264 -`yuv420p` wants even dimensions. Use `sns-twitter` (`1200×675`) for static X -card images. +the `x` target applies 1280×720 H.264 automatically. The lower-level +`preset:'sns-video'` path remains available for legacy/custom captures. Use +`sns-twitter` (`1200×675`) for static X card images. ### Demo → mp4 / trim (SNS) @@ -197,7 +225,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 } ``` @@ -207,6 +235,8 @@ The same warnings are written to `storyboard.json` with `code`, `severity`, 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. +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 @@ -247,15 +277,16 @@ move cursor/click/typing actions slowly, and use mp4 for X. logs move to stderr): ```json -{ "ok": true, "outDir": "/abs/store-assets", "manifest": "/abs/store-assets/shotkit-manifest.json", "produced": ["/abs/store-assets/01-popup.png"] } +{ "ok": true, "status": "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 · `2` usage / no config found. Failure payloads also use the single stdout JSON object (`{"ok":false,"error":…}`). -`ok:true` means the requested stages completed and the files were written. It -does not certify channel completeness, visual approval, legal compliance, or -availability of a downstream connector; read `handoff.review` and -`handoff.adapterHints[]` from the returned manifest for those next decisions. +`ok:true` means execution completed. `status:publish-ready` additionally means +every requested target passed shotkit's story lint, H.264/yuv420p, actual +dimensions, actual duration, thumbnail, nonblank-frame, integrity, and target +profile checks. It does not mean an external upload occurred; publishing still +requires an authorized connector or explicit external-write approval. 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 — @@ -288,15 +319,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 */ } }, ], }; ``` @@ -305,6 +335,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 @@ -366,7 +398,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` · @@ -381,10 +414,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 | +| Timeline/audio/motion 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/docs/handoff-conventions.md b/docs/handoff-conventions.md index 4139037..c7a73f1 100644 --- a/docs/handoff-conventions.md +++ b/docs/handoff-conventions.md @@ -1,10 +1,10 @@ # shotkit Handoff Conventions -shotkit is the agent-ready launch asset capture and handoff layer for browser -extensions. It should not try to become Screen Studio, Canva, Supademo, or a -hosted demo editor. Instead, it turns the shipped product into repeatable source -evidence and a self-contained contract that agents, 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 +publish-ready. The handoff pack is an internal machine boundary, not a request +for a user to inspect JSON or edit media. ## Files @@ -25,9 +25,8 @@ own copies; resolve `handoff.schemaFiles` relative to the directory containing requirement. shotkit validates the finalized three-document pack with these same schemas before publishing the manifest. -The CLI's `ok:true` only means the requested stages completed and files were -written. It does not certify visual approval, store-policy compliance, channel -completeness, legal compliance, or connector availability. +The CLI's `ok:true` means the requested stages completed. Its separate `status` +is `publish-ready`, `needs-fix`, `blocked`, or `not-requested`. ## Manifest Roles @@ -61,22 +60,46 @@ asset's `runId` matches it and its state is `produced`. A scene-filtered or digest changed becomes `modified`, is excluded from adapter readiness, and must be recaptured. -`handoff.review.status` is `ready` when the captured storyboard has no warnings, -`needs-review` when lint found improvements, and `incomplete` when a selected -demo was skipped or retained evidence changed. This is a quality-review signal, -not launch certification. `handoff.summary` reports asset, demo, and ready- -adapter counts. +`handoff.review` remains an additive v1 compatibility summary. Autonomous +callers use `handoff.automation` instead; `needs-fix` never means "ask the user +to review." `handoff.summary` reports asset, demo, adapter, and publish-ready +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. + +`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; +- 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 `userActionRequired:true`. + +`publish-ready` means these automated checks passed. External publication has +not happened yet; `targets[].upload` identifies the connector and notes that an +authorized external write is required. 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 assets were visually approved. -## 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: @@ -90,17 +113,16 @@ Each hint includes: - `missingRoles` / `missingInputs` — what to capture or provide next. - `nextStep` — the agent-facing action. -Recommended downstream flow: +Autonomous flow: -1. Read the CLI result's `manifest` path and, when needed, validate it with - `handoff.schemaFiles.manifest`. -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 `publish-ready`, use each target's deliverable and upload connector. +4. On `blocked`, report the exhausted blocker and attempted fixes. +5. 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 diff --git a/package-lock.json b/package-lock.json index 7e00926..479c324 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "playwright": "^1.60.0" + "playwright": "^1.60.0", + "pngjs": "^7.0.0" }, "bin": { "shotkit": "bin/shotkit.js" @@ -3830,9 +3831,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": { @@ -4424,6 +4425,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", diff --git a/package.json b/package.json index 4239b7f..61918c9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "shotkit", "version": "1.3.0", - "description": "Agent-ready browser-extension launch asset pipeline for CWS screenshots, social demos, and schema-backed handoff packs.", + "description": "Autonomously capture, validate, and retry publish-ready CWS, X, and YouTube launch assets from browser extensions.", "main": "src/index.js", "exports": { ".": "./src/index.js", @@ -41,6 +41,8 @@ "agent-ready", "json-schema", "product-evidence", + "publish-ready", + "video-automation", "storyboard", "handoff" ], @@ -52,7 +54,8 @@ "dependencies": { "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "playwright": "^1.60.0" + "playwright": "^1.60.0", + "pngjs": "^7.0.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -62,6 +65,7 @@ }, "jest": { "collectCoverageFrom": [ + "src/channels.js", "src/presets.js", "src/describe.js", "src/extension.js", @@ -76,7 +80,9 @@ "src/handoff.js", "src/handoff-files.js", "src/handoff-validator.js", - "src/integrations.js" + "src/image-qa.js", + "src/integrations.js", + "src/publish.js" ], "coverageReporters": [ "text", diff --git a/schemas/captions.schema.json b/schemas/captions.schema.json index 136ede8..1542222 100644 --- a/schemas/captions.schema.json +++ b/schemas/captions.schema.json @@ -19,6 +19,8 @@ "required": ["name", "captions"], "properties": { "name": { "type": "string" }, + "story": { "type": "string" }, + "target": { "type": "string" }, "captions": { "type": "array", "items": { diff --git a/schemas/shotkit-manifest.schema.json b/schemas/shotkit-manifest.schema.json index d209e64..15354a4 100644 --- a/schemas/shotkit-manifest.schema.json +++ b/schemas/shotkit-manifest.schema.json @@ -47,10 +47,17 @@ "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" } @@ -100,6 +107,7 @@ "type": "array", "items": { "$ref": "#/$defs/adapterHint" } }, + "automation": { "$ref": "#/$defs/automation" }, "review": { "type": "object", "required": ["status", "warningCount", "warnings", "incomplete"], @@ -122,7 +130,8 @@ "properties": { "assetCount": { "type": "integer", "minimum": 0 }, "demoCount": { "type": "integer", "minimum": 0 }, - "readyAdapterCount": { "type": "integer", "minimum": 0 } + "readyAdapterCount": { "type": "integer", "minimum": 0 }, + "publishReadyTargetCount": { "type": "integer", "minimum": 0 } } } } @@ -161,6 +170,10 @@ "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"] }, @@ -194,7 +207,147 @@ "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" }, + "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" } + } + }, + "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" } } } } diff --git a/schemas/storyboard.schema.json b/schemas/storyboard.schema.json index 9d2bf2d..cba0b64 100644 --- a/schemas/storyboard.schema.json +++ b/schemas/storyboard.schema.json @@ -37,7 +37,22 @@ "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" }, diff --git a/skills/capture/SKILL.md b/skills/capture/SKILL.md index d30c765..20fe5cf 100644 --- a/skills/capture/SKILL.md +++ b/skills/capture/SKILL.md @@ -1,10 +1,10 @@ --- name: capture -description: Build, inspect, iterate, and hand off an agent-ready browser-extension launch asset pack with shotkit. Use for CWS screenshots, promo/OG images, social demos, listing/privacy evidence, storyboard review, or downstream editor handoff in a repo with 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 publish-ready browser-extension launch assets with shotkit. 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 asking the user to review media; escalate only after automated attempts are exhausted. +allowed-tools: Bash(shotkit*), Bash(node bin/shotkit.js*), Bash(npm run capture:store*), Bash(npm exec -- playwright install chromium), Read, Edit, Write --- -# Build and hand off launch assets with shotkit +# Produce publish-ready 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,34 +12,65 @@ 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 placement. Use `targetOptions.` only when the + shared story genuinely needs target-specific framing. +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 ``` 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": "...", "manifest": "/abs/path/shotkit-manifest.json", "produced": ["/abs/path/01-….png", …] }`. +5. **Read the result** — stdout is exactly one JSON object: + `{ "ok": true, "status": "publish-ready", "outDir": "...", "manifest": "/abs/path/shotkit-manifest.json", "produced": [...] }`. Progress logs go to stderr in `--json` mode. - Read the returned `manifest` path first. It lists the mp4/webm, thumbnail, - captions, storyboard, bundled schema paths, integrity metadata, review - warnings, run freshness, and `handoff.adapterHints[]` for likely next tools. -4. **On failure** — exit code `2` = usage/no config found, `1` = runtime + Read `handoff.automation`, not the legacy human-oriented review summary. +6. **Fix and retry without user interruption**: + - `publish-ready`: stop. Report final target files and upload authorization + needed; do not ask the user to watch or edit them. + - `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 + blocker and attempted fixes. This is the first point at which user input is + appropriate. + - `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 @@ -72,20 +103,19 @@ rendered from the shipped code. By default, it also writes a handoff pack: because it becomes `.webm` and optional `.mp4`; `--scene ` reruns just that clip. - Use `thumbnail: { at: 1.2 }` for poster frames, `zoom: { scale: 1.04 }` or a - small `crop` when the UI is too small, and keep storyboard lint on unless the - clip is intentionally short. -- Do not position shotkit as a video editor. It is the source capture + - handoff layer before Screen Studio, Canva, Supademo, or future MCP adapters. - Use `assets[].role` from `shotkit-manifest.json` rather than guessing - filenames. -- When the user asks "what should I connect next?", inspect - `handoff.adapterHints[]`. Prefer `readiness:"ready"` hints first; treat - `needs-input` as a prompt to ask for missing avatar/audio/brand inputs; treat - `needs-assets` as a prompt to rerun shotkit with mp4/thumbnail/captions. -- Treat CLI `ok:true` as execution success only. Report - `handoff.review.status`, warning and incomplete counts, produced roles, - whether the run is full or partial, and the first asset-ready adapter. Do not - describe the pack as launch-approved without a separate visual/policy review. + small `crop` when the UI is too small. Keep storyboard lint on for every + channel target; `storyboardLint:false` is only for legacy, non-publishing + smoke clips and produces `needs-fix` for a target. +- Shotkit is not a timeline editor. It automates the repeatable channel work + (capture, trim, framing, captions, encode, poster frame, QA) and leaves manual + editors disabled by default. Use manifest roles instead of guessing files. +- Target workflows default to `automation.manualFallback:false`; manual editor + recommendations are omitted. Never suggest iMovie, Screen Studio, Canva, or + manual recapture unless the user explicitly requests a manual fallback. +- `publish-ready` means the final file passed shotkit's automated story, codec, + pixel-format, actual-dimension, actual-duration, thumbnail, nonblank-frame, + integrity, and channel-profile checks. External upload still requires the + user's authorization or an authorized connector. - Validate a received pack through `handoff.schemaFiles`; schema paths are relative to the manifest directory. On partial runs, compare each asset's `runId` with `manifest.run.id` and inspect `state` before assuming it was diff --git a/src/capture.js b/src/capture.js index c3d5bb0..29083ed 100644 --- a/src/capture.js +++ b/src/capture.js @@ -36,9 +36,10 @@ const { renderPrivacyDisclosureDoc, } = require('./describe'); const { resolveSize } = require('./presets'); -const { postProcessDemo } = require('./video'); +const { postProcessDemo, probeVideo } = require('./video'); const { analyzeDemoStoryboard, createDemoController, installDemoCaptionOverlay, normalizeDemoConfigs } = require('./demo'); const { assetRecord, writeHandoffDocs } = require('./handoff'); +const { analyzePng } = require('./image-qa'); const DEFAULT_VIEWPORT = { width: 1280, height: 800 }; @@ -96,7 +97,7 @@ function outputNames(config, cwd, demoConfigs) { return [ ...visualOutputNames(config), ...textOutputNames(config, cwd), - ...demoConfigs.map((demo) => demo.name), + ...demoConfigs.flatMap((demo) => [demo.name, demo.story]).filter(Boolean), ]; } @@ -110,6 +111,7 @@ function usageError(message) { * @param {object} config the project's shotkit config object (scenes, etc.) * @param {object} [opts] * @param {string[]} [opts.scenes] only capture these names (scenes/promoTiles/demo/demos/"description"/"privacy") + * @param {string[]} [opts.targets] only capture these configured channel targets * @param {boolean} [opts.noVideo] skip the demo screencast * @param {boolean} [opts.noBuild] skip config.build * @param {boolean} [opts.mp4] also convert the demo webm to H.264 mp4 @@ -117,12 +119,15 @@ function usageError(message) { * @param {boolean} [opts.freeze] passed to config hooks as flags.freeze * @param {string} [opts.cwd] project root for build / outDir / listing sources * @param {(msg:string)=>void} [opts.log] - * @returns {Promise<{produced: string[], outDir: string, manifest: string|null}>} + * @returns {Promise<{produced: string[], outDir: string, manifest: string|null, status:string}>} */ async function capture(config, opts = {}) { const cwd = opts.cwd || process.cwd(); const only = new Set(opts.scenes || []); + const targetOnly = new Set(opts.targets || []); const wants = (name) => only.size === 0 || only.has(name); + const wantsDemo = (demo) => wants(demo.name) || (demo.story && only.has(demo.story)); + const wantsTarget = (demo) => targetOnly.size === 0 || targetOnly.has(demo.target); const log = opts.log || ((msg) => console.log(`[shotkit] ${msg}`)); const passFlags = { liveGt: !!opts.liveGt, freeze: !!opts.freeze }; @@ -131,14 +136,16 @@ async function capture(config, opts = {}) { const bandHeight = config.bandHeight || DEFAULT_BAND_HEIGHT; const produced = []; let manifest = null; + let status = 'not-requested'; const assets = []; const demoConfigs = normalizeDemoConfigs(config); const capturedDemoConfigs = []; const demoViewports = {}; const demoWarnings = {}; - const shouldRunVisualPass = wantsAny(only, visualOutputNames(config)); - const shouldRunTextPass = wantsAny(only, textOutputNames(config, cwd)); - const selectedDemoConfigs = demoConfigs.filter((demoConfig) => !opts.noVideo && wants(demoConfig.name)); + const shouldRunVisualPass = targetOnly.size === 0 && wantsAny(only, visualOutputNames(config)); + const shouldRunTextPass = targetOnly.size === 0 && wantsAny(only, textOutputNames(config, cwd)); + const requestedDemoConfigs = demoConfigs.filter((demoConfig) => wantsDemo(demoConfig) && wantsTarget(demoConfig)); + const selectedDemoConfigs = requestedDemoConfigs.filter(() => !opts.noVideo); const needsBrowser = shouldRunVisualPass || selectedDemoConfigs.length > 0; if (only.size > 0) { const knownNames = new Set(outputNames(config, cwd, demoConfigs)); @@ -147,6 +154,16 @@ async function capture(config, opts = {}) { throw usageError(`unknown scene: ${unknownNames.join(', ')}. Known: ${[...knownNames].join(', ') || '(none)'}`); } } + if (targetOnly.size > 0) { + const configuredTargets = new Set(demoConfigs.map((demo) => demo.target).filter(Boolean)); + const unknownTargets = [...targetOnly].filter((target) => !configuredTargets.has(target)); + if (unknownTargets.length) { + throw usageError(`target not configured: ${unknownTargets.join(', ')}. Configured: ${[...configuredTargets].join(', ') || '(none)'}`); + } + if (!requestedDemoConfigs.length) { + throw usageError('no configured demo matches the requested scene and target filters'); + } + } fs.mkdirSync(outDir, { recursive: true }); const tempDirs = []; let fatalDemoError = null; @@ -384,6 +401,7 @@ async function capture(config, opts = {}) { baseUrl: setup2.env.baseUrl, flags: passFlags, demo, + target: demoConfig.targetProfile || null, }); } finally { demo.stop(); @@ -404,7 +422,9 @@ async function capture(config, opts = {}) { role: 'source-demo-webm', width: viewport.width, height: viewport.height, - source: { kind: 'demo', name: demoConfig.name }, + target: demoConfig.target, + channel: demoConfig.channel, + source: { kind: 'demo', name: demoConfig.name, story: demoConfig.story, target: demoConfig.target }, }, `✓ ${demoConfig.name}.webm (${viewport.width}×${viewport.height})`); // SNS post-processing: mp4 (H.264) and/or trim — needs a real ffmpeg, // fails loudly if one was requested but none is installed. @@ -427,14 +447,19 @@ async function capture(config, opts = {}) { } for (const extraPath of extra) { const format = path.extname(extraPath).toLowerCase(); + const media = format === '.mp4' && demoConfig.target ? probeVideo(extraPath) : undefined; + const visual = format === '.png' && demoConfig.target ? analyzePng(extraPath) : undefined; registerAsset(extraPath, { name: path.basename(extraPath, path.extname(extraPath)), type: format === '.png' ? 'image' : 'video', role: format === '.png' ? 'thumbnail' : 'sns-demo-mp4', - // No width/height: crop/zoom change the output dimensions from the - // source viewport and we don't re-measure, so recording the viewport - // size here would be wrong. The manifest schema makes them optional. - source: { kind: 'demo', name: demoConfig.name }, + width: media && media.ok ? media.width : undefined, + height: media && media.ok ? media.height : undefined, + target: demoConfig.target, + channel: demoConfig.channel, + media, + visual, + source: { kind: 'demo', name: demoConfig.name, story: demoConfig.story, target: demoConfig.target }, }); } } catch (err) { @@ -459,8 +484,8 @@ async function capture(config, opts = {}) { throw new Error(`demo capture failed for: ${names}`, { cause: demoErrors[0].error }); } - // 5. Handoff contract — metadata for external editors, MCP adapters, and - // agents. This is the starter-pack layer, not a competing editor surface. + // 5. Machine contract — target QA and fix/retry actions for agents, with + // legacy adapter hints available only when manual fallback is requested. if (config.handoff !== false) { const handoffPaths = writeHandoffDocs({ cwd, @@ -473,27 +498,38 @@ async function capture(config, opts = {}) { flags: passFlags, // Scene-filtered or --no-video runs only re-capture a subset; merge into // the existing handoff contract rather than clobbering a prior full run. - partial: only.size > 0 || !!opts.noVideo, + partial: only.size > 0 || targetOnly.size > 0 || !!opts.noVideo, run: { requestedScenes: [...only], + requestedTargets: [...targetOnly], + attempt: opts.attempt || 1, video: !opts.noVideo, noBuild: !!opts.noBuild, mp4: !!opts.mp4, configuredDemos: demoConfigs.map((demoConfig) => demoConfig.name), - selectedDemos: demoConfigs.filter((demoConfig) => wants(demoConfig.name)).map((demoConfig) => demoConfig.name), + configuredTargets: [...new Set(demoConfigs.map((demoConfig) => demoConfig.target).filter(Boolean))], + configuredTargetDemos: demoConfigs + .filter((demoConfig) => demoConfig.target) + .map((demoConfig) => ({ name: demoConfig.name, story: demoConfig.story, target: demoConfig.target })), + selectedDemos: requestedDemoConfigs.map((demoConfig) => demoConfig.name), capturedDemos: capturedDemoConfigs.map((demoConfig) => demoConfig.name), - skippedDemos: demoConfigs - .filter((demoConfig) => wants(demoConfig.name) && !capturedDemoConfigs.includes(demoConfig)) + skippedDemos: requestedDemoConfigs + .filter((demoConfig) => !capturedDemoConfigs.includes(demoConfig)) .map((demoConfig) => demoConfig.name), }, }); produced.push(...handoffPaths); manifest = path.join(outDir, 'shotkit-manifest.json'); + const handoff = JSON.parse(fs.readFileSync(manifest, 'utf8')); + status = handoff.handoff && handoff.handoff.automation + ? handoff.handoff.automation.status + : 'not-requested'; + log(`automation: ${status}`); for (const out of handoffPaths) log(`✓ ${path.basename(out)}`); } log(`done — ${produced.length} asset(s) in ${path.relative(cwd, outDir) || '.'}/`); - return { produced, outDir, manifest }; + return { produced, outDir, manifest, status }; } finally { await cleanupTempResources(); } diff --git a/src/channels.js b/src/channels.js new file mode 100644 index 0000000..89bd10d --- /dev/null +++ b/src/channels.js @@ -0,0 +1,122 @@ +/* + * Channel profiles are mechanical publishing constraints. Config authors own + * the story and actions; shotkit owns viewport, codec, duration cap, and poster + * defaults for known destinations. + */ + +const CHANNEL_PROFILES = Object.freeze({ + 'cws-youtube': Object.freeze({ + id: 'cws-youtube', + label: 'Chrome Web Store promo video', + platform: 'chrome-web-store', + delivery: 'youtube-url', + preset: 'sns-video', + viewport: Object.freeze({ width: 1280, height: 720 }), + mp4: Object.freeze({ crf: 18 }), + trim: Object.freeze({ duration: 30 }), + thumbnail: Object.freeze({ at: 1.2 }), + captionOptions: Object.freeze({ position: 'bottom' }), + recommendedDurationSeconds: Object.freeze({ min: 20, max: 40 }), + maximumDurationSeconds: 180, + outputSuffix: 'cws-youtube', + connector: 'youtube', + specUrl: 'https://developer.chrome.com/docs/webstore/cws-dashboard-listing', + }), + x: Object.freeze({ + id: 'x', + label: 'X post video', + platform: 'x', + delivery: 'social-upload', + preset: 'sns-video', + viewport: Object.freeze({ width: 1280, height: 720 }), + mp4: Object.freeze({ crf: 18 }), + trim: Object.freeze({ duration: 30 }), + thumbnail: Object.freeze({ at: 1.2 }), + captionOptions: Object.freeze({ position: 'bottom' }), + recommendedDurationSeconds: Object.freeze({ min: 20, max: 40 }), + maximumDurationSeconds: 140, + outputSuffix: 'x', + connector: 'x', + specUrl: 'https://help.x.com/en/using-x/x-videos', + }), + 'youtube-shorts': Object.freeze({ + id: 'youtube-shorts', + label: 'YouTube Short', + platform: 'youtube', + delivery: 'youtube-short', + preset: 'sns-vertical', + viewport: Object.freeze({ width: 720, height: 1280 }), + mp4: Object.freeze({ crf: 18 }), + trim: Object.freeze({ duration: 30 }), + thumbnail: Object.freeze({ at: 1.2 }), + captionOptions: Object.freeze({ position: 'bottom' }), + recommendedDurationSeconds: Object.freeze({ min: 20, max: 40 }), + maximumDurationSeconds: 180, + outputSuffix: 'youtube-shorts', + connector: 'youtube', + specUrl: 'https://support.google.com/youtube/answer/15424877', + }), +}); + +function resolveChannelProfile(id) { + const profile = CHANNEL_PROFILES[id]; + if (!profile) { + throw new Error(`shotkit: unknown channel target "${id}". Known: ${Object.keys(CHANNEL_PROFILES).join(', ')}`); + } + return profile; +} + +function targetIds(demo) { + if (demo.targets == null && demo.target == null) return []; + const values = demo.targets == null ? [demo.target] : demo.targets; + if (!Array.isArray(values) || !values.length || values.some((value) => typeof value !== 'string' || !value)) { + throw new Error(`shotkit: demo "${demo.name || '(unnamed)'}".targets must be a non-empty string array`); + } + return [...new Set(values)]; +} + +function validateTargetOptions(demo, ids) { + if (demo.targetOptions == null) return; + if (typeof demo.targetOptions !== 'object' || Array.isArray(demo.targetOptions)) { + throw new Error(`shotkit: demo "${demo.name}".targetOptions must be an object`); + } + const unknown = Object.keys(demo.targetOptions).filter((id) => !ids.includes(id)); + if (unknown.length) { + throw new Error(`shotkit: demo "${demo.name}".targetOptions contains undeclared target: ${unknown.join(', ')}`); + } + for (const [id, options] of Object.entries(demo.targetOptions)) { + if (!options || typeof options !== 'object' || Array.isArray(options)) { + throw new Error(`shotkit: demo "${demo.name}".targetOptions.${id} must be an object`); + } + } +} + +function expandDemoTargets(demo) { + const ids = targetIds(demo); + if (!ids.length) return [demo]; + validateTargetOptions(demo, ids); + return ids.map((id) => { + const profile = resolveChannelProfile(id); + const override = demo.targetOptions && demo.targetOptions[id] ? demo.targetOptions[id] : {}; + return { + ...demo, + ...override, + name: override.name || `${demo.name}-${profile.outputSuffix}`, + story: demo.story || demo.name, + target: profile.id, + channel: profile.platform, + preset: override.preset || profile.preset, + mp4: override.mp4 || demo.mp4 || profile.mp4, + trim: { ...profile.trim, ...(demo.trim || {}), ...(override.trim || {}) }, + thumbnail: override.thumbnail || demo.thumbnail || profile.thumbnail, + captionOptions: { + ...profile.captionOptions, + ...(demo.captionOptions || {}), + ...(override.captionOptions || {}), + }, + targetProfile: profile, + }; + }); +} + +module.exports = { CHANNEL_PROFILES, expandDemoTargets, resolveChannelProfile }; diff --git a/src/cli-runner.js b/src/cli-runner.js index d5d821a..af5fbb3 100644 --- a/src/cli-runner.js +++ b/src/cli-runner.js @@ -43,8 +43,8 @@ async function runCli(argv, io = {}, deps = {}) { try { const config = loadConfig(configPath); const log = opts.json ? (m) => stderr.write(`[shotkit] ${m}\n`) : undefined; - const { produced, outDir, manifest = null } = await capture(config, { ...opts, cwd, log }); - if (opts.json) writeJson(stdout, { ok: true, outDir, manifest, produced }); + const { produced, outDir, manifest = null, status = 'not-requested' } = await capture(config, { ...opts, cwd, log }); + if (opts.json) writeJson(stdout, { ok: true, status, outDir, manifest, produced }); return 0; } catch (err) { const msg = err && err.message ? err.message : String(err); diff --git a/src/cli.js b/src/cli.js index 7f9a40a..05df392 100644 --- a/src/cli.js +++ b/src/cli.js @@ -10,7 +10,7 @@ const fs = require('fs'); const path = require('path'); -const USAGE = `shotkit — build an agent-ready launch asset pack from a browser extension +const USAGE = `shotkit — autonomously build publish-ready launch assets from a browser extension Usage: shotkit [path] [options] @@ -23,8 +23,11 @@ Options: --scene only capture this scene/promoTile/demo/demos entry, "description", or "privacy"; repeatable, or comma-separated. When given, nothing else runs. + --target only render configured channel targets (cws-youtube, x, + youtube-shorts); repeatable, or comma-separated + --attempt automation retry number (default: 1; agents increment it) --json machine-readable mode: stdout gets one JSON object - {ok, outDir, manifest, produced[]}; progress logs move to stderr + {ok, status, outDir, manifest, produced[]}; logs go to stderr --no-video skip the demo screencast --mp4 also convert the demo to H.264 mp4 (needs ffmpeg on PATH or SHOTKIT_FFMPEG; SNS uploaders want mp4, not webm) @@ -42,6 +45,8 @@ Exit codes: 0 ok · 1 runtime failure · 2 usage / no config found function parseArgs(argv) { const opts = { scenes: [], + targets: [], + attempt: 1, errors: [], noVideo: false, noBuild: false, @@ -67,6 +72,18 @@ function parseArgs(argv) { else opts.errors.push('--scene requires a scene name'); } } + else if (a === '--target' || a.startsWith('--target=')) { + const inline = a.startsWith('--target='); + const value = inline ? a.slice('--target='.length) : argv[++i]; + if (!value || value.startsWith('-')) { + opts.errors.push('--target requires a channel target'); + if (!inline && value && value.startsWith('-')) i--; + } else { + const targets = value.split(',').filter(Boolean); + if (targets.length) opts.targets.push(...targets); + else opts.errors.push('--target requires a channel target'); + } + } else if (a === '--config' || a.startsWith('--config=')) { const inline = a.startsWith('--config='); const value = inline ? a.slice('--config='.length) : argv[++i]; @@ -77,6 +94,16 @@ function parseArgs(argv) { opts.config = value; } } + else if (a === '--attempt' || a.startsWith('--attempt=')) { + const inline = a.startsWith('--attempt='); + const value = inline ? a.slice('--attempt='.length) : argv[++i]; + if (!value || value.startsWith('-') || !Number.isInteger(Number(value)) || Number(value) < 1) { + opts.errors.push('--attempt requires a positive integer'); + if (!inline && value && value.startsWith('-')) i--; + } else { + opts.attempt = Number(value); + } + } else if (a === '--json') opts.json = true; else if (a === '--mp4') opts.mp4 = true; else if (a === '--no-video') opts.noVideo = true; diff --git a/src/demo.js b/src/demo.js index abf0130..bd0b75b 100644 --- a/src/demo.js +++ b/src/demo.js @@ -13,6 +13,7 @@ const DEFAULT_STEP_HOLD_MS = 800; const { normalizeDelayMs, normalizeDemoCaptions, parseTimeToMs } = require('./demo-time'); const { analyzeDemoStoryboard, formatStoryboardLint, lintDemoStoryboard } = require('./demo-storyboard'); +const { expandDemoTargets } = require('./channels'); function normalizeDemoConfigs(config = {}) { const demos = []; @@ -22,8 +23,7 @@ function normalizeDemoConfigs(config = {}) { demos.push(...config.demos); } - const seen = new Set(); - return demos.map((demo, index) => { + const validated = demos.map((demo, index) => { if (!demo || typeof demo !== 'object') { throw new Error(`shotkit: demo entry ${index} must be an object`); } @@ -32,10 +32,15 @@ function normalizeDemoConfigs(config = {}) { if (demo.captions != null && !Array.isArray(demo.captions)) { throw new Error(`shotkit: demo "${demo.name}".captions must be an array`); } - if (seen.has(demo.name)) throw new Error(`shotkit: duplicate demo name "${demo.name}"`); - seen.add(demo.name); return demo; }); + const expanded = validated.flatMap(expandDemoTargets); + const seen = new Set(); + for (const demo of expanded) { + if (seen.has(demo.name)) throw new Error(`shotkit: duplicate demo name "${demo.name}"`); + seen.add(demo.name); + } + return expanded; } function demoCaptionInitScript(options = {}) { diff --git a/src/handoff.js b/src/handoff.js index afaaee5..1733327 100644 --- a/src/handoff.js +++ b/src/handoff.js @@ -1,9 +1,9 @@ /* * shotkit — handoff contract exports. * - * These files are the agent-ready product boundary: a self-contained bundle of - * captured evidence, captions, story intent, integrity, review state, and - * next-tool hints for editors or future MCP adapters. + * These files are the autonomous machine boundary: captured evidence, + * captions, integrity, target QA, and agent-owned fix/retry actions. Users see + * final publish-ready assets or an exhausted blocker, not routine review work. */ const fs = require('fs'); @@ -11,6 +11,7 @@ const path = require('path'); const crypto = require('crypto'); const { normalizeDemoCaptions, parseTimeToMs } = require('./demo-time'); const { buildHandoffRecommendations } = require('./integrations'); +const { buildPublishPlan } = require('./publish'); const { isValidHandoffDocs, validateHandoffDocs } = require('./handoff-validator'); const { copyHandoffSchemas, @@ -70,7 +71,7 @@ function stableIdPart(value) { .replace(/^-+|-+$/g, '') || 'asset'; } -function assetRecord({ cwd, outDir, filePath, name, type, role, width, height, source }) { +function assetRecord({ cwd, outDir, filePath, name, type, role, width, height, source, target, channel, media, visual }) { const assetName = name || path.basename(filePath, path.extname(filePath)); return { id: `${stableIdPart(role)}:${stableIdPart(assetName)}`, @@ -82,6 +83,10 @@ function assetRecord({ cwd, outDir, filePath, name, type, role, width, height, s outPath: rel(outDir, filePath), width, height, + target, + channel, + media, + visual, source, }; } @@ -91,6 +96,9 @@ function demoAudience(demoConfig) { } function demoNextTool(demoConfig) { + if (demoConfig.targetProfile && demoConfig.targetProfile.connector) { + return `${demoConfig.targetProfile.connector}-upload`; + } if (demoConfig.nextTool) return demoConfig.nextTool; if (demoConfig.handoff && demoConfig.handoff.nextTool) return demoConfig.handoff.nextTool; return 'manual-editor'; @@ -138,7 +146,17 @@ function demoStoryboard(demoConfig, viewport) { const startMs = trimStartMs(demoConfig); return { name: demoConfig.name, + story: demoConfig.story, + target: demoConfig.target, + lintEnabled: demoConfig.storyboardLint !== false, audience: demoAudience(demoConfig), + channelProfile: demoConfig.targetProfile ? { + id: demoConfig.targetProfile.id, + label: demoConfig.targetProfile.label, + platform: demoConfig.targetProfile.platform, + delivery: demoConfig.targetProfile.delivery, + specUrl: demoConfig.targetProfile.specUrl, + } : undefined, preset: storyboardPreset(demoConfig.preset), viewport, recommendedNextTool: demoNextTool(demoConfig), @@ -161,6 +179,8 @@ function demoCaptions(demoConfig) { const startMs = trimStartMs(demoConfig); return { name: demoConfig.name, + story: demoConfig.story, + target: demoConfig.target, captions: deliverableBeats(normalizeDemoCaptions(demoConfig.captions || []), startMs), }; } @@ -200,17 +220,25 @@ function handoffReview(storyboardLint, run = {}, assets = []) { } function refreshManifestHandoff(manifest, storyboard, config) { - const adapterHints = buildHandoffRecommendations({ + const automation = buildPublishPlan({ + assets: manifest.assets, + storyboard, + run: manifest.run, + config, + }); + const adapterHints = automation.status !== 'not-requested' && !automation.manualFallback ? [] : buildHandoffRecommendations({ assets: manifest.assets, config, context: { storyboardDemoCount: storyboard.demos.length }, }); manifest.handoff.adapterHints = adapterHints; + manifest.handoff.automation = automation; manifest.handoff.review = handoffReview(storyboard.storyboardLint, manifest.run, manifest.assets); manifest.handoff.summary = { assetCount: manifest.assets.length, demoCount: storyboard.demos.length, readyAdapterCount: adapterHints.filter((hint) => hint.readiness === 'ready').length, + publishReadyTargetCount: automation.targets.filter((target) => target.status === 'publish-ready').length, }; } @@ -221,10 +249,14 @@ function buildHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewpo id: crypto.randomUUID(), mode: run.mode || 'full', requestedScenes: run.requestedScenes || [], + requestedTargets: run.requestedTargets || [], + attempt: run.attempt || 1, video: run.video !== false, noBuild: !!run.noBuild, mp4: !!run.mp4, configuredDemos: run.configuredDemos || [], + configuredTargets: run.configuredTargets || [], + configuredTargetDemos: run.configuredTargetDemos || [], selectedDemos: run.selectedDemos || [], capturedDemos: run.capturedDemos || [], skippedDemos: run.skippedDemos || [], @@ -273,16 +305,19 @@ function buildHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewpo storyboards: 'storyboard.json', captions: 'captions.json', recommendedFlow: [ - 'use shotkit outputs as source evidence', - 'polish in Screen Studio, Canva, Supademo, or another editor', + 'read handoff.automation and apply every agent-owned action', + 'retry listed scenes until every requested target is publish-ready', + 'upload final deliverables through an authorized connector', 'keep repo fixtures and storyboard as the repeatable source of truth', ], adapterHints: [], + automation: null, review: handoffReview(storyboard.storyboardLint, runInfo), summary: { assetCount: currentAssets.length, demoCount: storyboard.demos.length, readyAdapterCount: 0, + publishReadyTargetCount: 0, }, }, assets: currentAssets, diff --git a/src/image-qa.js b/src/image-qa.js new file mode 100644 index 0000000..803ad3d --- /dev/null +++ b/src/image-qa.js @@ -0,0 +1,47 @@ +const fs = require('fs'); +const { PNG } = require('pngjs'); + +function analyzePng(filePath) { + try { + const png = PNG.sync.read(fs.readFileSync(filePath)); + const pixelCount = png.width * png.height; + const stride = Math.max(1, Math.floor(pixelCount / 100_000)); + const colors = new Set(); + let samples = 0; + let opaque = 0; + let mean = 0; + let sumSquares = 0; + + for (let pixel = 0; pixel < pixelCount; pixel += stride) { + const offset = pixel * 4; + const r = png.data[offset]; + const g = png.data[offset + 1]; + const b = png.data[offset + 2]; + const a = png.data[offset + 3]; + samples += 1; + if (a > 16) opaque += 1; + const luminance = (0.2126 * r) + (0.7152 * g) + (0.0722 * b); + const delta = luminance - mean; + mean += delta / samples; + sumSquares += delta * (luminance - mean); + colors.add(`${r >> 4}:${g >> 4}:${b >> 4}:${a >> 4}`); + } + + const luminanceStdDev = samples > 1 ? Math.sqrt(sumSquares / (samples - 1)) : 0; + const opaqueRatio = samples ? opaque / samples : 0; + const colorBuckets = colors.size; + return { + ok: true, + width: png.width, + height: png.height, + opaqueRatio: Number(opaqueRatio.toFixed(4)), + luminanceStdDev: Number(luminanceStdDev.toFixed(2)), + colorBuckets, + nonBlank: opaqueRatio > 0.5 && (luminanceStdDev >= 3 || colorBuckets >= 8), + }; + } catch (err) { + return { ok: false, nonBlank: false, error: err.message }; + } +} + +module.exports = { analyzePng }; diff --git a/src/index.js b/src/index.js index c5d5dea..cf722ec 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,10 @@ /* * shotkit — public API. * - * shotkit turns a BUILT browser extension (or any HTML) into source launch - * assets plus a schema-backed handoff pack for agents and downstream editors. - * Playwright is the capture engine; the product boundary is exposed through - * the CLI (`shotkit`), `capture()`, and agent-readable docs/skills. + * shotkit turns a BUILT browser extension (or any HTML) into automatically + * validated channel assets. Playwright captures reusable stories; channel + * profiles, final-file QA, and agent-owned retry actions drive them to + * publish-ready through the CLI, capture(), and agent-readable skills. * * Config authors typically use `capture` indirectly (via the CLI) and import * the helpers below inside their `shotkit.config.js` to set up scenes: @@ -30,6 +30,8 @@ const { splitSections, } = require('./describe'); const { PRESETS, resolveSize } = require('./presets'); +const { CHANNEL_PROFILES, resolveChannelProfile } = require('./channels'); +const { buildPublishPlan } = require('./publish'); const { findFfmpeg, buildFfmpegArgs, buildThumbnailArgs, buildVideoFilter, postProcessDemo } = require('./video'); const { DEFAULT_TARGETS, buildHandoffRecommendations } = require('./integrations'); const { @@ -88,6 +90,8 @@ module.exports = { // sizes PRESETS, resolveSize, + CHANNEL_PROFILES, + resolveChannelProfile, // demo video post-processing findFfmpeg, buildFfmpegArgs, @@ -97,6 +101,7 @@ module.exports = { // downstream handoff recommendations DEFAULT_TARGETS, buildHandoffRecommendations, + buildPublishPlan, // demo story rendering analyzeDemoStoryboard, createDemoController, diff --git a/src/presets.js b/src/presets.js index f9c9a4b..15842a6 100644 --- a/src/presets.js +++ b/src/presets.js @@ -18,6 +18,7 @@ const PRESETS = Object.freeze({ 'sns-og': { width: 1200, height: 630 }, // Open Graph (link previews) 'sns-square': { width: 1080, height: 1080 }, // Instagram / square 'sns-portrait': { width: 1080, height: 1350 }, // Instagram portrait (4:5) + 'sns-vertical': { width: 720, height: 1280 }, // vertical short-form video (9:16) }); /** diff --git a/src/publish.js b/src/publish.js new file mode 100644 index 0000000..736ab18 --- /dev/null +++ b/src/publish.js @@ -0,0 +1,226 @@ +/* Convert final target assets and structured lint into an exception-only plan. */ + +const { resolveChannelProfile } = require('./channels'); + +function assetRef(asset) { + if (!asset) return null; + return { + id: asset.id, + role: asset.role, + path: asset.path, + outPath: asset.outPath, + state: asset.state, + }; +} + +function targetAsset(assets, demoName, role) { + return assets.find((asset) => ( + asset.role === role && asset.source && asset.source.name === demoName + )); +} + +function failedCheck(code, message, fix, demo, target) { + return { + check: { code, status: 'fail', message }, + action: { + code, + owner: 'agent', + demo, + target, + fix, + rerun: { scene: demo, target }, + }, + }; +} + +function targetPublishPlan({ demo, lint, assets, skipped }) { + const profile = resolveChannelProfile(demo.target); + const checks = []; + const actions = []; + const fail = (code, message, fix) => { + const item = failedCheck(code, message, fix, demo.name, demo.target); + checks.push(item.check); + actions.push(item.action); + }; + const pass = (code, message) => checks.push({ code, status: 'pass', message }); + + const mp4 = targetAsset(assets, demo.name, 'sns-demo-mp4'); + const thumbnail = targetAsset(assets, demo.name, 'thumbnail'); + + if (skipped.has(demo.name)) { + fail('target-not-captured', `${demo.name} was selected but not captured`, 'rerun this target with video enabled'); + } + if (!mp4) { + fail('missing-publish-mp4', 'final H.264 MP4 is missing', 'rerun the target; the channel profile enables MP4 automatically'); + } else if (mp4.state === 'modified') { + fail('modified-publish-mp4', 'the retained MP4 changed outside shotkit', 'recapture this target from the story script'); + } else { + pass('publish-mp4-present', `final MP4 is ${mp4.state || 'available'}`); + const media = mp4.media; + if (!media || !media.ok) { + fail('media-probe-failed', media && media.error ? media.error : 'final MP4 was not probed', 'install ffprobe (bundled with ffmpeg) or set SHOTKIT_FFPROBE, then rerun'); + } else { + if (media.codec === 'h264') pass('codec-h264', 'video codec is H.264'); + else fail('wrong-video-codec', `video codec is ${media.codec || 'unknown'}`, 'rerun with the channel profile H.264 encoder'); + + if (media.pixelFormat === 'yuv420p') pass('pixel-format-yuv420p', 'pixel format is yuv420p'); + else fail('wrong-pixel-format', `pixel format is ${media.pixelFormat || 'unknown'}`, 'rerun with the channel profile yuv420p output'); + + if (media.width === profile.viewport.width && media.height === profile.viewport.height) { + pass('channel-dimensions', `dimensions match ${profile.viewport.width}x${profile.viewport.height}`); + } else { + fail( + 'wrong-channel-dimensions', + `final dimensions are ${media.width}x${media.height}; expected ${profile.viewport.width}x${profile.viewport.height}`, + `remove conflicting crop/preset overrides and rerun target ${profile.id}`, + ); + } + + if (media.durationSeconds == null) { + fail('missing-media-duration', 'ffprobe did not report duration', 'rerun after verifying the ffprobe installation'); + } else if (media.durationSeconds > profile.maximumDurationSeconds) { + fail( + 'channel-duration-exceeded', + `duration ${media.durationSeconds.toFixed(2)}s exceeds ${profile.maximumDurationSeconds}s`, + `shorten trim.duration for target ${profile.id}`, + ); + } else if ( + media.durationSeconds < profile.recommendedDurationSeconds.min + || media.durationSeconds > profile.recommendedDurationSeconds.max + ) { + fail( + 'story-duration-outside-target', + `duration ${media.durationSeconds.toFixed(2)}s is outside ${profile.recommendedDurationSeconds.min}-${profile.recommendedDurationSeconds.max}s`, + `adjust story pacing or trim.duration for target ${profile.id}`, + ); + } else { + pass('story-duration', `duration ${media.durationSeconds.toFixed(2)}s is in target range`); + } + } + } + + if (!thumbnail) { + fail('missing-publish-thumbnail', 'poster/QA thumbnail is missing', 'rerun; the channel profile enables a thumbnail automatically'); + } else if (thumbnail.state === 'modified') { + fail('modified-publish-thumbnail', 'the retained thumbnail changed outside shotkit', 'regenerate the target thumbnail'); + } else { + pass('publish-thumbnail-present', 'poster/QA thumbnail is available'); + if (!thumbnail.visual || !thumbnail.visual.ok) { + fail( + 'thumbnail-qa-failed', + thumbnail.visual && thumbnail.visual.error ? thumbnail.visual.error : 'thumbnail pixels were not analyzed', + 'regenerate the thumbnail and rerun automated pixel QA', + ); + } else if (!thumbnail.visual.nonBlank) { + fail('blank-publish-thumbnail', 'poster/QA thumbnail appears blank or uniform', 'recapture this target after verifying the visible result state'); + } else { + pass('thumbnail-nonblank', `thumbnail has ${thumbnail.visual.colorBuckets} color buckets`); + } + } + + if (demo.lintEnabled === false) { + fail( + 'storyboard-lint-disabled', + 'storyboard lint is disabled for a publish target', + 'remove storyboardLint:false so story checks run before publication', + ); + } + for (const warning of lint.warnings || []) { + fail(`storyboard:${warning.code}`, warning.message, warning.fix); + } + + return { + target: profile.id, + label: profile.label, + platform: profile.platform, + delivery: profile.delivery, + demo: demo.name, + story: demo.story || demo.name, + status: actions.length ? 'needs-fix' : 'publish-ready', + deliverable: assetRef(mp4), + thumbnail: assetRef(thumbnail), + checks, + actions, + upload: { + connector: profile.connector, + requiresAuthorization: true, + specUrl: profile.specUrl, + }, + }; +} + +function inRequestedScope(demo, requestedTargets, requestedScenes) { + if (requestedTargets.size && !requestedTargets.has(demo.target)) return false; + if (requestedScenes.size && !requestedScenes.has(demo.name) && !requestedScenes.has(demo.story)) return false; + return true; +} + +function buildPublishPlan({ assets = [], storyboard = {}, run = {}, config = {} }) { + const requestedTargets = new Set(run.requestedTargets || []); + const requestedScenes = new Set(run.requestedScenes || []); + const isRequested = (demo) => inRequestedScope(demo, requestedTargets, requestedScenes); + const demos = (storyboard.demos || []).filter((demo) => demo.target && isRequested(demo)); + const expectedDemos = (run.configuredTargetDemos || []).filter(isRequested); + const manualFallback = !!(config.automation && config.automation.manualFallback); + const maxAttempts = Number.isInteger(config.automation && config.automation.maxAttempts) + && config.automation.maxAttempts > 0 + ? config.automation.maxAttempts + : 3; + const attempt = Number.isInteger(run.attempt) && run.attempt > 0 ? run.attempt : 1; + if (!demos.length && !expectedDemos.length) { + return { + policy: 'exception-only', + status: 'not-requested', + manualFallback, + userActionRequired: false, + maxAttempts, + attempt, + targets: [], + actions: [], + retryScenes: [], + }; + } + + const lintByName = new Map((storyboard.storyboardLint || []).map((item) => [item.name, item])); + const skipped = new Set(run.skippedDemos || []); + const targets = demos.map((demo) => targetPublishPlan({ + demo, + lint: lintByName.get(demo.name) || { warnings: [] }, + assets, + skipped, + })); + const actions = targets.flatMap((target) => target.actions); + const configuredButMissing = expectedDemos.filter((configured) => ( + !targets.some((item) => item.demo === configured.name) + )); + for (const configured of configuredButMissing) { + actions.push({ + code: 'target-output-missing', + owner: 'agent', + demo: configured.name, + target: configured.target, + fix: `capture configured target ${configured.target} for story ${configured.story}`, + rerun: { scene: configured.name, target: configured.target }, + }); + } + + const exhausted = actions.length > 0 && attempt >= maxAttempts; + if (exhausted) { + for (const target of targets) { + if (target.status === 'needs-fix') target.status = 'blocked'; + } + } + return { + policy: 'exception-only', + status: exhausted ? 'blocked' : actions.length ? 'needs-fix' : 'publish-ready', + manualFallback, + userActionRequired: exhausted, + maxAttempts, + attempt, + targets, + actions, + retryScenes: [...new Set(actions.map((action) => action.demo).filter(Boolean))], + }; +} + +module.exports = { buildPublishPlan }; diff --git a/src/video.js b/src/video.js index 1c96f98..b2a4093 100644 --- a/src/video.js +++ b/src/video.js @@ -75,6 +75,69 @@ function findFfmpeg(env = process.env) { return null; } +function findFfprobe(env = process.env) { + const sibling = env.SHOTKIT_FFMPEG + ? path.join(path.dirname(env.SHOTKIT_FFMPEG), 'ffprobe') + : null; + for (const bin of [env.SHOTKIT_FFPROBE, sibling, 'ffprobe']) { + if (!bin) continue; + try { + const result = spawnSync(bin, ['-version'], { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + env, + timeout: 10_000, + }); + if (result.status === 0 && /ffprobe version/i.test(result.stdout || '')) return bin; + } catch (_err) { + /* try the next candidate */ + } + } + return null; +} + +function parseProbeOutput(stdout) { + const data = JSON.parse(stdout); + const stream = data.streams && data.streams[0]; + if (!stream) throw new Error('ffprobe returned no video stream'); + const durationSeconds = Number(data.format && data.format.duration); + return { + ok: true, + codec: stream.codec_name, + pixelFormat: stream.pix_fmt, + width: stream.width, + height: stream.height, + durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : null, + }; +} + +function probeVideo(filePath, env = process.env) { + const bin = findFfprobe(env); + if (!bin) { + return { ok: false, error: 'no ffprobe found; install ffmpeg or set SHOTKIT_FFPROBE' }; + } + const result = spawnSync(bin, [ + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=codec_name,pix_fmt,width,height:format=duration', + '-of', 'json', + filePath, + ], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + env, + timeout: 20_000, + }); + if (result.status !== 0) { + return { ok: false, error: (result.stderr || `ffprobe exited ${result.status}`).trim() }; + } + try { + return parseProbeOutput(result.stdout); + } catch (err) { + return { ok: false, error: err.message }; + } +} + /** * Build the ffmpeg argv. Pure (unit-tested). * @@ -242,4 +305,14 @@ function postProcessDemo({ webmPath, mp4, trim, crop, zoom, thumbnail, log, env return produced; } -module.exports = { findFfmpeg, buildFfmpegArgs, buildThumbnailArgs, buildVideoFilter, postProcessDemo, INSTALL_HINT }; +module.exports = { + findFfmpeg, + findFfprobe, + parseProbeOutput, + probeVideo, + buildFfmpegArgs, + buildThumbnailArgs, + buildVideoFilter, + postProcessDemo, + INSTALL_HINT, +}; diff --git a/test/capture-lifecycle.test.js b/test/capture-lifecycle.test.js index 6364cb1..3ae7bcd 100644 --- a/test/capture-lifecycle.test.js +++ b/test/capture-lifecycle.test.js @@ -144,4 +144,54 @@ describe('capture lifecycle cleanup', () => { expect(cleanup).not.toHaveBeenCalled(); expect(fs.existsSync(path.join(cwd, 'store-assets'))).toBe(false); }); + + test('rejects unconfigured channel targets before capture work', async () => { + const prepareExtension = jest.fn(async () => preparedExtension(jest.fn())); + const cwd = tempCwd(); + + await expect(capture({ + handoff: false, + prepareExtension, + demo: { name: 'skillbridge', targets: ['x'], run: async () => {} }, + }, { + cwd, + targets: ['youtube-shorts'], + noBuild: true, + log: () => {}, + })).rejects.toMatchObject({ + message: 'target not configured: youtube-shorts. Configured: x', + exitCode: 2, + }); + + expect(prepareExtension).not.toHaveBeenCalled(); + expect(launchWithExtension).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(cwd, 'store-assets'))).toBe(false); + }); + + test('rejects scene and target filters whose intersection is empty', async () => { + const prepareExtension = jest.fn(async () => preparedExtension(jest.fn())); + const cwd = tempCwd(); + + await expect(capture({ + handoff: false, + prepareExtension, + demos: [ + { name: 'translate', targets: ['x'], run: async () => {} }, + { name: 'restore', targets: ['youtube-shorts'], run: async () => {} }, + ], + }, { + cwd, + scenes: ['translate'], + targets: ['youtube-shorts'], + noBuild: true, + log: () => {}, + })).rejects.toMatchObject({ + message: 'no configured demo matches the requested scene and target filters', + exitCode: 2, + }); + + expect(prepareExtension).not.toHaveBeenCalled(); + expect(launchWithExtension).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(cwd, 'store-assets'))).toBe(false); + }); }); diff --git a/test/cli-runner.test.js b/test/cli-runner.test.js index 97b4ac8..faee4db 100644 --- a/test/cli-runner.test.js +++ b/test/cli-runner.test.js @@ -28,6 +28,7 @@ describe('runCli', () => { return { outDir: path.join(cwd, 'store-assets'), manifest: path.join(cwd, 'store-assets', 'shotkit-manifest.json'), + status: 'publish-ready', produced: [path.join(cwd, 'store-assets', 'a.png')], }; }); @@ -44,6 +45,7 @@ describe('runCli', () => { expect(code).toBe(0); expect(JSON.parse(stdout.read())).toEqual({ ok: true, + status: 'publish-ready', outDir: path.join(cwd, 'store-assets'), manifest: path.join(cwd, 'store-assets', 'shotkit-manifest.json'), produced: [path.join(cwd, 'store-assets', 'a.png')], diff --git a/test/cli.test.js b/test/cli.test.js index a3b87e7..1e438b1 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -8,7 +8,7 @@ const BIN = path.resolve(__dirname, '..', 'bin', 'shotkit.js'); describe('parseArgs', () => { test('defaults', () => { - expect(parseArgs([])).toMatchObject({ scenes: [], errors: [], json: false, noVideo: false, help: false, path: null }); + expect(parseArgs([])).toMatchObject({ scenes: [], targets: [], errors: [], json: false, noVideo: false, help: false, path: null }); }); test('positional path + flags', () => { @@ -23,11 +23,26 @@ describe('parseArgs', () => { expect(parseArgs(['--scene=a,b']).scenes).toEqual(['a', 'b']); }); + test('--target accepts comma lists and repeats', () => { + expect(parseArgs(['--target', 'x,cws-youtube', '--target', 'youtube-shorts']).targets) + .toEqual(['x', 'cws-youtube', 'youtube-shorts']); + expect(parseArgs(['--target=x']).targets).toEqual(['x']); + }); + + test('--attempt accepts positive integers for agent retries', () => { + expect(parseArgs(['--attempt', '2']).attempt).toBe(2); + expect(parseArgs(['--attempt=3']).attempt).toBe(3); + expect(parseArgs(['--attempt', '0']).errors).toEqual(['--attempt requires a positive integer']); + }); + test('usage errors are explicit for missing values and unknown options', () => { expect(parseArgs(['--scene']).errors).toEqual(['--scene requires a scene name']); expect(parseArgs(['--scene=']).errors).toEqual(['--scene requires a scene name']); expect(parseArgs(['--config', '--json']).errors).toEqual(['--config requires a config path']); expect(parseArgs(['--config=']).errors).toEqual(['--config requires a config path']); + expect(parseArgs(['--target']).errors).toEqual(['--target requires a channel target']); + expect(parseArgs(['--target=']).errors).toEqual(['--target requires a channel target']); + expect(parseArgs(['--attempt=oops']).errors).toEqual(['--attempt requires a positive integer']); expect(parseArgs(['--wat']).errors).toEqual(['unknown option: --wat']); }); diff --git a/test/demo.test.js b/test/demo.test.js index 9e9293d..151043c 100644 --- a/test/demo.test.js +++ b/test/demo.test.js @@ -119,6 +119,51 @@ describe('normalizeDemoConfigs', () => { expect(() => normalizeDemoConfigs({ demos: [{ run }] })).toThrow(/needs a name/); expect(() => normalizeDemoConfigs({ demos: [{ name: 'demo' }] })).toThrow(/needs run/); }); + + test('expands one story into autonomous channel variants', () => { + const [cws, x, shorts] = normalizeDemoConfigs({ + demo: { + name: 'skillbridge', + targets: ['cws-youtube', 'x', 'youtube-shorts'], + captions: [{ at: 0.5, text: 'Translate the lesson' }], + run, + }, + }); + + expect(cws).toMatchObject({ + name: 'skillbridge-cws-youtube', + story: 'skillbridge', + target: 'cws-youtube', + preset: 'sns-video', + mp4: { crf: 18 }, + trim: { duration: 30 }, + thumbnail: { at: 1.2 }, + }); + expect(x).toMatchObject({ name: 'skillbridge-x', target: 'x', preset: 'sns-video' }); + expect(shorts).toMatchObject({ + name: 'skillbridge-youtube-shorts', + target: 'youtube-shorts', + preset: 'sns-vertical', + }); + expect(cws.run).toBe(run); + expect(shorts.targetProfile.viewport).toEqual({ width: 720, height: 1280 }); + }); + + test('rejects unknown or malformed channel targets', () => { + expect(() => normalizeDemoConfigs({ demo: { name: 'demo', targets: ['unknown'], run } })) + .toThrow(/unknown channel target/); + expect(() => normalizeDemoConfigs({ demo: { name: 'demo', targets: [], run } })) + .toThrow(/non-empty string array/); + }); + + test('rejects malformed or undeclared target overrides', () => { + expect(() => normalizeDemoConfigs({ + demo: { name: 'demo', targets: ['x'], targetOptions: { 'youtube-shorts': {} }, run }, + })).toThrow(/contains undeclared target: youtube-shorts/); + expect(() => normalizeDemoConfigs({ + demo: { name: 'demo', targets: ['x'], targetOptions: { x: true }, run }, + })).toThrow(/targetOptions\.x must be an object/); + }); }); describe('lintDemoStoryboard', () => { diff --git a/test/handoff.test.js b/test/handoff.test.js index 0cf2595..0949300 100644 --- a/test/handoff.test.js +++ b/test/handoff.test.js @@ -168,6 +168,7 @@ describe('handoff contract', () => { assetCount: 6, demoCount: 0, readyAdapterCount: 0, + publishReadyTargetCount: 0, }); const storyboard = manifest.assets.find((asset) => asset.role === 'storyboard-contract'); expect(storyboard.bytes).toBeGreaterThan(0); @@ -361,4 +362,88 @@ describe('handoff contract', () => { const supademo = manifest.handoff.adapterHints.find((hint) => hint.id === 'supademo'); expect(supademo).toMatchObject({ readiness: 'needs-assets', missingRoles: ['storyboard-content'] }); }); + + test('publishes a schema-valid autonomous target plan without manual adapters', () => { + const { cwd, outDir } = tmpProject(); + const { normalizeDemoConfigs } = require('../src/demo'); + const [demo] = normalizeDemoConfigs({ + demo: { + name: 'skillbridge', + targets: ['x'], + captions: [ + { at: 0.5, text: 'Translate the lesson' }, + { at: 18, text: 'Restore the original anytime' }, + ], + run: async () => {}, + }, + }); + const mp4Path = path.join(outDir, `${demo.name}.mp4`); + const thumbnailPath = path.join(outDir, `${demo.name}-thumbnail.png`); + fs.writeFileSync(mp4Path, 'mp4'); + fs.writeFileSync(thumbnailPath, 'png'); + const source = { kind: 'demo', name: demo.name, story: demo.story, target: demo.target }; + const assets = [ + assetRecord({ + cwd, + outDir, + filePath: mp4Path, + name: demo.name, + type: 'video', + role: 'sns-demo-mp4', + target: 'x', + channel: 'x', + media: { + ok: true, + codec: 'h264', + pixelFormat: 'yuv420p', + width: 1280, + height: 720, + durationSeconds: 30, + }, + source, + }), + assetRecord({ + cwd, + outDir, + filePath: thumbnailPath, + name: `${demo.name}-thumbnail`, + type: 'image', + role: 'thumbnail', + target: 'x', + channel: 'x', + visual: { ok: true, nonBlank: true, colorBuckets: 32 }, + source, + }), + ]; + + writeHandoffDocs({ + cwd, + outDir, + config: {}, + assets, + demoConfigs: [demo], + demoViewports: { [demo.name]: { width: 1280, height: 720 } }, + demoWarnings: { [demo.name]: [] }, + flags: {}, + run: { + configuredDemos: [demo.name], + configuredTargets: ['x'], + configuredTargetDemos: [{ name: demo.name, story: demo.story, target: 'x' }], + selectedDemos: [demo.name], + capturedDemos: [demo.name], + }, + }); + + const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'shotkit-manifest.json'), 'utf8')); + expect(manifest.handoff.automation).toMatchObject({ + policy: 'exception-only', + status: 'publish-ready', + manualFallback: false, + userActionRequired: false, + targets: [{ target: 'x', demo: demo.name, status: 'publish-ready' }], + actions: [], + }); + expect(manifest.handoff.adapterHints).toEqual([]); + expect(manifest.handoff.summary.publishReadyTargetCount).toBe(1); + }); }); diff --git a/test/image-qa.test.js b/test/image-qa.test.js new file mode 100644 index 0000000..71396b8 --- /dev/null +++ b/test/image-qa.test.js @@ -0,0 +1,43 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { PNG } = require('pngjs'); +const { analyzePng } = require('../src/image-qa'); + +function writePng(fill) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-png-qa-')); + const filePath = path.join(dir, 'frame.png'); + const png = new PNG({ width: 32, height: 32 }); + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const offset = ((y * 32) + x) * 4; + const [r, g, b, a] = fill(x, y); + png.data[offset] = r; + png.data[offset + 1] = g; + png.data[offset + 2] = b; + png.data[offset + 3] = a; + } + } + fs.writeFileSync(filePath, PNG.sync.write(png)); + return filePath; +} + +describe('thumbnail pixel QA', () => { + test('flags a uniform black frame as blank', () => { + const result = analyzePng(writePng(() => [0, 0, 0, 255])); + expect(result).toMatchObject({ ok: true, nonBlank: false, width: 32, height: 32 }); + }); + + test('accepts a visibly varied frame', () => { + const result = analyzePng(writePng((x, y) => [x * 8, y * 8, (x + y) * 4, 255])); + expect(result).toMatchObject({ ok: true, nonBlank: true, width: 32, height: 32 }); + expect(result.colorBuckets).toBeGreaterThan(8); + expect(result.luminanceStdDev).toBeGreaterThan(3); + }); + + test('returns a structured failure for an invalid PNG', () => { + const filePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-png-qa-')), 'bad.png'); + fs.writeFileSync(filePath, 'not a png'); + expect(analyzePng(filePath)).toMatchObject({ ok: false, nonBlank: false }); + }); +}); diff --git a/test/package-boundary.test.js b/test/package-boundary.test.js index b286afe..44509e9 100644 --- a/test/package-boundary.test.js +++ b/test/package-boundary.test.js @@ -28,6 +28,7 @@ describe('npm package boundary', () => { test('locks the public root API names', () => { expect(Object.keys(publicApi).sort()).toEqual([ + 'CHANNEL_PROFILES', 'DEFAULT_BAND_HEIGHT', 'DEFAULT_TARGETS', 'DEFAULT_VIEWPORT', @@ -42,6 +43,7 @@ describe('npm package boundary', () => { 'buildFfmpegArgs', 'buildHandoffDocs', 'buildHandoffRecommendations', + 'buildPublishPlan', 'buildThumbnailArgs', 'buildVideoFilter', 'capture', @@ -73,6 +75,7 @@ describe('npm package boundary', () => { 'renderDescriptionDoc', 'renderPrivacyDisclosureDoc', 'renderPromoTile', + 'resolveChannelProfile', 'resolveSize', 'serveDirectory', 'setDemoCaption', diff --git a/test/presets.test.js b/test/presets.test.js index 6526808..d986f8f 100644 --- a/test/presets.test.js +++ b/test/presets.test.js @@ -7,6 +7,7 @@ describe('presets', () => { expect(resolveSize('sns-twitter')).toEqual({ width: 1200, height: 675 }); expect(resolveSize('sns-video')).toEqual({ width: 1280, height: 720 }); expect(resolveSize('sns-og')).toEqual({ width: 1200, height: 630 }); + expect(resolveSize('sns-vertical')).toEqual({ width: 720, height: 1280 }); }); test('explicit size passes through', () => { @@ -27,5 +28,6 @@ describe('presets', () => { expect(PRESETS).toHaveProperty('cws-screenshot'); expect(PRESETS).toHaveProperty('sns-video', { width: 1280, height: 720 }); expect(PRESETS).toHaveProperty('sns-square', { width: 1080, height: 1080 }); + expect(PRESETS).toHaveProperty('sns-vertical', { width: 720, height: 1280 }); }); }); diff --git a/test/publish.test.js b/test/publish.test.js new file mode 100644 index 0000000..60ebd06 --- /dev/null +++ b/test/publish.test.js @@ -0,0 +1,200 @@ +const { buildPublishPlan } = require('../src/publish'); + +function asset(role, name, extra = {}) { + return { + id: `${role}:${name}`, + name, + role, + path: `store-assets/${name}`, + outPath: name, + state: 'produced', + source: { kind: 'demo', name }, + ...extra, + }; +} + +function storyboard(warnings = []) { + return { + demos: [{ name: 'skillbridge-x', story: 'skillbridge', target: 'x' }], + storyboardLint: [{ name: 'skillbridge-x', ok: warnings.length === 0, warnings }], + }; +} + +describe('autonomous publish plan', () => { + test('marks a fully probed variant publish-ready without human review', () => { + const plan = buildPublishPlan({ + storyboard: storyboard(), + run: {}, + assets: [ + asset('sns-demo-mp4', 'skillbridge-x', { + media: { + ok: true, + codec: 'h264', + pixelFormat: 'yuv420p', + width: 1280, + height: 720, + durationSeconds: 30, + }, + }), + asset('thumbnail', 'skillbridge-x', { + visual: { ok: true, nonBlank: true, colorBuckets: 48 }, + }), + ], + }); + + expect(plan).toMatchObject({ + policy: 'exception-only', + status: 'publish-ready', + manualFallback: false, + userActionRequired: false, + targets: [{ target: 'x', status: 'publish-ready' }], + actions: [], + }); + expect(plan.targets[0].checks.every((check) => check.status === 'pass')).toBe(true); + }); + + test('turns media and story failures into agent-owned retry actions', () => { + const plan = buildPublishPlan({ + storyboard: storyboard([{ + code: 'late-first-caption', + severity: 'warning', + message: 'first caption starts after 3s', + fix: 'show the result sooner', + }]), + run: {}, + assets: [ + asset('sns-demo-mp4', 'skillbridge-x', { + media: { + ok: true, + codec: 'vp9', + pixelFormat: 'yuv444p', + width: 720, + height: 1280, + durationSeconds: 8, + }, + }), + ], + }); + + expect(plan.status).toBe('needs-fix'); + expect(plan.userActionRequired).toBe(false); + expect(plan.actions).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'wrong-video-codec', owner: 'agent' }), + expect.objectContaining({ code: 'wrong-channel-dimensions', owner: 'agent' }), + expect.objectContaining({ code: 'storyboard:late-first-caption', owner: 'agent' }), + expect.objectContaining({ code: 'missing-publish-thumbnail', owner: 'agent' }), + ])); + expect(plan.retryScenes).toEqual(['skillbridge-x']); + }); + + test('does not declare a target publish-ready when storyboard lint is disabled', () => { + const unlinted = storyboard(); + unlinted.demos[0].lintEnabled = false; + const plan = buildPublishPlan({ + storyboard: unlinted, + assets: [ + asset('sns-demo-mp4', 'skillbridge-x', { + media: { + ok: true, + codec: 'h264', + pixelFormat: 'yuv420p', + width: 1280, + height: 720, + durationSeconds: 30, + }, + }), + asset('thumbnail', 'skillbridge-x', { + visual: { ok: true, nonBlank: true, colorBuckets: 48 }, + }), + ], + }); + + expect(plan).toMatchObject({ + status: 'needs-fix', + actions: [expect.objectContaining({ code: 'storyboard-lint-disabled', owner: 'agent' })], + }); + }); + + test('creates retry actions when configured targets produced no storyboard', () => { + const plan = buildPublishPlan({ + storyboard: { demos: [], storyboardLint: [] }, + run: { + configuredTargetDemos: [{ name: 'skillbridge-x', story: 'skillbridge', target: 'x' }], + }, + assets: [], + }); + + expect(plan).toMatchObject({ + status: 'needs-fix', + actions: [{ + code: 'target-output-missing', + owner: 'agent', + demo: 'skillbridge-x', + target: 'x', + }], + }); + }); + + test('does not demand unrequested channel targets during a target-only run', () => { + const plan = buildPublishPlan({ + storyboard: storyboard(), + run: { + requestedTargets: ['x'], + configuredTargetDemos: [ + { name: 'skillbridge-x', story: 'skillbridge', target: 'x' }, + { name: 'skillbridge-youtube-shorts', story: 'skillbridge', target: 'youtube-shorts' }, + ], + }, + assets: [ + asset('sns-demo-mp4', 'skillbridge-x', { + media: { + ok: true, + codec: 'h264', + pixelFormat: 'yuv420p', + width: 1280, + height: 720, + durationSeconds: 30, + }, + }), + asset('thumbnail', 'skillbridge-x', { + visual: { ok: true, nonBlank: true, colorBuckets: 48 }, + }), + ], + }); + + expect(plan).toMatchObject({ + status: 'publish-ready', + targets: [{ target: 'x' }], + actions: [], + }); + }); + + test('leaves legacy non-target demos outside autonomous publishing', () => { + expect(buildPublishPlan({ + storyboard: { demos: [{ name: 'legacy' }], storyboardLint: [] }, + run: {}, + assets: [], + })).toMatchObject({ status: 'not-requested', targets: [], actions: [] }); + }); + + test('accepts an omitted storyboard for public API callers', () => { + expect(buildPublishPlan({})).toMatchObject({ status: 'not-requested', targets: [] }); + }); + + test('escalates only after the configured automatic attempts are exhausted', () => { + const plan = buildPublishPlan({ + storyboard: storyboard(), + run: { attempt: 3 }, + config: { automation: { maxAttempts: 3 } }, + assets: [], + }); + + expect(plan).toMatchObject({ + status: 'blocked', + attempt: 3, + maxAttempts: 3, + userActionRequired: true, + targets: [{ status: 'blocked' }], + }); + }); +}); diff --git a/test/video.test.js b/test/video.test.js index cd836b3..f03053f 100644 --- a/test/video.test.js +++ b/test/video.test.js @@ -3,6 +3,7 @@ const os = require('os'); const path = require('path'); const { findFfmpeg, + parseProbeOutput, buildFfmpegArgs, buildThumbnailArgs, buildVideoFilter, @@ -55,6 +56,26 @@ describe('buildFfmpegArgs', () => { }); }); +describe('ffprobe metadata', () => { + test('normalizes final codec, dimensions, pixel format, and duration', () => { + expect(parseProbeOutput(JSON.stringify({ + streams: [{ codec_name: 'h264', pix_fmt: 'yuv420p', width: 1280, height: 720 }], + format: { duration: '29.970000' }, + }))).toEqual({ + ok: true, + codec: 'h264', + pixelFormat: 'yuv420p', + width: 1280, + height: 720, + durationSeconds: 29.97, + }); + }); + + test('rejects probe output without a video stream', () => { + expect(() => parseProbeOutput('{"streams":[],"format":{}}')).toThrow(/no video stream/); + }); +}); + describe('video filter helpers', () => { test('default filter only enforces even dimensions', () => { expect(buildVideoFilter()).toBe('scale=trunc(iw/2)*2:trunc(ih/2)*2'); From 9e6c02969f9f40895f6f0312485ca0d58f8d54f9 Mon Sep 17 00:00:00 2001 From: heznpc Date: Fri, 10 Jul 2026 14:07:57 +0900 Subject: [PATCH 06/13] feat: record native select interactions --- AGENTS.md | 12 +- CHANGELOG.md | 3 + README.ko.md | 9 +- README.md | 25 +++- package.json | 1 + skills/capture/SKILL.md | 9 +- src/demo-select.js | 293 ++++++++++++++++++++++++++++++++++++++++ src/demo.js | 49 +++++-- test/demo.test.js | 63 ++++++++- 9 files changed, 436 insertions(+), 28 deletions(-) create mode 100644 src/demo-select.js diff --git a/AGENTS.md b/AGENTS.md index b044c8c..9947dfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,8 @@ 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-select.js → recordable native-select mirror and real value-change action channels.js → autonomous CWS/YouTube, X, and Shorts target profiles promo.js → renderPromoTile (HTML template → image) describe.js → extractListing / renderDescriptionDoc (STORE_LISTING.md → copy) @@ -70,9 +71,9 @@ 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 machine boundary**: target workflows use `handoff.automation` to fix and retry until `publish-ready`; users do not read @@ -124,7 +125,10 @@ 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 +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. diff --git a/CHANGELOG.md b/CHANGELOG.md index a8c4b42..ecca644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. - 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. - Exception-only `handoff.automation`: `publish-ready`, `needs-fix`, and exhausted `blocked` states, agent-owned fix/rerun actions, `--target`, and bounded `--attempt` retries. diff --git a/README.ko.md b/README.ko.md index bf7b29d..213e605 100644 --- a/README.ko.md +++ b/README.ko.md @@ -21,7 +21,7 @@ ## 상태와 범위 (Status & Scope) - **현재 구현된 것** — Playwright로 *실제 출하 빌드*를 실행하고 하나의 story를 `cws-youtube`, `x`, `youtube-shorts` variant로 확장합니다. target별 viewport/H.264/trim/caption/thumbnail을 자동 적용하고, 최종 MP4를 ffprobe로 검사하며, thumbnail 픽셀의 blank-frame 여부까지 확인해 `publish-ready`, `needs-fix`, `blocked`를 산출합니다. manifest에는 에이전트가 실행할 retry action과 source evidence가 함께 남습니다. -- **스토리 렌더러** — 데모 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 캠페인 컷으로 바꾸기 쉬운 정도까지만 제공합니다. +- **스토리 렌더러** — 데모 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 서버, repo별 story/action 의도 제거, 범용 timeline editor, 호스티드 데모 플랫폼. 반복 가능한 채널 작업은 자동화하고 수동 편집기는 명시적으로 요청한 fallback일 때만 노출합니다. - **공개하지 않음** — 없음. @@ -164,11 +164,16 @@ 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, +}); +``` Use either timed captions, the helper API, or both: @@ -212,7 +225,7 @@ 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); }, } diff --git a/package.json b/package.json index 61918c9..cef3d40 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "src/index.js", "src/video.js", "src/demo.js", + "src/demo-select.js", "src/demo-time.js", "src/demo-storyboard.js", "src/handoff.js", diff --git a/skills/capture/SKILL.md b/skills/capture/SKILL.md index 20fe5cf..1ab133b 100644 --- a/skills/capture/SKILL.md +++ b/skills/capture/SKILL.md @@ -93,9 +93,12 @@ 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 `` because its OS popup is not part of the Playwright page screencast. +- For short-form focus captions, prefer authored timed captions and + `captionOptions: { mode: 'focus', wordsPerChunk: 3, wordMs: 360 }`. Shotkit + animates those words deterministically even when the product demo is silent; + do not add speech transcription solely to create caption motion. Shorts + enables this mode by default, while CWS and X stay static unless overridden. + The resolved style is recorded in both caption and storyboard handoff docs; + `captions.json` also carries the trim-relative rendered `timeline[]`. Treat a + `dense-focus-caption` lint as an agent-owned timing fix, never drop words. - `storyboard.json` carries structured lint (`code`, `severity`, `message`, `fix`) for agents. Treat those warnings as the edit list for the next `shotkit.config.js` pass. diff --git a/src/channels.js b/src/channels.js index 89bd10d..52d864a 100644 --- a/src/channels.js +++ b/src/channels.js @@ -49,7 +49,14 @@ const CHANNEL_PROFILES = Object.freeze({ mp4: Object.freeze({ crf: 18 }), trim: Object.freeze({ duration: 30 }), thumbnail: Object.freeze({ at: 1.2 }), - captionOptions: Object.freeze({ position: 'bottom' }), + captionOptions: Object.freeze({ + position: 'bottom-left', + mode: 'focus', + wordsPerChunk: 3, + wordMs: 360, + activeColor: '#facc15', + bottomOffset: 380, + }), recommendedDurationSeconds: Object.freeze({ min: 20, max: 40 }), maximumDurationSeconds: 180, outputSuffix: 'youtube-shorts', diff --git a/src/demo-caption-focus.js b/src/demo-caption-focus.js new file mode 100644 index 0000000..18180c8 --- /dev/null +++ b/src/demo-caption-focus.js @@ -0,0 +1,239 @@ +const DEFAULT_FOCUS_WORDS_PER_CHUNK = 3; +const DEFAULT_FOCUS_WORD_MS = 360; +const DEFAULT_FOCUS_ACTIVE_COLOR = '#facc15'; +const MIN_FOCUS_FRAME_MS = 120; + +function captionOptionObject(options) { + if (options == null) return {}; + if (typeof options !== 'object' || Array.isArray(options)) { + throw new Error('shotkit: demo captionOptions must be an object'); + } + if (options.position != null && (typeof options.position !== 'string' || !options.position.trim())) { + throw new Error('shotkit: demo captionOptions.position must be a non-empty string'); + } + if (options.bottomOffset != null && (!Number.isFinite(options.bottomOffset) || options.bottomOffset < 0)) { + throw new Error('shotkit: demo captionOptions.bottomOffset must be a non-negative number'); + } + return options; +} + +function captionMode(options = {}) { + options = captionOptionObject(options); + const mode = options.mode == null ? 'static' : options.mode; + if (mode !== 'static' && mode !== 'focus') { + throw new Error('shotkit: demo captionOptions.mode must be "static" or "focus"'); + } + return mode; +} + +function boundedInteger(value, fallback, name, min, max) { + const resolved = value == null ? fallback : value; + if (!Number.isInteger(resolved) || resolved < min || resolved > max) { + throw new Error(`shotkit: demo captionOptions.${name} must be an integer between ${min} and ${max}`); + } + return resolved; +} + +function normalizeFocusOptions(options = {}) { + options = captionOptionObject(options); + const mode = captionMode(options); + if (mode === 'static') return { mode }; + + const activeColor = options.activeColor == null ? DEFAULT_FOCUS_ACTIVE_COLOR : options.activeColor; + if (typeof activeColor !== 'string' || !activeColor.trim()) { + throw new Error('shotkit: demo captionOptions.activeColor must be a non-empty CSS color'); + } + + return { + mode, + wordsPerChunk: boundedInteger( + options.wordsPerChunk, + DEFAULT_FOCUS_WORDS_PER_CHUNK, + 'wordsPerChunk', + 1, + 6, + ), + wordMs: boundedInteger(options.wordMs, DEFAULT_FOCUS_WORD_MS, 'wordMs', 120, 2000), + activeColor: activeColor.trim(), + }; +} + +function captionStyle(options = {}) { + options = captionOptionObject(options); + const focus = normalizeFocusOptions(options); + const style = { + mode: focus.mode, + position: options.position || 'bottom-left', + }; + if (Number.isFinite(options.bottomOffset) && options.bottomOffset >= 0) { + style.bottomOffset = Math.round(options.bottomOffset); + } + if (focus.mode === 'focus') { + style.wordsPerChunk = focus.wordsPerChunk; + style.wordMs = focus.wordMs; + style.activeColor = focus.activeColor; + } + return style; +} + +function splitCaptionWords(text) { + const value = String(text).trim(); + if (!value) return []; + if (/\s/u.test(value) || typeof Intl === 'undefined' || typeof Intl.Segmenter !== 'function') { + return value.split(/\s+/u).filter(Boolean); + } + + const words = []; + let prefix = ''; + for (const part of new Intl.Segmenter(undefined, { granularity: 'word' }).segment(value)) { + if (part.isWordLike) { + words.push(`${prefix}${part.segment}`); + prefix = ''; + } else if (words.length) { + words[words.length - 1] += part.segment; + } else { + prefix += part.segment; + } + } + if (prefix) words.push(prefix); + return words.length ? words : [value]; +} + +function focusFrame(caption, atMs, focusWords, activeWordIndex, condensed = false) { + return { + atMs, + text: focusWords.join(' '), + sourceAtMs: caption.atMs, + sourceText: caption.text, + options: { + focusWords, + activeWordIndex, + fullText: caption.text, + condensed, + }, + }; +} + +function captionChunks(words, wordsPerChunk) { + const chunks = []; + for (let index = 0; index < words.length; index += wordsPerChunk) { + chunks.push(words.slice(index, index + wordsPerChunk)); + } + return chunks; +} + +function buildFocusCaptionFrames(caption, words, nextAtMs, focus) { + const availableMs = nextAtMs - caption.atMs; + const hasBoundary = Number.isFinite(availableMs); + const desiredMs = words.length * focus.wordMs; + let cadenceMs = focus.wordMs; + + if (hasBoundary && availableMs < desiredMs) { + cadenceMs = Math.floor(availableMs / words.length); + } + if (!hasBoundary || cadenceMs >= MIN_FOCUS_FRAME_MS) { + return words.map((_word, wordIndex) => { + const chunkStart = Math.floor(wordIndex / focus.wordsPerChunk) * focus.wordsPerChunk; + return focusFrame( + caption, + caption.atMs + (wordIndex * cadenceMs), + words.slice(chunkStart, chunkStart + focus.wordsPerChunk), + wordIndex - chunkStart, + ); + }); + } + + const chunks = captionChunks(words, focus.wordsPerChunk); + const chunkCadenceMs = Math.floor(availableMs / chunks.length); + if (chunkCadenceMs >= MIN_FOCUS_FRAME_MS) { + return chunks.map((chunk, index) => focusFrame( + caption, + caption.atMs + (index * chunkCadenceMs), + chunk, + 0, + )); + } + + return [focusFrame(caption, caption.atMs, words, 0, true)]; +} + +function buildCaptionFrames(schedule = [], options = {}) { + const focus = normalizeFocusOptions(options); + if (focus.mode === 'static') { + return schedule.map((caption) => ({ + ...caption, + sourceAtMs: caption.atMs, + sourceText: caption.text, + options: {}, + })); + } + + const frames = []; + schedule.forEach((caption, captionIndex) => { + const words = splitCaptionWords(caption.text); + const nextAtMs = schedule[captionIndex + 1] ? schedule[captionIndex + 1].atMs : Number.POSITIVE_INFINITY; + if (!words.length) { + frames.push({ + ...caption, + sourceAtMs: caption.atMs, + sourceText: caption.text, + options: {}, + }); + return; + } + frames.push(...buildFocusCaptionFrames(caption, words, nextAtMs, focus)); + }); + return frames; +} + +function analyzeFocusCaptionDensity(schedule = [], options = {}) { + const focus = normalizeFocusOptions(options); + if (focus.mode !== 'focus') return []; + + return schedule.flatMap((caption, index) => { + const next = schedule[index + 1]; + if (!next) return []; + const wordCount = splitCaptionWords(caption.text).length; + const availableMs = next.atMs - caption.atMs; + const recommendedMs = wordCount * focus.wordMs; + if (!wordCount || availableMs >= recommendedMs) return []; + return [{ index, wordCount, availableMs, recommendedMs }]; + }); +} + +function buildCaptionTimeline(frames = [], { startMs = 0, endMs = null } = {}) { + return frames.flatMap((frame, index) => { + const nextFrameAtMs = frames[index + 1] ? frames[index + 1].atMs : null; + const nextAtMs = nextFrameAtMs == null + ? endMs + : endMs == null ? nextFrameAtMs : Math.min(nextFrameAtMs, endMs); + if (nextAtMs != null && nextAtMs <= startMs) return []; + const atMs = Math.max(frame.atMs, startMs) - startMs; + const frameEndMs = nextAtMs == null ? null : Math.max(nextAtMs, startMs) - startMs; + if (frameEndMs != null && frameEndMs <= atMs) return []; + const focusWords = Array.isArray(frame.options.focusWords) ? frame.options.focusWords : null; + return [{ + at: atMs / 1000, + atMs, + ...(frameEndMs == null ? {} : { endAt: frameEndMs / 1000, endMs: frameEndMs }), + text: frame.text, + sourceText: frame.sourceText, + words: focusWords || splitCaptionWords(frame.text), + activeWordIndex: focusWords ? frame.options.activeWordIndex : null, + condensed: !!frame.options.condensed, + }]; + }); +} + +module.exports = { + DEFAULT_FOCUS_ACTIVE_COLOR, + DEFAULT_FOCUS_WORD_MS, + DEFAULT_FOCUS_WORDS_PER_CHUNK, + MIN_FOCUS_FRAME_MS, + analyzeFocusCaptionDensity, + buildCaptionFrames, + buildCaptionTimeline, + captionStyle, + normalizeFocusOptions, + splitCaptionWords, +}; diff --git a/src/demo-storyboard.js b/src/demo-storyboard.js index def61e9..0df0c7b 100644 --- a/src/demo-storyboard.js +++ b/src/demo-storyboard.js @@ -1,4 +1,5 @@ const { normalizeDemoCaptions, parseTimeToMs } = require('./demo-time'); +const { analyzeFocusCaptionDensity } = require('./demo-caption-focus'); function storyboardWarning(code, message, fix, details) { return { @@ -47,6 +48,23 @@ function analyzeDemoStoryboard(demoConfig, { viewport, mp4Requested } = {}) { { atMs: captions[0].atMs }, )); } + try { + for (const density of analyzeFocusCaptionDensity(captions, demoConfig.captionOptions)) { + const earliestNextAt = (captions[density.index].atMs + density.recommendedMs) / 1000; + warnings.push(storyboardWarning( + 'dense-focus-caption', + `focus caption ${density.index + 1} has ${density.availableMs}ms before the next beat`, + `move the next caption to at least ${earliestNextAt}s or shorten this caption`, + density, + )); + } + } catch (e) { + warnings.push(storyboardWarning( + 'invalid-caption-options', + e.message, + 'fix demo.captionOptions before capture', + )); + } for (const caption of captions) { if (caption.text.length > 70) { warnings.push(storyboardWarning( diff --git a/src/demo.js b/src/demo.js index 2e17945..3f40b8f 100644 --- a/src/demo.js +++ b/src/demo.js @@ -12,6 +12,7 @@ const DEFAULT_CLICK_BEFORE_MS = 120; const DEFAULT_STEP_HOLD_MS = 800; const { normalizeDelayMs, normalizeDemoCaptions, parseTimeToMs } = require('./demo-time'); +const { buildCaptionFrames } = require('./demo-caption-focus'); const { analyzeDemoStoryboard, formatStoryboardLint, lintDemoStoryboard } = require('./demo-storyboard'); const { expandDemoTargets } = require('./channels'); const { hideDemoSelect, installDemoSelectOverlay, performDemoSelect } = require('./demo-select'); @@ -80,13 +81,44 @@ function demoCaptionInitScript(options = {}) { opacity: 1; transform: translateY(0); } + #${rootId}[data-mode="focus"] { + width: min(660px, calc(100vw - 56px)); + min-height: 78px; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + column-gap: .22em; + row-gap: .08em; + padding: 14px 22px 16px; + background: rgba(8,11,16,.86); + font-size: 32px; + font-weight: 800; + line-height: 1.15; + text-align: center; + overflow-wrap: anywhere; + } + #${rootId}[data-mode="focus"][data-condensed="true"] { + font-size: 26px; + line-height: 1.18; + } + #${rootId} .shotkit-caption-word { + display: inline-block; + color: rgba(255,255,255,.72); + transform-origin: center bottom; + } + #${rootId} .shotkit-caption-word[data-active="true"] { + color: var(--shotkit-caption-active-color, #facc15); + text-shadow: 0 2px 14px rgba(0,0,0,.52); + animation: shotkit-caption-focus-pop 180ms cubic-bezier(.2,.9,.3,1.2); + } #${rootId}[data-position="bottom-left"] { left: max(28px, env(safe-area-inset-left)); - bottom: max(26px, env(safe-area-inset-bottom)); + bottom: max(var(--shotkit-caption-bottom-offset, 26px), env(safe-area-inset-bottom)); } #${rootId}[data-position="bottom"] { left: 50%; - bottom: max(26px, env(safe-area-inset-bottom)); + bottom: max(var(--shotkit-caption-bottom-offset, 26px), env(safe-area-inset-bottom)); transform: translate(-50%, 8px); text-align: center; } @@ -101,10 +133,23 @@ function demoCaptionInitScript(options = {}) { } #${rootId}[data-position="bottom-left"] { left: max(18px, env(safe-area-inset-left)); - bottom: max(18px, env(safe-area-inset-bottom)); + bottom: max(var(--shotkit-caption-bottom-offset, 18px), env(safe-area-inset-bottom)); } #${rootId}[data-position="bottom"] { - bottom: max(18px, env(safe-area-inset-bottom)); + bottom: max(var(--shotkit-caption-bottom-offset, 18px), env(safe-area-inset-bottom)); + } + #${rootId}[data-mode="focus"] { + width: calc(100vw - 220px); + max-width: 500px; + min-height: 88px; + padding: 15px 18px 17px; + font-size: 34px; + } + #${rootId}[data-mode="focus"][data-position="bottom-left"] { + left: max(48px, env(safe-area-inset-left)); + } + #${rootId}[data-mode="focus"][data-condensed="true"] { + font-size: 25px; } } #${pointerId} { @@ -161,6 +206,11 @@ function demoCaptionInitScript(options = {}) { 0% { opacity: .95; transform: scale(.6); } 100% { opacity: 0; transform: scale(1.9); } } + @keyframes shotkit-caption-focus-pop { + 0% { opacity: .55; transform: translateY(5px) scale(.86); } + 70% { opacity: 1; transform: translateY(-1px) scale(1.1); } + 100% { opacity: 1; transform: translateY(0) scale(1.06); } + } `; document.head.appendChild(style); } @@ -197,7 +247,32 @@ function demoCaptionInitScript(options = {}) { if (!root) return; const position = nextOptions.position || baseOptions.position; root.dataset.position = position; - root.textContent = String(text); + root.dataset.mode = nextOptions.mode === 'focus' ? 'focus' : 'static'; + root.dataset.condensed = nextOptions.condensed || ( + root.dataset.mode === 'focus' && String(text).length > 42 + ) ? 'true' : 'false'; + if (Number.isFinite(nextOptions.bottomOffset) && nextOptions.bottomOffset >= 0) { + root.style.setProperty('--shotkit-caption-bottom-offset', `${Math.round(nextOptions.bottomOffset)}px`); + } else { + root.style.removeProperty('--shotkit-caption-bottom-offset'); + } + root.style.setProperty('--shotkit-caption-active-color', nextOptions.activeColor || '#facc15'); + root.textContent = ''; + if (root.dataset.mode === 'focus' && Array.isArray(nextOptions.focusWords)) { + nextOptions.focusWords.forEach((word, index) => { + const span = document.createElement('span'); + span.className = 'shotkit-caption-word'; + span.dataset.active = index === nextOptions.activeWordIndex ? 'true' : 'false'; + span.textContent = String(word); + root.appendChild(span); + }); + root.setAttribute('aria-label', String(nextOptions.fullText || text)); + root.setAttribute('aria-live', 'off'); + } else { + root.textContent = String(text); + root.removeAttribute('aria-label'); + root.setAttribute('aria-live', 'polite'); + } root.dataset.visible = text ? 'true' : 'false'; } @@ -323,6 +398,7 @@ async function clickTarget(page, target, clickOptions) { function createDemoController({ page, captions = [], captionOptions = {} }) { const schedule = normalizeDemoCaptions(captions); + const captionFrames = buildCaptionFrames(schedule, captionOptions); const timers = []; let activeText = ''; let activeOptions = {}; @@ -348,8 +424,8 @@ function createDemoController({ page, captions = [], captionOptions = {} }) { }; page.on('domcontentloaded', replay); - for (const caption of schedule) { - timers.push(setTimeout(() => render(caption.text), caption.atMs)); + for (const frame of captionFrames) { + timers.push(setTimeout(() => render(frame.text, frame.options), frame.atMs)); } return { diff --git a/src/handoff.js b/src/handoff.js index 1733327..09096d8 100644 --- a/src/handoff.js +++ b/src/handoff.js @@ -10,6 +10,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { normalizeDemoCaptions, parseTimeToMs } = require('./demo-time'); +const { buildCaptionFrames, buildCaptionTimeline, captionStyle } = require('./demo-caption-focus'); const { buildHandoffRecommendations } = require('./integrations'); const { buildPublishPlan } = require('./publish'); const { isValidHandoffDocs, validateHandoffDocs } = require('./handoff-validator'); @@ -117,6 +118,16 @@ function trimStartMs(demoConfig) { } } +function trimEndMs(demoConfig, startMs) { + const trim = demoConfig.trim; + if (!trim || typeof trim !== 'object' || trim.duration == null) return null; + try { + return startMs + parseTimeToMs(trim.duration, 'trim.duration'); + } catch (_e) { + return null; + } +} + // Shift caption times by the trimmed-off prefix and drop captions that fall // before the clip starts (they are not in the deliverable). Output conforms to // the beat/caption schema: at >= 0 (number), atMs >= 0 (integer). @@ -165,6 +176,7 @@ function demoStoryboard(demoConfig, viewport) { crop: demoConfig.crop || null, zoom: demoConfig.zoom || null, }, + captionStyle: captionStyle(demoConfig.captionOptions || {}), thumbnail: storyboardThumbnail(demoConfig.thumbnail), recommendedStory: { durationSeconds: { min: 20, max: 40 }, @@ -177,11 +189,15 @@ function demoStoryboard(demoConfig, viewport) { function demoCaptions(demoConfig) { const startMs = trimStartMs(demoConfig); + const captions = normalizeDemoCaptions(demoConfig.captions || []); + const frames = buildCaptionFrames(captions, demoConfig.captionOptions); return { name: demoConfig.name, story: demoConfig.story, target: demoConfig.target, - captions: deliverableBeats(normalizeDemoCaptions(demoConfig.captions || []), startMs), + style: captionStyle(demoConfig.captionOptions || {}), + captions: deliverableBeats(captions, startMs), + timeline: buildCaptionTimeline(frames, { startMs, endMs: trimEndMs(demoConfig, startMs) }), }; } diff --git a/test/demo-caption-focus.test.js b/test/demo-caption-focus.test.js new file mode 100644 index 0000000..7f0ef8a --- /dev/null +++ b/test/demo-caption-focus.test.js @@ -0,0 +1,175 @@ +const { + analyzeFocusCaptionDensity, + buildCaptionFrames, + buildCaptionTimeline, + captionStyle, + normalizeFocusOptions, + splitCaptionWords, +} = require('../src/demo-caption-focus'); + +describe('focused demo captions', () => { + test('keeps static caption schedules backward compatible', () => { + expect(buildCaptionFrames([{ atMs: 500, text: 'Show the result' }])).toEqual([ + { + atMs: 500, + text: 'Show the result', + sourceAtMs: 500, + sourceText: 'Show the result', + options: {}, + }, + ]); + }); + + test('builds timed word highlights in compact chunks', () => { + const frames = buildCaptionFrames([ + { atMs: 500, text: 'Translate the whole lesson now' }, + ], { + mode: 'focus', + wordsPerChunk: 2, + wordMs: 200, + }); + expect(frames.map(({ atMs, text, options }) => ({ + atMs, + text, + focusWords: options.focusWords, + activeWordIndex: options.activeWordIndex, + }))).toEqual([ + { + atMs: 500, + text: 'Translate the', + focusWords: ['Translate', 'the'], + activeWordIndex: 0, + }, + { + atMs: 700, + text: 'Translate the', + focusWords: ['Translate', 'the'], + activeWordIndex: 1, + }, + { + atMs: 900, + text: 'whole lesson', + focusWords: ['whole', 'lesson'], + activeWordIndex: 0, + }, + { + atMs: 1100, + text: 'whole lesson', + focusWords: ['whole', 'lesson'], + activeWordIndex: 1, + }, + { + atMs: 1300, + text: 'now', + focusWords: ['now'], + activeWordIndex: 0, + }, + ]); + expect(frames.every((frame) => frame.sourceText === 'Translate the whole lesson now')).toBe(true); + }); + + test('compresses pacing so every word appears before the next beat', () => { + const frames = buildCaptionFrames([ + { atMs: 0, text: 'One two three four' }, + { atMs: 500, text: 'Next beat' }, + ], { mode: 'focus', wordMs: 300 }); + + const firstCaption = frames.filter((frame) => frame.sourceText === 'One two three four'); + expect(firstCaption.map((frame) => frame.atMs)).toEqual([0, 125, 250, 375]); + expect(firstCaption.at(-1).text).toBe('four'); + expect(frames.find((frame) => frame.atMs === 500).text).toBe('Next beat'); + expect(analyzeFocusCaptionDensity([ + { atMs: 0, text: 'One two three four' }, + { atMs: 500, text: 'Next beat' }, + ], { mode: 'focus', wordMs: 300 })).toEqual([ + { index: 0, wordCount: 4, availableMs: 500, recommendedMs: 1200 }, + ]); + }); + + test('preserves the full phrase when even chunk pacing is too dense', () => { + const [frame] = buildCaptionFrames([ + { atMs: 0, text: 'One two three four five six seven' }, + { atMs: 100, text: 'Next beat' }, + ], { mode: 'focus' }); + + expect(frame).toMatchObject({ + atMs: 0, + text: 'One two three four five six seven', + options: { + focusWords: ['One', 'two', 'three', 'four', 'five', 'six', 'seven'], + condensed: true, + }, + }); + }); + + test('emits a portable frame timeline with trim-relative boundaries', () => { + const frames = buildCaptionFrames([ + { atMs: 0, text: 'One two' }, + ], { mode: 'focus', wordMs: 200 }); + + expect(buildCaptionTimeline(frames, { startMs: 100, endMs: 700 })).toEqual([ + { + at: 0, + atMs: 0, + endAt: 0.1, + endMs: 100, + text: 'One two', + sourceText: 'One two', + words: ['One', 'two'], + activeWordIndex: 0, + condensed: false, + }, + { + at: 0.1, + atMs: 100, + endAt: 0.6, + endMs: 600, + text: 'One two', + sourceText: 'One two', + words: ['One', 'two'], + activeWordIndex: 1, + condensed: false, + }, + ]); + }); + + test('clamps every timeline frame to the delivered clip end', () => { + const frames = buildCaptionFrames([ + { atMs: 0, text: 'One two three' }, + ], { mode: 'focus', wordMs: 200 }); + + expect(buildCaptionTimeline(frames, { endMs: 350 }).map((frame) => ({ + atMs: frame.atMs, + endMs: frame.endMs, + activeWordIndex: frame.activeWordIndex, + }))).toEqual([ + { atMs: 0, endMs: 200, activeWordIndex: 0 }, + { atMs: 200, endMs: 350, activeWordIndex: 1 }, + ]); + }); + + test('normalizes style metadata and validates controls', () => { + expect(captionStyle({ + position: 'bottom', + mode: 'focus', + bottomOffset: 119.7, + })).toEqual({ + mode: 'focus', + position: 'bottom', + bottomOffset: 120, + wordsPerChunk: 3, + wordMs: 360, + activeColor: '#facc15', + }); + expect(splitCaptionWords(' 번역 결과를 바로 확인하세요 ')).toEqual(['번역', '결과를', '바로', '확인하세요']); + const japanese = splitCaptionWords('翻訳結果を確認します。'); + expect(japanese.length).toBeGreaterThan(1); + expect(japanese.join('')).toBe('翻訳結果を確認します。'); + expect(normalizeFocusOptions(null)).toEqual({ mode: 'static' }); + expect(() => normalizeFocusOptions({ mode: 'karaoke' })).toThrow(/must be "static" or "focus"/); + expect(() => normalizeFocusOptions({ mode: 'focus', wordsPerChunk: 0 })).toThrow(/wordsPerChunk/); + expect(() => normalizeFocusOptions({ mode: 'focus', wordMs: 20 })).toThrow(/wordMs/); + expect(() => normalizeFocusOptions({ mode: 'focus', bottomOffset: -1 })).toThrow(/bottomOffset/); + expect(() => normalizeFocusOptions('focus')).toThrow(/must be an object/); + }); +}); diff --git a/test/demo.test.js b/test/demo.test.js index 536e9d6..cb426ce 100644 --- a/test/demo.test.js +++ b/test/demo.test.js @@ -17,6 +17,7 @@ class FakePage extends EventEmitter { constructor() { super(); this.captions = []; + this.captionCalls = []; this.clicks = []; this.waits = []; this.inits = []; @@ -39,6 +40,7 @@ class FakePage extends EventEmitter { if (fn.name === 'demoCaptionInitScript') this.inits.push(arg); if (arg && Object.prototype.hasOwnProperty.call(arg, 'captionText')) { this.captions.push(arg.captionText); + this.captionCalls.push({ text: arg.captionText, options: arg.captionOptions }); } if (arg && Object.prototype.hasOwnProperty.call(arg, 'pointerPoint')) { this.pointerMoves.push({ point: arg.pointerPoint, options: arg.pointerOptions }); @@ -175,6 +177,16 @@ describe('normalizeDemoConfigs', () => { preset: 'sns-vertical', }); expect(cws.run).toBe(run); + expect(cws.captionOptions).toEqual({ position: 'bottom' }); + expect(x.captionOptions).toEqual({ position: 'bottom' }); + expect(shorts.captionOptions).toEqual({ + position: 'bottom-left', + mode: 'focus', + wordsPerChunk: 3, + wordMs: 360, + activeColor: '#facc15', + bottomOffset: 380, + }); expect(shorts.targetProfile.viewport).toEqual({ width: 720, height: 1280 }); }); @@ -229,6 +241,39 @@ describe('lintDemoStoryboard', () => { }, { viewport: { width: 1280, height: 720 }, mp4Requested: true })).toEqual([]); }); + test('asks agents to space focus-caption beats before publishing', () => { + const warnings = analyzeDemoStoryboard({ + name: 'demo', + mp4: true, + trim: { duration: 25 }, + captionOptions: { mode: 'focus', wordMs: 300 }, + captions: [ + { at: 0, text: 'One two three four' }, + { at: 0.5, text: 'Restore the original' }, + ], + }, { viewport: { width: 720, height: 1280 }, mp4Requested: true }); + + expect(warnings).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'dense-focus-caption', + fix: 'move the next caption to at least 1.2s or shorten this caption', + }), + ])); + }); + + test('surfaces malformed caption display options as structured lint', () => { + expect(analyzeDemoStoryboard({ + name: 'demo', + captionOptions: { mode: 'focus', bottomOffset: -1 }, + captions: [{ at: 0, text: 'Restore the original' }], + })).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid-caption-options', + fix: 'fix demo.captionOptions before capture', + }), + ])); + }); + test('warns about weak story shape and odd video dimensions', () => { const warnings = lintDemoStoryboard({ name: 'demo', @@ -357,6 +402,37 @@ describe('createDemoController', () => { expect(page.listenerCount('domcontentloaded')).toBe(0); }); + test('scheduled focus captions advance the active word without crossing beats', async () => { + jest.useFakeTimers(); + const page = new FakePage(); + const demo = createDemoController({ + page, + captions: [ + { at: 0.2, text: 'Translate the lesson now' }, + { at: 0.7, text: 'Restore anytime' }, + ], + captionOptions: { mode: 'focus', wordsPerChunk: 2, wordMs: 200 }, + }); + + await jest.advanceTimersByTimeAsync(200); + expect(page.captionCalls.at(-1)).toMatchObject({ + text: 'Translate the', + options: { + mode: 'focus', + focusWords: ['Translate', 'the'], + activeWordIndex: 0, + }, + }); + await jest.advanceTimersByTimeAsync(200); + expect(page.captionCalls.at(-1).options.activeWordIndex).toBe(1); + await jest.advanceTimersByTimeAsync(300); + expect(page.captionCalls.at(-1)).toMatchObject({ + text: 'Restore anytime', + options: { activeWordIndex: 0 }, + }); + demo.stop(); + }); + test('replays the active caption after navigation', async () => { jest.useFakeTimers(); const page = new FakePage(); diff --git a/test/handoff.test.js b/test/handoff.test.js index 0949300..fde1ae2 100644 --- a/test/handoff.test.js +++ b/test/handoff.test.js @@ -57,6 +57,7 @@ describe('handoff contract', () => { audience: 'sns', recommendedNextTool: 'screen-studio', viewport: { width: 1280, height: 720 }, + captionStyle: { mode: 'static', position: 'bottom-left' }, beats: [ { at: 0.5, atMs: 500, text: 'Translate in place' }, { at: 8, atMs: 8000, text: 'Restore original text' }, @@ -119,6 +120,16 @@ describe('handoff contract', () => { expect(docs.manifest.handoff.adapterHints.map((item) => item.id)).toContain('screen-studio'); expect(docs.storyboard.kind).toBe(HANDOFF_KINDS.storyboard); expect(docs.captions.kind).toBe(HANDOFF_KINDS.captions); + expect(docs.captions.demos[0].style).toEqual({ mode: 'static', position: 'bottom-left' }); + expect(docs.captions.demos[0].timeline).toEqual([{ + at: 1, + atMs: 1000, + text: 'Restore anytime', + sourceText: 'Restore anytime', + words: ['Restore', 'anytime'], + activeWordIndex: null, + condensed: false, + }]); expect(docs.storyboard.demos[0].beats[0].text).toBe('Restore anytime'); expect(docs.captions.demos[0].captions[0].atMs).toBe(1000); expect(docs.storyboard.storyboardLint).toEqual([{ From 30702c393294364b738b68542bae184313100f45 Mon Sep 17 00:00:00 2001 From: heznpc Date: Fri, 10 Jul 2026 23:02:47 +0900 Subject: [PATCH 08/13] feat: add verified composition calibrator --- AGENTS.md | 13 ++ CHANGELOG.md | 16 ++ README.ko.md | 15 ++ README.md | 70 +++++- bin/shotkit.js | 2 +- calibrator/app.js | 392 ++++++++++++++++++++++++++++++++ calibrator/index.html | 242 ++++++++++++++++++++ calibrator/model.js | 44 ++++ calibrator/preview.js | 197 ++++++++++++++++ calibrator/regions.js | 138 +++++++++++ calibrator/styles.css | 367 ++++++++++++++++++++++++++++++ docs/handoff-conventions.md | 15 +- eslint.config.js | 4 + package.json | 6 +- schemas/calibration.schema.json | 73 ++++++ schemas/captions.schema.json | 3 +- schemas/storyboard.schema.json | 28 ++- scripts/verify-pack.mjs | 9 +- skills/capture/SKILL.md | 17 +- src/calibration.js | 266 ++++++++++++++++++++++ src/calibrator-server.js | 350 ++++++++++++++++++++++++++++ src/capture.js | 33 ++- src/channels.js | 1 + src/cli-runner.js | 14 ++ src/cli.js | 18 ++ src/demo-caption-focus.js | 9 +- src/demo-caption-qa.js | 168 ++++++++++++++ src/demo-select.js | 6 +- src/demo-storyboard.js | 13 ++ src/demo.js | 132 +++++++++-- src/handoff.js | 5 + src/index.js | 12 + test/calibration.test.js | 118 ++++++++++ test/calibrator-server.test.js | 135 +++++++++++ test/cli-runner.test.js | 34 +++ test/cli.test.js | 16 +- test/demo-caption-focus.test.js | 4 + test/demo-caption-qa.test.js | 81 +++++++ test/demo.test.js | 32 +++ test/handoff.test.js | 8 +- test/package-boundary.test.js | 6 + test/schema-validation.test.js | 18 ++ 42 files changed, 3086 insertions(+), 44 deletions(-) create mode 100644 calibrator/app.js create mode 100644 calibrator/index.html create mode 100644 calibrator/model.js create mode 100644 calibrator/preview.js create mode 100644 calibrator/regions.js create mode 100644 calibrator/styles.css create mode 100644 schemas/calibration.schema.json create mode 100644 src/calibration.js create mode 100644 src/calibrator-server.js create mode 100644 src/demo-caption-qa.js create mode 100644 test/calibration.test.js create mode 100644 test/calibrator-server.test.js create mode 100644 test/demo-caption-qa.test.js diff --git a/AGENTS.md b/AGENTS.md index 5461aef..e717082 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,9 @@ src/ caption.js → compositeCaption (disclaimer/caption band, stacked UNDER the shot) 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 channels.js → autonomous CWS/YouTube, X, and Shorts target profiles promo.js → renderPromoTile (HTML template → image) describe.js → extractListing / renderDescriptionDoc (STORE_LISTING.md → copy) @@ -54,6 +56,7 @@ src/ 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) ``` @@ -94,6 +97,12 @@ test/ → unit tests for the pure/safe modules (no browser) - **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 @@ -141,6 +150,10 @@ the arrow cursor, and the real value change are recorded. Prefer 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e7a83..d727ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] ### Added +- 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. @@ -22,6 +28,10 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. 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. @@ -43,6 +53,12 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. editors. 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`. diff --git a/README.ko.md b/README.ko.md index 213e605..808c474 100644 --- a/README.ko.md +++ b/README.ko.md @@ -61,6 +61,7 @@ shotkit # outDir에 전부 산출 shotkit --scene 01-feature # 특정 scene/타일/데모/demos 항목 또는 "description"만 shotkit --target x # 설정된 X variant만 제작/재시도 shotkit --attempt 2 --json # 두 번째 자동 수정 시도 +shotkit --calibrate # 로컬 구도 Calibrator 열기 shotkit --no-video # 스크린캐스트 생략 shotkit --no-build # 이미 빌드된 번들 사용 shotkit ../my-extension --json # 다른 체크아웃 대상 실행; 결과 JSON을 stdout에 @@ -68,6 +69,20 @@ shotkit ../my-extension --json # 다른 체크아웃 대상 실행; 결과 JSON 산출물은 `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을 끕니다). +### 구도 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입니다. 에이전트도 같은 JSON과 제어면을 사용하므로 사용자가 +매번 영상을 검토할 필요는 없습니다. + ### Handoff Pack handoff pack은 에이전트가 target별 최종 파일을 검증하고 자동 수정·재촬영하는 diff --git a/README.md b/README.md index 9c0e448..de84269 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ shotkit # produce everything into outDir shotkit --scene 01-feature # just one scene/promoTile/demo/demos entry, "description", or "privacy" shotkit --target x # render/retry only the configured X variants shotkit --attempt 2 --json # next autonomous fix attempt +shotkit --calibrate # open the local composition calibrator shotkit --no-video # skip the screencast (faster/CI) shotkit --no-build # use an already-built bundle shotkit ../my-extension --json # run against another checkout; JSON result on stdout @@ -77,6 +78,39 @@ shotkit ../my-extension --json # run against another checkout; JSON result on s Outputs land in `outDir` (default `store-assets/`): `.png`, `.png`, `.webm`, optional `.mp4`, optional `-thumbnail.png`, `description.md`, optional `privacy-disclosure.md`, and, by default, `storyboard.json`, `captions.json`, `shotkit-manifest.json`, plus the three schemas under `schemas/` (`handoff: false` disables the handoff pack). +### Composition Calibrator + +When automated retries cannot settle a vertical composition, a repo can expose +a small set of authored layout presets and a tracked calibration document: + +```js +module.exports = { + calibration: { + from: 'shotkit.calibration.json', + layouts: ['focus-column', 'compact-column'], + }, + demos: [{ + name: 'launch-story', + targets: ['youtube-shorts'], + async run({ page, calibration }) { + // Apply calibration.layoutPreset to capture-only product layout CSS. + }, + }], +}; +``` + +Run `shotkit --calibrate` to open the local-only dashboard. It previews the +actual captured MP4 and intentionally limits adjustment to the declared layout +preset, 1.00-1.20 framing, caption lane/appearance, and at most three protected +UI regions. Save writes `shotkit.calibration.json`; it never rewrites the +CommonJS config. Recapture replays the real story and current profile, and only +a matching `publish-ready` result marks that profile verified. Changed or stale +profiles remain `needs-fix`. + +This is an exception-only calibration surface, not a layer canvas, timeline, +keyframe editor, or replacement for autonomous QA. Agents can operate the same +controls and consume the resulting JSON without asking a user to review media. + ### Handoff Pack The handoff pack is primarily an internal machine boundary: agents use it to @@ -129,10 +163,17 @@ The story expands to `skillbridge-cws-youtube`, `skillbridge-x`, and `skillbridge-youtube-shorts`. Landscape targets use 1280×720; Shorts uses 720×1280. All target variants receive H.264/yuv420p, a 30-second cap, a poster frame, and automated final-file checks. The Shorts profile also turns timed -captions into three-word focus chunks, highlights the current word, and keeps -the overlay inside YouTube's visual-guide safe region. CWS and X retain the quieter -static caption style. `targetOptions.` is available for a channel-specific -framing or caption override. +captions into three-word outline focus chunks, highlights the current word, and +keeps the overlay inside YouTube's visual-guide safe region. CWS and X retain +the quieter static panel style. `targetOptions.` is available for a +channel-specific story, framing, caption, or short video disclaimer override. + +A responsive product can replay one `run` function for every target. A desktop +surface that does not reflow cleanly at 720×1280 needs a focused Shorts override: +remove secondary panels, enlarge the one action/result pair, and use a +target-specific `run` (plus capture-only fixture CSS when needed). Do not scale +the full desktop story into a vertical canvas or treat a center crop as mobile +composition. The default policy is exception-only: `needs-fix` actions belong to the agent, which edits the config and reruns `automation.retryScenes[]` with an incremented @@ -197,6 +238,7 @@ targetOptions: { captionOptions: { position: 'bottom-left', mode: 'focus', + appearance: 'outline', wordsPerChunk: 3, wordMs: 360, activeColor: '#facc15', @@ -206,7 +248,9 @@ targetOptions: { } ``` -Set `mode: 'static'` to disable the treatment for a target. The resolved style +`position` is intentionally limited to `bottom-left` and `bottom`. Set +`mode: 'static'` to disable focus sequencing; `appearance` independently +selects the `panel` or `outline` surface. The resolved style is also written to `storyboard.json` and `captions.json`; the latter includes a trim-relative `timeline[]` with rendered chunks, active-word indexes, and frame boundaries for downstream agents. When authored beats are too close for the @@ -231,7 +275,9 @@ await demo.select('#language', 'ko', { }); ``` -Use either timed captions, the helper API, or both: +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: { @@ -264,7 +310,9 @@ demo: { 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. +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: @@ -281,8 +329,12 @@ 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, and timing drift. 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`. diff --git a/bin/shotkit.js b/bin/shotkit.js index 8a1a292..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) — diff --git a/calibrator/app.js b/calibrator/app.js new file mode 100644 index 0000000..ce4b41a --- /dev/null +++ b/calibrator/app.js @@ -0,0 +1,392 @@ +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'), + 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'), + 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; + 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; + elements.targetStatus.textContent = status.replaceAll('-', ' '); + elements.statusDot.className = `status-dot ${status === 'publish-ready' ? 'is-ready' : status === 'not-requested' ? 'is-neutral' : 'is-warning'}`; + } + + 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 { + state.document = await api('/api/state'); + 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; + } + + 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(); + setProfileState(); + syncControls(); + preview.applyGeometry(); + regions.render(); + renderWarnings(); + preview.renderBeats(); + elements.saveButton.disabled = true; + elements.recaptureButton.disabled = false; + } + + 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.dirty = false; + setProfileState(); + renderTargetStatus(); + 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); + } catch (error) { + showNotice('Recapture failed', error.message); + setOperation('Recapture 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.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..0fafa27 --- /dev/null +++ b/calibrator/index.html @@ -0,0 +1,242 @@ + + + + + + + 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..a3a5657 --- /dev/null +++ b/calibrator/styles.css @@ -0,0 +1,367 @@ +: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: 320px; + background: var(--panel); + color: var(--ink); + font-size: 13px; + letter-spacing: 0; + overflow: hidden; +} + +button, +input { font: inherit; letter-spacing: 0; } + +button { color: inherit; } + +.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: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 { background: var(--good); } +.target-item[data-status="needs-fix"] i, +.target-item[data-status="blocked"] i { background: var(--warn); } +.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; 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; + 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; } + +#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); } +.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) { + .brand-copy strong { font-size: 12px; } + .brand-copy span { max-width: 128px; } + .toolbar-status { max-width: 210px; overflow: hidden; } + .stage-panel { min-height: 590px; } + .stage-surface { min-height: 548px; } + .canvas-frame { max-width: 294px; max-height: 522px; } + .coordinate-grid, + .region-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} diff --git a/docs/handoff-conventions.md b/docs/handoff-conventions.md index efd4c61..fe217ab 100644 --- a/docs/handoff-conventions.md +++ b/docs/handoff-conventions.md @@ -71,21 +71,30 @@ 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` style: compact word chunks, current-word emphasis, and a bottom safe -offset. The style is deterministic for silent demos and requires no transcript +`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`, `position`, timing, chunk size, active color, and safe offset +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. +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, and schedule drift 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. diff --git a/eslint.config.js b/eslint.config.js index 2b1e607..25bb515 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,10 @@ module.exports = [ files: ['scripts/**/*.mjs'], languageOptions: { sourceType: 'module' }, }, + { + files: ['calibrator/**/*.js'], + languageOptions: { sourceType: 'module' }, + }, { ignores: ['node_modules/', 'coverage/'], }, diff --git a/package.json b/package.json index 8f49a57..2948403 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,13 @@ "files": [ "src", "bin", + "calibrator", "skills/capture", "docs/handoff-conventions.md", "schemas" ], "scripts": { - "lint": "eslint src/ bin/ scripts/ test/", + "lint": "eslint src/ bin/ calibrator/ scripts/ test/", "research": "node scripts/research-to-product-fit.mjs", "test": "jest --verbose --coverage", "pack:check": "node scripts/verify-pack.mjs", @@ -66,6 +67,8 @@ "jest": { "collectCoverageFrom": [ "src/channels.js", + "src/calibration.js", + "src/calibrator-server.js", "src/presets.js", "src/describe.js", "src/extension.js", @@ -76,6 +79,7 @@ "src/video.js", "src/demo.js", "src/demo-caption-focus.js", + "src/demo-caption-qa.js", "src/demo-select.js", "src/demo-time.js", "src/demo-storyboard.js", 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 0b060a5..33129ab 100644 --- a/schemas/captions.schema.json +++ b/schemas/captions.schema.json @@ -26,7 +26,8 @@ "required": ["mode", "position"], "properties": { "mode": { "enum": ["static", "focus"] }, - "position": { "type": "string" }, + "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 }, diff --git a/schemas/storyboard.schema.json b/schemas/storyboard.schema.json index 69bde67..fc30e55 100644 --- a/schemas/storyboard.schema.json +++ b/schemas/storyboard.schema.json @@ -58,6 +58,19 @@ "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 }, @@ -81,13 +94,26 @@ "required": ["mode", "position"], "properties": { "mode": { "enum": ["static", "focus"] }, - "position": { "type": "string" }, + "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" } } }, + "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"], diff --git a/scripts/verify-pack.mjs b/scripts/verify-pack.mjs index 968f7a3..dcf4850 100644 --- a/scripts/verify-pack.mjs +++ b/scripts/verify-pack.mjs @@ -17,11 +17,18 @@ 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", "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", ]; for (const relpath of requiredFiles) { @@ -47,7 +54,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\/|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 632e499..371122d 100644 --- a/skills/capture/SKILL.md +++ b/skills/capture/SKILL.md @@ -38,9 +38,12 @@ rendered from the shipped code. By default, it also writes a handoff pack: Shotkit expands target-specific names, viewport, H.264 MP4, 30-second cap, poster frame, and caption treatment. The `youtube-shorts` profile uses - three-word focus chunks with an animated current-word highlight and a + 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. 4. **Run attempt 1** (from the repo, or pass its path): ```bash @@ -48,6 +51,13 @@ rendered from the shipped code. By default, it also writes a handoff pack: 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 + turn this into a user media-review step. + Before npm publication, run the source checkout with `node bin/shotkit.js --json --attempt 1`, or use a project wrapper such as `npm run capture:store -- --json`. @@ -102,13 +112,16 @@ rendered from the shipped code. By default, it also writes a handoff pack: use `demo.select()` for a native ` + +
Caption @@ -235,6 +252,12 @@

Beats

+ + + + + + diff --git a/calibrator/styles.css b/calibrator/styles.css index a3a5657..e427c65 100644 --- a/calibrator/styles.css +++ b/calibrator/styles.css @@ -109,6 +109,8 @@ button { color: inherit; } .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; } @@ -156,9 +158,12 @@ button:disabled { cursor: not-allowed; opacity: .45; } .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 { background: var(--good); } +.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="blocked"] i { background: var(--warn); } +.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; } @@ -254,6 +259,15 @@ button:disabled { cursor: not-allowed; opacity: .45; } .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; } diff --git a/docs/handoff-conventions.md b/docs/handoff-conventions.md index fe217ab..17bd34d 100644 --- a/docs/handoff-conventions.md +++ b/docs/handoff-conventions.md @@ -3,8 +3,9 @@ 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 -publish-ready. The handoff pack is an internal machine boundary, not a request -for a user to inspect JSON or edit media. +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 @@ -17,16 +18,23 @@ Every successful run writes these files unless `handoff: false` is set: - `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/`. 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 three-document pack with these -same schemas before publishing the manifest. +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. Its separate `status` -is `publish-ready`, `needs-fix`, `blocked`, or `not-requested`. +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 @@ -62,8 +70,9 @@ 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.summary` reports asset, demo, adapter, and publish-ready -target counts. +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 @@ -102,15 +111,25 @@ is considered verified only when its current hash has produced a real 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 `userActionRequired:true`. +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. -`publish-ready` means these automated checks passed. External publication has -not happened yet; `targets[].upload` identifies the connector and notes that an -authorized external write is required. +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 assets were visually approved. +not mean the connector is installed or the user approved the assets. ## Manual Fallback @@ -136,9 +155,13 @@ Autonomous flow: 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 `publish-ready`, use each target's deliverable and upload connector. -4. On `blocked`, report the exhausted blocker and attempted fixes. -5. Keep repo fixtures and the story/action script as the repeatable source. +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. Fallback tool notes: @@ -204,6 +227,8 @@ 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 diff --git a/package.json b/package.json index 2948403..b2a9285 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "shotkit", "version": "1.3.0", - "description": "Autonomously capture, validate, and retry publish-ready CWS, X, and YouTube launch assets from browser extensions.", + "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", @@ -67,6 +67,7 @@ "jest": { "collectCoverageFrom": [ "src/channels.js", + "src/approval.js", "src/calibration.js", "src/calibrator-server.js", "src/presets.js", 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/shotkit-manifest.schema.json b/schemas/shotkit-manifest.schema.json index 15354a4..628ccbd 100644 --- a/schemas/shotkit-manifest.schema.json +++ b/schemas/shotkit-manifest.schema.json @@ -85,7 +85,8 @@ "properties": { "manifest": { "type": "string" }, "storyboard": { "type": "string" }, - "captions": { "type": "string" } + "captions": { "type": "string" }, + "approval": { "const": "urn:starter-series:shotkit:schema:approval:v1" } } }, "schemaFiles": { @@ -94,7 +95,8 @@ "properties": { "manifest": { "const": "schemas/shotkit-manifest.schema.json" }, "storyboard": { "const": "schemas/storyboard.schema.json" }, - "captions": { "const": "schemas/captions.schema.json" } + "captions": { "const": "schemas/captions.schema.json" }, + "approval": { "const": "schemas/approval.schema.json" } } }, "storyboards": { "const": "storyboard.json" }, @@ -108,6 +110,7 @@ "items": { "$ref": "#/$defs/adapterHint" } }, "automation": { "$ref": "#/$defs/automation" }, + "approval": { "$ref": "#/$defs/approvalGate" }, "review": { "type": "object", "required": ["status", "warningCount", "warnings", "incomplete"], @@ -131,7 +134,8 @@ "assetCount": { "type": "integer", "minimum": 0 }, "demoCount": { "type": "integer", "minimum": 0 }, "readyAdapterCount": { "type": "integer", "minimum": 0 }, - "publishReadyTargetCount": { "type": "integer", "minimum": 0 } + "publishReadyTargetCount": { "type": "integer", "minimum": 0 }, + "approvedTargetCount": { "type": "integer", "minimum": 0 } } } } @@ -220,6 +224,7 @@ "properties": { "name": { "type": "string" }, "story": { "type": "string" }, + "profileHash": { "type": "string", "minLength": 1 }, "target": { "type": "string" } } }, @@ -333,6 +338,46 @@ "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"], diff --git a/scripts/verify-pack.mjs b/scripts/verify-pack.mjs index dcf4850..5cce3d8 100644 --- a/scripts/verify-pack.mjs +++ b/scripts/verify-pack.mjs @@ -29,6 +29,7 @@ const requiredFiles = [ "schemas/storyboard.schema.json", "schemas/captions.schema.json", "schemas/calibration.schema.json", + "schemas/approval.schema.json", ]; for (const relpath of requiredFiles) { diff --git a/skills/capture/SKILL.md b/skills/capture/SKILL.md index 371122d..2ff936e 100644 --- a/skills/capture/SKILL.md +++ b/skills/capture/SKILL.md @@ -1,10 +1,10 @@ --- name: capture -description: Autonomously produce publish-ready browser-extension launch assets with shotkit. 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 asking the user to review media; escalate only after automated attempts are exhausted. +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 --- -# Produce publish-ready launch 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 @@ -56,7 +56,8 @@ rendered from the shipped code. By default, it also writes a handoff pack: 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 - turn this into a user media-review step. + ask the user to diagnose composition or operate the controls. Once technical + QA passes, use the same dashboard for the user's final media decision. Before npm publication, run the source checkout with `node bin/shotkit.js --json --attempt 1`, or use a project wrapper such as @@ -69,18 +70,24 @@ rendered from the shipped code. By default, it also writes a handoff pack: demo — needs ffmpeg on PATH or `SHOTKIT_FFMPEG`), `--no-build` (reuse an existing build). 5. **Read the result** — stdout is exactly one JSON object: - `{ "ok": true, "status": "publish-ready", "outDir": "...", "manifest": "/abs/path/shotkit-manifest.json", "produced": [...] }`. + `{ "ok": true, "status": "awaiting-approval", "machineStatus": "publish-ready", "outDir": "...", "manifest": "/abs/path/shotkit-manifest.json", "produced": [...] }`. Progress logs go to stderr in `--json` mode. - Read `handoff.automation`, not the legacy human-oriented review summary. -6. **Fix and retry without user interruption**: - - `publish-ready`: stop. Report final target files and upload authorization - needed; do not ask the user to watch or edit them. + 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 - blocker and attempted fixes. This is the first point at which user input is - appropriate. + technical blocker and attempted fixes; ask for technical input. + - `awaiting-approval`: technical QA passed. Open the Calibrator and present + the rendered candidate to the user. 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 @@ -138,10 +145,11 @@ rendered from the shipped code. By default, it also writes a handoff pack: - Target workflows default to `automation.manualFallback:false`; manual editor recommendations are omitted. Never suggest iMovie, Screen Studio, Canva, or manual recapture unless the user explicitly requests a manual fallback. -- `publish-ready` means the final file passed shotkit's automated story, codec, +- Machine `publish-ready` means the final file passed shotkit's automated story, codec, pixel-format, actual-dimension, actual-duration, thumbnail, nonblank-frame, - integrity, and channel-profile checks. External upload still requires the - user's authorization or an authorized connector. + integrity, and channel-profile checks. It is not user approval. Publication + additionally requires `handoff.approval.publishable:true` and an authorized + connector. - Validate a received pack through `handoff.schemaFiles`; schema paths are relative to the manifest directory. On partial runs, compare each asset's `runId` with `manifest.run.id` and inspect `state` before assuming it was diff --git a/src/approval.js b/src/approval.js new file mode 100644 index 0000000..7858d7f --- /dev/null +++ b/src/approval.js @@ -0,0 +1,201 @@ +const fs = require('fs'); +const path = require('path'); + +const { writeJson } = require('./handoff-files'); + +const APPROVAL_VERSION = 1; +const APPROVAL_KIND = 'shotkit.approval'; +const APPROVAL_SCHEMA_ID = 'urn:starter-series:shotkit:schema:approval:v1'; +const APPROVAL_FILE = 'shotkit-approval.json'; +const DECISION_STATUSES = new Set(['approved', 'changes-requested']); +const UNSAFE_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +function emptyApprovalDocument() { + return { + $schema: APPROVAL_SCHEMA_ID, + version: APPROVAL_VERSION, + kind: APPROVAL_KIND, + decisions: {}, + }; +} + +function isObject(value) { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function nonEmptyString(value, name) { + if (typeof value !== 'string' || !value.trim()) throw new Error(`shotkit: ${name} must be a non-empty string`); + return value.trim(); +} + +function approvalKey(value, name) { + const key = nonEmptyString(value, name); + if (UNSAFE_KEYS.has(key)) throw new Error(`shotkit: ${name} uses a reserved key`); + return key; +} + +function normalizeDecision(decision, name = 'approval decision') { + if (!isObject(decision)) throw new Error(`shotkit: ${name} must be an object`); + if (!DECISION_STATUSES.has(decision.status)) { + throw new Error(`shotkit: ${name}.status must be "approved" or "changes-requested"`); + } + const assetDigest = nonEmptyString(decision.assetDigest, `${name}.assetDigest`); + if (!/^[a-f0-9]{64}$/i.test(assetDigest)) throw new Error(`shotkit: ${name}.assetDigest must be a SHA-256 digest`); + const decidedAt = nonEmptyString(decision.decidedAt, `${name}.decidedAt`); + if (Number.isNaN(Date.parse(decidedAt))) throw new Error(`shotkit: ${name}.decidedAt must be an ISO date-time`); + const note = decision.note == null ? '' : String(decision.note).trim(); + if (decision.status === 'changes-requested' && !note) { + throw new Error(`shotkit: ${name}.note is required when changes are requested`); + } + if (note.length > 2000) throw new Error(`shotkit: ${name}.note must be at most 2000 characters`); + return { + status: decision.status, + assetDigest: assetDigest.toLowerCase(), + ...(decision.profileHash ? { profileHash: nonEmptyString(decision.profileHash, `${name}.profileHash`) } : {}), + decidedAt, + ...(note ? { note } : {}), + }; +} + +function normalizeApprovalDocument(document) { + if (!isObject(document)) throw new Error('shotkit: approval document must be an object'); + if (document.$schema !== APPROVAL_SCHEMA_ID) throw new Error(`shotkit: approval $schema must be ${APPROVAL_SCHEMA_ID}`); + if (document.version !== APPROVAL_VERSION) throw new Error(`shotkit: approval version must be ${APPROVAL_VERSION}`); + if (document.kind !== APPROVAL_KIND) throw new Error(`shotkit: approval kind must be ${APPROVAL_KIND}`); + if (!isObject(document.decisions)) throw new Error('shotkit: approval decisions must be an object'); + const decisions = {}; + for (const [story, targets] of Object.entries(document.decisions)) { + const normalizedStory = approvalKey(story, 'approval story key'); + if (!isObject(targets)) throw new Error(`shotkit: approval decisions.${story} must be an object`); + decisions[normalizedStory] = {}; + for (const [target, decision] of Object.entries(targets)) { + const normalizedTarget = approvalKey(target, `approval decisions.${story} target key`); + decisions[normalizedStory][normalizedTarget] = normalizeDecision( + decision, + `approval decisions.${story}.${target}`, + ); + } + } + return { $schema: APPROVAL_SCHEMA_ID, version: APPROVAL_VERSION, kind: APPROVAL_KIND, decisions }; +} + +function approvalPath(outDir) { + return path.join(path.resolve(outDir), APPROVAL_FILE); +} + +function loadApproval(outDir) { + const filePath = approvalPath(outDir); + if (!fs.existsSync(filePath)) return { path: filePath, document: emptyApprovalDocument() }; + try { + return { path: filePath, document: normalizeApprovalDocument(JSON.parse(fs.readFileSync(filePath, 'utf8'))) }; + } catch (error) { + throw new Error(`shotkit: could not load approval file: ${error.message}`, { cause: error }); + } +} + +function updateApprovalDecision(outDir, story, target, decision) { + story = approvalKey(story, 'approval story'); + target = approvalKey(target, 'approval target'); + const loaded = loadApproval(outDir); + const normalized = normalizeDecision({ ...decision, decidedAt: decision.decidedAt || new Date().toISOString() }); + if (!Object.prototype.hasOwnProperty.call(loaded.document.decisions, story)) { + loaded.document.decisions[story] = {}; + } + loaded.document.decisions[story][target] = normalized; + writeJson(loaded.path, loaded.document); + return { ...loaded, decision: normalized }; +} + +function decisionFor(document, story, target) { + return document.decisions && document.decisions[story] ? document.decisions[story][target] || null : null; +} + +function targetAssetDigest(manifest, target) { + const asset = target.deliverable && (manifest.assets || []).find((item) => item.id === target.deliverable.id); + return asset && asset.integrity && asset.integrity.algorithm === 'sha256' ? asset.integrity.digest : null; +} + +function approvalGate(manifest, document = emptyApprovalDocument(), options = {}) { + const normalized = normalizeApprovalDocument(document); + const automation = manifest.handoff && manifest.handoff.automation; + const technicalTargets = automation && Array.isArray(automation.targets) ? automation.targets : []; + const targets = technicalTargets.map((target) => { + const context = typeof options.targetContext === 'function' ? options.targetContext(target) || {} : {}; + const assetDigest = targetAssetDigest(manifest, target); + const profileHash = Object.prototype.hasOwnProperty.call(context, 'profileHash') + ? context.profileHash + : target.profileHash || null; + const decision = decisionFor(normalized, target.story, target.target); + const current = !!(decision && assetDigest + && decision.assetDigest === assetDigest + && (decision.profileHash || null) === profileHash); + let status = 'not-ready'; + if (target.status === 'publish-ready' && context.ready !== false && assetDigest) { + status = current ? decision.status : 'awaiting-approval'; + } + return { + target: target.target, + demo: target.demo, + story: target.story, + status, + assetDigest, + ...(profileHash ? { profileHash } : {}), + stale: !!decision && !current, + ...(current ? { + decision: { + status: decision.status, + decidedAt: decision.decidedAt, + ...(decision.note ? { note: decision.note } : {}), + }, + } : {}), + }; + }); + let status = 'not-requested'; + if (technicalTargets.length) { + if (!automation || automation.status !== 'publish-ready') status = 'not-ready'; + else if (targets.some((target) => target.status === 'not-ready')) status = 'not-ready'; + else if (targets.some((target) => target.status === 'changes-requested')) status = 'changes-requested'; + else if (targets.every((target) => target.status === 'approved')) status = 'approved'; + else status = 'awaiting-approval'; + } + return { + required: technicalTargets.length > 0, + status, + file: APPROVAL_FILE, + userActionRequired: status === 'awaiting-approval', + publishable: status === 'approved', + targets, + }; +} + +function syncManifestApproval(manifest, document = emptyApprovalDocument(), options = {}) { + if (!manifest.handoff) manifest.handoff = {}; + manifest.handoff.approval = approvalGate(manifest, document, options); + if (manifest.handoff.summary) { + manifest.handoff.summary.approvedTargetCount = manifest.handoff.approval.targets + .filter((target) => target.status === 'approved').length; + } + return manifest.handoff.approval; +} + +function deliveryStatus(manifest) { + const automation = manifest.handoff && manifest.handoff.automation; + if (!automation || automation.status !== 'publish-ready') return automation ? automation.status : 'not-requested'; + return manifest.handoff.approval ? manifest.handoff.approval.status : 'awaiting-approval'; +} + +module.exports = { + APPROVAL_FILE, + APPROVAL_KIND, + APPROVAL_SCHEMA_ID, + APPROVAL_VERSION, + approvalGate, + approvalPath, + deliveryStatus, + emptyApprovalDocument, + loadApproval, + normalizeApprovalDocument, + normalizeDecision, + syncManifestApproval, + updateApprovalDecision, +}; diff --git a/src/calibrator-server.js b/src/calibrator-server.js index 20ac66d..dce8b73 100644 --- a/src/calibrator-server.js +++ b/src/calibrator-server.js @@ -9,7 +9,13 @@ const { loadCalibration, updateCalibrationProfile, } = require('./calibration'); +const { + loadApproval, + syncManifestApproval, + updateApprovalDecision, +} = require('./approval'); const { normalizeDemoConfigs } = require('./demo'); +const { writeJson } = require('./handoff-files'); const STATIC_DIR = path.join(__dirname, '..', 'calibrator'); const MAX_BODY_BYTES = 256 * 1024; @@ -122,6 +128,50 @@ function profileFor(document, story, target) { : {}; } +function applyCalibrationHashes(manifest, calibrationDocument) { + const targets = manifest.handoff && manifest.handoff.automation + ? manifest.handoff.automation.targets || [] + : []; + for (const target of targets) { + const profile = profileFor(calibrationDocument, target.story, target.target); + if (!target.profileHash && profile.verification) { + const profileHash = calibrationProfileHash(profile); + if (profile.verification.status === 'publish-ready' && profile.verification.profileHash === profileHash) { + target.profileHash = profileHash; + } + } + } +} + +function calibrationApprovalOptions(calibrationDocument) { + return { + targetContext(target) { + const profile = profileFor(calibrationDocument, target.story, target.target); + const saved = hasProfile(calibrationDocument, target.story, target.target); + const profileHash = saved ? calibrationProfileHash(profile) : null; + const verified = !!(saved && profile.verification + && profile.verification.status === 'publish-ready' + && profile.verification.profileHash === profileHash + && (!target.profileHash || target.profileHash === profileHash)); + return { ready: verified, profileHash }; + }, + }; +} + +function syncApprovalManifest(outDir, approvalDocument, calibrationDocument) { + const manifestPath = path.join(outDir, 'shotkit-manifest.json'); + const manifest = readJson(manifestPath); + if (!manifest || !manifest.handoff || !manifest.handoff.automation) return null; + if (calibrationDocument) applyCalibrationHashes(manifest, calibrationDocument); + const gate = syncManifestApproval( + manifest, + approvalDocument, + calibrationDocument ? calibrationApprovalOptions(calibrationDocument) : {}, + ); + writeJson(manifestPath, manifest); + return gate; +} + function hasProfile(document, story, target) { return !!(document.profiles && document.profiles[story] && Object.prototype.hasOwnProperty.call(document.profiles[story], target)); @@ -131,15 +181,23 @@ function createStateReader({ cwd, config }) { const outDir = path.resolve(cwd, config.outDir || 'store-assets'); return function state(selected = {}) { const calibration = loadCalibration(config, cwd); + const approval = loadApproval(outDir); const demos = applyCalibrationProfiles(normalizeDemoConfigs(config), calibration.document) .filter((demo) => demo.target); const manifest = readJson(path.join(outDir, 'shotkit-manifest.json'), {}); + applyCalibrationHashes(manifest, calibration.document); const storyboard = readJson(path.join(outDir, 'storyboard.json'), {}); const captions = readJson(path.join(outDir, 'captions.json'), {}); const assets = manifest.assets || []; const automationTargets = manifest.handoff && manifest.handoff.automation ? manifest.handoff.automation.targets || [] : []; + const approvalGate = syncManifestApproval( + manifest, + approval.document, + calibrationApprovalOptions(calibration.document), + ); + const approvalByKey = new Map((approvalGate.targets || []).map((item) => [`${item.story}::${item.target}`, item])); const storyboardByName = new Map((storyboard.demos || []).map((demo) => [demo.name, demo])); const captionByName = new Map((captions.demos || []).map((demo) => [demo.name, demo])); const lintByName = new Map((storyboard.storyboardLint || []).map((item) => [item.name, item.warnings || []])); @@ -156,6 +214,11 @@ function createStateReader({ cwd, config }) { const directVideo = path.join(outDir, `${demo.name}.mp4`); const directThumbnail = path.join(outDir, `${demo.name}-thumbnail.png`); const publish = automationTargets.find((item) => item.demo === demo.name && item.target === demo.target); + const approvalTarget = approvalByKey.get(`${story}::${demo.target}`) || { + status: 'not-ready', + assetDigest: null, + stale: false, + }; const profile = profileFor(calibration.document, story, demo.target); const savedProfile = hasProfile(calibration.document, story, demo.target); const profileHash = savedProfile ? calibrationProfileHash(profile) : null; @@ -164,13 +227,19 @@ function createStateReader({ cwd, config }) { && profile.verification.status === 'publish-ready' && profile.verification.profileHash === profileHash); const publishStatus = publish ? publish.status : 'not-requested'; + const reviewable = publishStatus === 'publish-ready' && verified && !!approvalTarget.assetDigest; + const reviewStatus = reviewable ? approvalTarget.status : 'not-ready'; + const status = publishStatus === 'publish-ready' + ? reviewable ? reviewStatus : 'needs-fix' + : publishStatus; const videoName = mp4 && mp4.outPath ? path.basename(mp4.outPath) : path.basename(directVideo); const thumbnailName = thumbnail && thumbnail.outPath ? path.basename(thumbnail.outPath) : path.basename(directThumbnail); return { story, target: demo.target, name: demo.name, - status: savedProfile && publishStatus === 'publish-ready' && !verified ? 'needs-fix' : publishStatus, + status, + machineStatus: publishStatus, viewport, safeArea: safeArea(demo.target, viewport), videoUrl: fs.existsSync(path.join(outDir, videoName)) ? `/media/${encodeURIComponent(videoName)}` : null, @@ -183,6 +252,14 @@ function createStateReader({ cwd, config }) { hasProfile: savedProfile, profileHash, verified, + reviewable, + publishable: reviewable && reviewStatus === 'approved', + review: { + status: reviewStatus, + stale: !!approvalTarget.stale, + ...(approvalTarget.decision ? { decision: approvalTarget.decision } : {}), + }, + assetDigest: approvalTarget.assetDigest, }; }); const active = targets.find((item) => item.story === selected.story && item.target === selected.target) || targets[0] || null; @@ -191,6 +268,7 @@ function createStateReader({ cwd, config }) { calibrationPath: calibration.path ? path.relative(cwd, calibration.path) : null, targets, selected: active ? { story: active.story, target: active.target } : null, + approvalStatus: approvalGate.status, }; }; } @@ -245,6 +323,7 @@ async function startCalibrator({ cwd, config, configPath, port = 0, open = true const state = createStateReader({ cwd, config }); let recapturing = false; const outDir = path.resolve(cwd, config.outDir || 'store-assets'); + syncApprovalManifest(outDir, loadApproval(outDir).document, loadCalibration(config, cwd).document); const server = http.createServer(async (req, res) => { try { const url = new URL(req.url || '/', 'http://127.0.0.1'); @@ -259,6 +338,7 @@ async function startCalibrator({ cwd, config, configPath, port = 0, open = true return; } const updated = updateCalibrationProfile(config, cwd, body.story, body.target, body.profile); + syncApprovalManifest(outDir, loadApproval(outDir).document, updated.document); json(res, 200, { ok: true, profile: profileFor(updated.document, body.story, body.target), @@ -289,7 +369,7 @@ async function startCalibrator({ cwd, config, configPath, port = 0, open = true target: body.target, attempt: Math.max(1, currentAttempt + 1), }); - if (result.status === 'publish-ready') { + if (result.machineStatus === 'publish-ready') { const calibration = loadCalibration(config, cwd); const profile = profileFor(calibration.document, body.story, body.target); const profileHash = calibrationProfileHash(profile); @@ -297,7 +377,7 @@ async function startCalibrator({ cwd, config, configPath, port = 0, open = true ...profile, verification: { profileHash, - status: result.status, + status: result.machineStatus, verifiedAt: new Date().toISOString(), }, }); @@ -315,6 +395,41 @@ async function startCalibrator({ cwd, config, configPath, port = 0, open = true } return; } + if (req.method === 'POST' && url.pathname === '/api/review') { + const body = await requestBody(req); + if (!['approved', 'changes-requested'].includes(body.status)) { + json(res, 400, { ok: false, error: 'review status must be approved or changes-requested' }); + return; + } + if (body.status === 'changes-requested' && (typeof body.note !== 'string' || !body.note.trim())) { + json(res, 400, { ok: false, error: 'review feedback is required when changes are requested' }); + return; + } + const current = state({ story: body.story, target: body.target }); + const selected = current.targets.find((item) => item.story === body.story && item.target === body.target); + if (!selected) { + json(res, 404, { ok: false, error: 'configured story/target was not found' }); + return; + } + if (!selected.reviewable) { + json(res, 409, { ok: false, error: 'target must pass machine QA and recapture verification before user review' }); + return; + } + const updated = updateApprovalDecision(outDir, body.story, body.target, { + status: body.status, + assetDigest: selected.assetDigest, + ...(selected.profileHash ? { profileHash: selected.profileHash } : {}), + note: body.note, + }); + syncApprovalManifest(outDir, updated.document, loadCalibration(config, cwd).document); + const refreshed = state({ story: body.story, target: body.target }); + json(res, 200, { + ok: true, + review: refreshed.targets.find((item) => item.story === body.story && item.target === body.target).review, + approvalStatus: refreshed.approvalStatus, + }); + return; + } if (req.method === 'GET' && url.pathname.startsWith('/media/')) { const name = decodeURIComponent(url.pathname.slice('/media/'.length)); if (!name || path.basename(name) !== name || !/\.(mp4|webm|png|jpe?g)$/i.test(name)) { diff --git a/src/capture.js b/src/capture.js index 8ce89f1..445d613 100644 --- a/src/capture.js +++ b/src/capture.js @@ -40,6 +40,7 @@ const { postProcessDemo, probeVideo } = require('./video'); const { analyzeDemoStoryboard, createDemoController, installDemoCaptionOverlay, normalizeDemoConfigs } = require('./demo'); const { analyzeDemoCaptionMetrics } = require('./demo-caption-qa'); const { applyCalibrationProfiles, loadCalibration } = require('./calibration'); +const { deliveryStatus } = require('./approval'); const { assetRecord, writeHandoffDocs } = require('./handoff'); const { analyzePng } = require('./image-qa'); @@ -121,7 +122,7 @@ function usageError(message) { * @param {boolean} [opts.freeze] passed to config hooks as flags.freeze * @param {string} [opts.cwd] project root for build / outDir / listing sources * @param {(msg:string)=>void} [opts.log] - * @returns {Promise<{produced: string[], outDir: string, manifest: string|null, status:string}>} + * @returns {Promise<{produced: string[], outDir: string, manifest: string|null, status:string, machineStatus:string}>} */ async function capture(config, opts = {}) { const cwd = opts.cwd || process.cwd(); @@ -139,6 +140,7 @@ async function capture(config, opts = {}) { const produced = []; let manifest = null; let status = 'not-requested'; + let machineStatus = 'not-requested'; const assets = []; const calibration = loadCalibration(config, cwd); const demoConfigs = applyCalibrationProfiles(normalizeDemoConfigs(config), calibration.document); @@ -546,15 +548,16 @@ async function capture(config, opts = {}) { produced.push(...handoffPaths); manifest = path.join(outDir, 'shotkit-manifest.json'); const handoff = JSON.parse(fs.readFileSync(manifest, 'utf8')); - status = handoff.handoff && handoff.handoff.automation + machineStatus = handoff.handoff && handoff.handoff.automation ? handoff.handoff.automation.status : 'not-requested'; - log(`automation: ${status}`); + status = deliveryStatus(handoff); + log(`automation: ${machineStatus}; delivery: ${status}`); for (const out of handoffPaths) log(`✓ ${path.basename(out)}`); } log(`done — ${produced.length} asset(s) in ${path.relative(cwd, outDir) || '.'}/`); - return { produced, outDir, manifest, status }; + return { produced, outDir, manifest, status, machineStatus }; } finally { await cleanupTempResources(); } diff --git a/src/cli-runner.js b/src/cli-runner.js index 824a101..a276431 100644 --- a/src/cli-runner.js +++ b/src/cli-runner.js @@ -57,8 +57,14 @@ async function runCli(argv, io = {}, deps = {}) { return 0; } const log = opts.json ? (m) => stderr.write(`[shotkit] ${m}\n`) : undefined; - const { produced, outDir, manifest = null, status = 'not-requested' } = await capture(config, { ...opts, cwd, log }); - if (opts.json) writeJson(stdout, { ok: true, status, outDir, manifest, produced }); + const { + produced, + outDir, + manifest = null, + status = 'not-requested', + machineStatus = 'not-requested', + } = await capture(config, { ...opts, cwd, log }); + if (opts.json) writeJson(stdout, { ok: true, status, machineStatus, outDir, manifest, produced }); return 0; } catch (err) { const msg = err && err.message ? err.message : String(err); diff --git a/src/cli.js b/src/cli.js index 770de88..a07723e 100644 --- a/src/cli.js +++ b/src/cli.js @@ -10,7 +10,7 @@ const fs = require('fs'); const path = require('path'); -const USAGE = `shotkit — autonomously build publish-ready launch assets from a browser extension +const USAGE = `shotkit — autonomously build and verify launch assets, then gate final user approval Usage: shotkit [path] [options] @@ -30,7 +30,9 @@ Options: --port calibrator port (default: choose an available local port) --no-open start the calibrator without opening a browser window --json machine-readable mode: stdout gets one JSON object - {ok, status, outDir, manifest, produced[]}; logs go to stderr + {ok, status, machineStatus, outDir, manifest, produced[]}; + logs go to stderr. machineStatus is technical QA; status + also enforces the user's final approval decision. --no-video skip the demo screencast --mp4 also convert the demo to H.264 mp4 (needs ffmpeg on PATH or SHOTKIT_FFMPEG; SNS uploaders want mp4, not webm) diff --git a/src/demo.js b/src/demo.js index 18318e5..6ab9b14 100644 --- a/src/demo.js +++ b/src/demo.js @@ -341,6 +341,7 @@ function demoCaptionInitScript(options = {}) { return { text: String(text), sourceText: String(nextOptions.fullText || text), + renderedAt: Date.now(), mode: root.dataset.mode, appearance: root.dataset.appearance, rect: { @@ -405,8 +406,7 @@ async function ensureDemoCaptionOverlay(page, options = {}) { await page.evaluate(demoCaptionInitScript, options); } -async function setDemoCaption(page, text, options = {}) { - await ensureDemoCaptionOverlay(page, options); +async function renderDemoCaption(page, text, options = {}) { return page.evaluate( ({ captionText, captionOptions }) => { return window.__shotkitDemoCaption.show(captionText, captionOptions); @@ -418,6 +418,11 @@ async function setDemoCaption(page, text, options = {}) { ); } +async function setDemoCaption(page, text, options = {}) { + await ensureDemoCaptionOverlay(page, options); + return renderDemoCaption(page, text, options); +} + async function hideDemoCaption(page) { await page.evaluate(() => { if (window.__shotkitDemoCaption) window.__shotkitDemoCaption.hide(); @@ -488,6 +493,7 @@ function createDemoController({ page, captions = [], captionOptions = {} }) { let activeText = ''; let activeOptions = {}; let activeExpectedAtMs = null; + let overlayReady = false; let stopped = false; async function render(text, options = {}, expectedAtMs = null) { @@ -499,17 +505,25 @@ function createDemoController({ page, captions = [], captionOptions = {} }) { captionStyle(nextOptions); try { if (activeText) { - const sample = await setDemoCaption(page, activeText, nextOptions); + if (!overlayReady) { + await ensureDemoCaptionOverlay(page, nextOptions); + overlayReady = true; + } + const sample = await renderDemoCaption(page, activeText, nextOptions); if (sample) { + const { renderedAt, ...metrics } = sample; captionSamples.push({ - ...sample, + ...metrics, expectedAtMs, - actualAtMs: Date.now() - startedAt, + actualAtMs: Number.isFinite(renderedAt) + ? Math.max(0, Math.round(renderedAt - startedAt)) + : Date.now() - startedAt, }); } } else await hideDemoCaption(page); } catch (_e) { + overlayReady = false; // Navigations can briefly destroy the execution context. The next helper // call or DOMContentLoaded replay will render the latest caption. } @@ -517,6 +531,7 @@ function createDemoController({ page, captions = [], captionOptions = {} }) { const replay = () => { if (!activeText || stopped) return; + overlayReady = false; setTimeout(() => render(activeText, activeOptions, activeExpectedAtMs), 0); }; page.on('domcontentloaded', replay); diff --git a/src/handoff.js b/src/handoff.js index c84c13a..4ed27f6 100644 --- a/src/handoff.js +++ b/src/handoff.js @@ -2,8 +2,8 @@ * shotkit — handoff contract exports. * * These files are the autonomous machine boundary: captured evidence, - * captions, integrity, target QA, and agent-owned fix/retry actions. Users see - * final publish-ready assets or an exhausted blocker, not routine review work. + * captions, integrity, target QA, agent-owned fix/retry actions, and the final + * user approval gate. Users review media, not manifests or repair mechanics. */ const fs = require('fs'); @@ -13,6 +13,12 @@ const { normalizeDemoCaptions, parseTimeToMs } = require('./demo-time'); const { buildCaptionFrames, buildCaptionTimeline, captionStyle } = require('./demo-caption-focus'); const { buildHandoffRecommendations } = require('./integrations'); const { buildPublishPlan } = require('./publish'); +const { + APPROVAL_SCHEMA_ID, + emptyApprovalDocument, + loadApproval, + syncManifestApproval, +} = require('./approval'); const { isValidHandoffDocs, validateHandoffDocs } = require('./handoff-validator'); const { copyHandoffSchemas, @@ -34,11 +40,13 @@ const HANDOFF_SCHEMA_IDS = Object.freeze({ manifest: 'urn:starter-series:shotkit:schema:shotkit-manifest:v1', storyboard: 'urn:starter-series:shotkit:schema:storyboard:v1', captions: 'urn:starter-series:shotkit:schema:captions:v1', + approval: APPROVAL_SCHEMA_ID, }); const HANDOFF_SCHEMA_FILES = Object.freeze({ manifest: 'schemas/shotkit-manifest.schema.json', storyboard: 'schemas/storyboard.schema.json', captions: 'schemas/captions.schema.json', + approval: 'schemas/approval.schema.json', }); function readProjectInfo(cwd) { @@ -240,7 +248,7 @@ function handoffReview(storyboardLint, run = {}, assets = []) { }; } -function refreshManifestHandoff(manifest, storyboard, config) { +function refreshManifestHandoff(manifest, storyboard, config, approvalDocument = emptyApprovalDocument()) { const automation = buildPublishPlan({ assets: manifest.assets, storyboard, @@ -254,12 +262,14 @@ function refreshManifestHandoff(manifest, storyboard, config) { }); manifest.handoff.adapterHints = adapterHints; manifest.handoff.automation = automation; + syncManifestApproval(manifest, approvalDocument); manifest.handoff.review = handoffReview(storyboard.storyboardLint, manifest.run, manifest.assets); manifest.handoff.summary = { assetCount: manifest.assets.length, demoCount: storyboard.demos.length, readyAdapterCount: adapterHints.filter((hint) => hint.readiness === 'ready').length, publishReadyTargetCount: automation.targets.filter((target) => target.status === 'publish-ready').length, + approvedTargetCount: manifest.handoff.approval.targets.filter((target) => target.status === 'approved').length, }; } @@ -328,11 +338,13 @@ function buildHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewpo recommendedFlow: [ 'read handoff.automation and apply every agent-owned action', 'retry listed scenes until every requested target is publish-ready', - 'upload final deliverables through an authorized connector', + 'present technically ready media to the user for final approval', + 'upload only the exact deliverable digest the user approved', 'keep repo fixtures and storyboard as the repeatable source of truth', ], adapterHints: [], automation: null, + approval: null, review: handoffReview(storyboard.storyboardLint, runInfo), summary: { assetCount: currentAssets.length, @@ -442,7 +454,7 @@ function writeHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewpo // Partial runs can inherit an older inventory. Prune entries whose files no // longer exist, then recompute recommendations from the actual final bundle. docs.manifest.assets = hydrateManifestAssets(docs.manifest.assets, outDir, manifestPath); - refreshManifestHandoff(docs.manifest, docs.storyboard, config); + refreshManifestHandoff(docs.manifest, docs.storyboard, config, loadApproval(outDir).document); validateHandoffDocs(docs); validateFinalPack(docs, outDir, manifestPath); writeJson(manifestPath, docs.manifest); diff --git a/src/index.js b/src/index.js index b4b0b1f..7037a82 100644 --- a/src/index.js +++ b/src/index.js @@ -3,8 +3,8 @@ * * shotkit turns a BUILT browser extension (or any HTML) into automatically * validated channel assets. Playwright captures reusable stories; channel - * profiles, final-file QA, and agent-owned retry actions drive them to - * publish-ready through the CLI, capture(), and agent-readable skills. + * profiles, final-file QA, and agent-owned retry actions drive them to a + * technically verified candidate; an explicit user decision gates publication. * * Config authors typically use `capture` indirectly (via the CLI) and import * the helpers below inside their `shotkit.config.js` to set up scenes: diff --git a/src/publish.js b/src/publish.js index 736ab18..ce420f8 100644 --- a/src/publish.js +++ b/src/publish.js @@ -136,6 +136,7 @@ function targetPublishPlan({ demo, lint, assets, skipped }) { delivery: profile.delivery, demo: demo.name, story: demo.story || demo.name, + ...(demo.calibration && demo.calibration.profileHash ? { profileHash: demo.calibration.profileHash } : {}), status: actions.length ? 'needs-fix' : 'publish-ready', deliverable: assetRef(mp4), thumbnail: assetRef(thumbnail), diff --git a/test/approval.test.js b/test/approval.test.js new file mode 100644 index 0000000..21c134e --- /dev/null +++ b/test/approval.test.js @@ -0,0 +1,145 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + approvalGate, + deliveryStatus, + emptyApprovalDocument, + loadApproval, + syncManifestApproval, + updateApprovalDecision, +} = require('../src/approval'); + +const DIGEST = 'a'.repeat(64); + +function manifest(targetStatus = 'publish-ready') { + return { + assets: [{ id: 'sns-demo-mp4:demo-shorts', integrity: { algorithm: 'sha256', digest: DIGEST } }], + handoff: { + automation: { + status: targetStatus === 'publish-ready' ? 'publish-ready' : 'needs-fix', + targets: [{ + target: 'youtube-shorts', + story: 'demo', + demo: 'demo-shorts', + status: targetStatus, + profileHash: 'profile-1', + deliverable: { id: 'sns-demo-mp4:demo-shorts' }, + }], + }, + summary: {}, + }, + }; +} + +function decision(status, overrides = {}) { + return { + status, + assetDigest: DIGEST, + profileHash: 'profile-1', + decidedAt: '2026-07-10T00:00:00.000Z', + ...(status === 'changes-requested' ? { note: 'Move the result higher.' } : {}), + ...overrides, + }; +} + +describe('user approval gate', () => { + test('holds a technically ready target for user review', () => { + const value = manifest(); + syncManifestApproval(value, emptyApprovalDocument()); + expect(value.handoff.approval).toMatchObject({ + status: 'awaiting-approval', + userActionRequired: true, + publishable: false, + targets: [{ status: 'awaiting-approval', stale: false }], + }); + expect(deliveryStatus(value)).toBe('awaiting-approval'); + }); + + test.each([ + ['approved', 'approved', true], + ['changes-requested', 'changes-requested', false], + ])('applies a current %s decision', (decisionStatus, expectedStatus, publishable) => { + const value = manifest(); + const document = emptyApprovalDocument(); + document.decisions.demo = { 'youtube-shorts': decision(decisionStatus) }; + const gate = approvalGate(value, document); + expect(gate).toMatchObject({ status: expectedStatus, publishable }); + expect(gate.targets[0]).toMatchObject({ status: expectedStatus, stale: false }); + }); + + test('expires approval when the deliverable digest changes', () => { + const value = manifest(); + const document = emptyApprovalDocument(); + document.decisions.demo = { + 'youtube-shorts': decision('approved', { assetDigest: 'b'.repeat(64) }), + }; + expect(approvalGate(value, document)).toMatchObject({ + status: 'awaiting-approval', + targets: [{ status: 'awaiting-approval', stale: true }], + }); + }); + + test('does not replace technical failure with an approval state', () => { + const value = manifest('needs-fix'); + syncManifestApproval(value, emptyApprovalDocument()); + expect(value.handoff.approval.status).toBe('not-ready'); + expect(deliveryStatus(value)).toBe('needs-fix'); + }); + + test('keeps a technically ready target not-ready when its deliverable is missing', () => { + const value = manifest(); + value.assets = []; + expect(approvalGate(value, emptyApprovalDocument())).toMatchObject({ + status: 'not-ready', + userActionRequired: false, + publishable: false, + targets: [{ status: 'not-ready', assetDigest: null }], + }); + }); + + test('invalidates approval when current profile context is unverified', () => { + const value = manifest(); + const document = emptyApprovalDocument(); + document.decisions.demo = { 'youtube-shorts': decision('approved') }; + const gate = approvalGate(value, document, { + targetContext: () => ({ ready: false, profileHash: 'profile-2' }), + }); + expect(gate).toMatchObject({ + status: 'not-ready', + publishable: false, + targets: [{ status: 'not-ready', profileHash: 'profile-2', stale: true }], + }); + }); + + test('atomically writes and reloads user feedback', () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-approval-')); + try { + updateApprovalDecision(outDir, 'demo', 'youtube-shorts', { + status: 'changes-requested', + assetDigest: DIGEST, + profileHash: 'profile-1', + note: 'Keep the CTA clear.', + }); + expect(loadApproval(outDir).document.decisions.demo['youtube-shorts']).toMatchObject({ + status: 'changes-requested', + note: 'Keep the CTA clear.', + }); + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); + + test('rejects reserved decision keys', () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-approval-')); + try { + expect(() => updateApprovalDecision(outDir, '__proto__', 'youtube-shorts', { + status: 'approved', + assetDigest: DIGEST, + })).toThrow('uses a reserved key'); + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/calibrator-server.test.js b/test/calibrator-server.test.js index 4d6adfa..7894a6c 100644 --- a/test/calibrator-server.test.js +++ b/test/calibrator-server.test.js @@ -4,6 +4,8 @@ const path = require('path'); const { safeStaticPath, startCalibrator } = require('../src/calibrator-server'); +const DIGEST = 'a'.repeat(64); + function writeJson(filePath, value) { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); } @@ -16,15 +18,24 @@ function projectFixture() { fs.writeFileSync(path.join(outDir, `${name}.mp4`), Buffer.from('0123456789')); writeJson(path.join(outDir, 'shotkit-manifest.json'), { assets: [{ + id: `sns-demo-mp4:${name}`, role: 'sns-demo-mp4', type: 'video', outPath: `${name}.mp4`, source: { kind: 'demo', name, story: 'demo', target: 'youtube-shorts' }, + integrity: { algorithm: 'sha256', digest: DIGEST }, }], handoff: { automation: { attempt: 1, - targets: [{ demo: name, target: 'youtube-shorts', status: 'publish-ready' }], + status: 'publish-ready', + targets: [{ + demo: name, + story: 'demo', + target: 'youtube-shorts', + status: 'publish-ready', + deliverable: { id: `sns-demo-mp4:${name}` }, + }], }, }, }); @@ -70,7 +81,8 @@ describe('calibrator server', () => { story: 'demo', target: 'youtube-shorts', name, - status: 'publish-ready', + status: 'needs-fix', + machineStatus: 'publish-ready', hasProfile: false, verified: false, videoUrl: `/media/${name}.mp4`, @@ -107,6 +119,15 @@ describe('calibrator server', () => { profileHash: saved.profileHash, }); expect(readProfile(cwd).layoutPreset).toBe('focus-column'); + const savedManifest = JSON.parse(fs.readFileSync( + path.join(cwd, 'store-assets', 'shotkit-manifest.json'), + 'utf8', + )); + expect(savedManifest.handoff.approval).toMatchObject({ + status: 'not-ready', + userActionRequired: false, + publishable: false, + }); const calibrationPath = path.join(cwd, 'shotkit.calibration.json'); const calibration = JSON.parse(fs.readFileSync(calibrationPath, 'utf8')); @@ -116,6 +137,61 @@ describe('calibrator server', () => { verifiedAt: '2026-07-10T00:00:00.000Z', }; writeJson(calibrationPath, calibration); + + const awaiting = await fetch(`${calibrator.url}/api/state`).then((response) => response.json()); + expect(awaiting.targets[0]).toMatchObject({ + status: 'awaiting-approval', + machineStatus: 'publish-ready', + reviewable: true, + publishable: false, + review: { status: 'awaiting-approval' }, + }); + + const approved = await fetch(`${calibrator.url}/api/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ story: 'demo', target: 'youtube-shorts', status: 'approved' }), + }).then((response) => response.json()); + expect(approved).toMatchObject({ ok: true, approvalStatus: 'approved', review: { status: 'approved' } }); + const approvedState = await fetch(`${calibrator.url}/api/state`).then((response) => response.json()); + expect(approvedState.targets[0]).toMatchObject({ status: 'approved', publishable: true }); + + const requested = await fetch(`${calibrator.url}/api/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + story: 'demo', + target: 'youtube-shorts', + status: 'changes-requested', + note: 'Move the result higher.', + }), + }).then((response) => response.json()); + expect(requested).toMatchObject({ + ok: true, + approvalStatus: 'changes-requested', + review: { status: 'changes-requested', decision: { note: 'Move the result higher.' } }, + }); + + await fetch(`${calibrator.url}/api/profile`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + story: 'demo', + target: 'youtube-shorts', + profile: { + ...readProfile(cwd), + captionOptions: { position: 'bottom-left', appearance: 'outline', bottomOffset: 420 }, + verification: undefined, + }, + }), + }); + const stale = await fetch(`${calibrator.url}/api/state`).then((response) => response.json()); + expect(stale.targets[0]).toMatchObject({ + status: 'needs-fix', + reviewable: false, + review: { status: 'not-ready', stale: true }, + }); + const manifestPath = path.join(cwd, 'store-assets', 'shotkit-manifest.json'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); manifest.handoff.automation.targets[0].status = 'needs-fix'; @@ -125,6 +201,7 @@ describe('calibrator server', () => { expect(failedRecapture.targets[0]).toMatchObject({ status: 'needs-fix', verified: false }); } finally { await calibrator.close(); + fs.rmSync(cwd, { recursive: true, force: true }); } }); }); diff --git a/test/capture-description.test.js b/test/capture-description.test.js index 230ea1c..b723198 100644 --- a/test/capture-description.test.js +++ b/test/capture-description.test.js @@ -111,6 +111,7 @@ test('description-only scene does not run build, prepareExtension, or Chromium', 'shotkit-manifest.schema.json', 'storyboard.schema.json', 'captions.schema.json', + 'approval.schema.json', ]); expect(prepareExtension).not.toHaveBeenCalled(); expect(launchWithExtension).not.toHaveBeenCalled(); diff --git a/test/cli-runner.test.js b/test/cli-runner.test.js index d80cf1c..bc35b66 100644 --- a/test/cli-runner.test.js +++ b/test/cli-runner.test.js @@ -28,7 +28,8 @@ describe('runCli', () => { return { outDir: path.join(cwd, 'store-assets'), manifest: path.join(cwd, 'store-assets', 'shotkit-manifest.json'), - status: 'publish-ready', + status: 'awaiting-approval', + machineStatus: 'publish-ready', produced: [path.join(cwd, 'store-assets', 'a.png')], }; }); @@ -45,7 +46,8 @@ describe('runCli', () => { expect(code).toBe(0); expect(JSON.parse(stdout.read())).toEqual({ ok: true, - status: 'publish-ready', + status: 'awaiting-approval', + machineStatus: 'publish-ready', outDir: path.join(cwd, 'store-assets'), manifest: path.join(cwd, 'store-assets', 'shotkit-manifest.json'), produced: [path.join(cwd, 'store-assets', 'a.png')], diff --git a/test/demo.test.js b/test/demo.test.js index 53d12c6..00ee6e2 100644 --- a/test/demo.test.js +++ b/test/demo.test.js @@ -20,6 +20,7 @@ class FakePage extends EventEmitter { super(); this.captions = []; this.captionCalls = []; + this.captionSample = null; this.clicks = []; this.waits = []; this.inits = []; @@ -43,6 +44,7 @@ class FakePage extends EventEmitter { if (arg && Object.prototype.hasOwnProperty.call(arg, 'captionText')) { this.captions.push(arg.captionText); this.captionCalls.push({ text: arg.captionText, options: arg.captionOptions }); + return this.captionSample; } if (arg && Object.prototype.hasOwnProperty.call(arg, 'pointerPoint')) { this.pointerMoves.push({ point: arg.pointerPoint, options: arg.pointerOptions }); @@ -324,6 +326,24 @@ describe('createDemoController', () => { expect(page.captions).toEqual(['Open the course page']); }); + test('records the browser render timestamp instead of host round-trip completion', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-10T00:00:00.000Z')); + const page = new FakePage(); + page.captionSample = { + renderedAt: Date.now() + 125, + rect: { left: 10, top: 20, right: 110, bottom: 60, width: 100, height: 40 }, + }; + const demo = createDemoController({ page }); + + await demo.caption('Rendered in the page'); + const report = demo.captionMetrics(); + demo.stop(); + + expect(report.samples[0]).toMatchObject({ actualAtMs: 125 }); + expect(report.samples[0]).not.toHaveProperty('renderedAt'); + }); + test('step, click, and wait keep config walkthroughs compact', async () => { const page = new FakePage(); const demo = createDemoController({ page }); diff --git a/test/handoff.test.js b/test/handoff.test.js index 2c587ac..6650389 100644 --- a/test/handoff.test.js +++ b/test/handoff.test.js @@ -108,6 +108,7 @@ describe('handoff contract', () => { manifest: 'schemas/shotkit-manifest.schema.json', storyboard: 'schemas/storyboard.schema.json', captions: 'schemas/captions.schema.json', + approval: 'schemas/approval.schema.json', }, storyboards: 'storyboard.json', captions: 'captions.json', @@ -168,6 +169,7 @@ describe('handoff contract', () => { 'schemas/shotkit-manifest.schema.json', 'schemas/storyboard.schema.json', 'schemas/captions.schema.json', + 'schemas/approval.schema.json', ]); for (const filePath of paths) expect(fs.existsSync(filePath)).toBe(true); const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'shotkit-manifest.json'), 'utf8')); @@ -178,12 +180,14 @@ describe('handoff contract', () => { 'handoff-schema', 'handoff-schema', 'handoff-schema', + 'handoff-schema', ]); expect(manifest.handoff.summary).toEqual({ - assetCount: 6, + assetCount: 7, demoCount: 0, readyAdapterCount: 0, publishReadyTargetCount: 0, + approvedTargetCount: 0, }); const storyboard = manifest.assets.find((asset) => asset.role === 'storyboard-contract'); expect(storyboard.bytes).toBeGreaterThan(0); @@ -460,5 +464,12 @@ describe('handoff contract', () => { }); expect(manifest.handoff.adapterHints).toEqual([]); expect(manifest.handoff.summary.publishReadyTargetCount).toBe(1); + expect(manifest.handoff.approval).toMatchObject({ + status: 'awaiting-approval', + userActionRequired: true, + publishable: false, + targets: [{ target: 'x', status: 'awaiting-approval' }], + }); + expect(manifest.handoff.summary.approvedTargetCount).toBe(0); }); }); diff --git a/test/publish.test.js b/test/publish.test.js index 60ebd06..d20f84d 100644 --- a/test/publish.test.js +++ b/test/publish.test.js @@ -21,7 +21,7 @@ function storyboard(warnings = []) { } describe('autonomous publish plan', () => { - test('marks a fully probed variant publish-ready without human review', () => { + test('marks a fully probed variant technically publish-ready for user review', () => { const plan = buildPublishPlan({ storyboard: storyboard(), run: {}, diff --git a/test/schema-validation.test.js b/test/schema-validation.test.js index c45be9e..4145947 100644 --- a/test/schema-validation.test.js +++ b/test/schema-validation.test.js @@ -119,6 +119,27 @@ describe('handoff docs conform to the published JSON schemas', () => { expect(validate(document)).toBe(true); }); + it('validates the user approval document contract', () => { + const validate = ajv.compile(loadSchema('approval.schema.json')); + const document = { + $schema: 'urn:starter-series:shotkit:schema:approval:v1', + version: 1, + kind: 'shotkit.approval', + decisions: { + launch: { + 'youtube-shorts': { + status: 'changes-requested', + assetDigest: 'a'.repeat(64), + profileHash: 'profile-v1', + decidedAt: '2026-07-10T00:00:00.000Z', + note: 'Move the result above the caption lane.', + }, + }, + }, + }; + expect(validate(document)).toBe(true); + }); + it('keeps additive manifest fields compatible with the original v1 shape', () => { const legacy = JSON.parse(JSON.stringify(docs.manifest)); delete legacy.category; diff --git a/test/schema.test.js b/test/schema.test.js index 47ebe02..736398b 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -11,12 +11,14 @@ describe('packaged handoff schemas', () => { expect(readSchema('shotkit-manifest.schema.json').$id).toBe(HANDOFF_SCHEMA_IDS.manifest); expect(readSchema('storyboard.schema.json').$id).toBe(HANDOFF_SCHEMA_IDS.storyboard); expect(readSchema('captions.schema.json').$id).toBe(HANDOFF_SCHEMA_IDS.captions); + expect(readSchema('approval.schema.json').$id).toBe(HANDOFF_SCHEMA_IDS.approval); }); test('schemas declare the expected document kinds', () => { expect(readSchema('shotkit-manifest.schema.json').properties.kind.const).toBe('shotkit.manifest'); expect(readSchema('storyboard.schema.json').properties.kind.const).toBe('shotkit.storyboard'); expect(readSchema('captions.schema.json').properties.kind.const).toBe('shotkit.captions'); + expect(readSchema('approval.schema.json').properties.kind.const).toBe('shotkit.approval'); }); test('v1 keeps its constants and adds the agent-ready category compatibly', () => { From 983b1a7e770903debdd4a11f9d6150e09af0147d Mon Sep 17 00:00:00 2001 From: heznpc Date: Sat, 11 Jul 2026 02:50:59 +0900 Subject: [PATCH 10/13] feat: add campaign recipe dashboard --- README.ko.md | 19 +- README.md | 34 +++ calibrator/app.js | 11 +- calibrator/index.html | 9 + calibrator/styles.css | 18 +- campaign/app.js | 523 ++++++++++++++++++++++++++++++++ campaign/index.html | 204 +++++++++++++ campaign/styles.css | 468 ++++++++++++++++++++++++++++ eslint.config.js | 2 +- package.json | 6 +- scripts/verify-pack.mjs | 5 +- skills/capture/SKILL.md | 7 +- src/approval.js | 33 +- src/calibration-verification.js | 52 ++++ src/calibrator-server.js | 377 +++++++++++++++++++---- src/campaign-dashboard.js | 216 +++++++++++++ src/campaign.js | 176 +++++++++++ src/cli-runner.js | 9 +- src/cli.js | 8 +- test/approval.test.js | 22 ++ test/calibrator-server.test.js | 386 ++++++++++++++++++++++- test/campaign.test.js | 135 +++++++++ test/cli-runner.test.js | 38 +++ test/cli.test.js | 14 +- test/package-boundary.test.js | 1 + 25 files changed, 2690 insertions(+), 83 deletions(-) create mode 100644 campaign/app.js create mode 100644 campaign/index.html create mode 100644 campaign/styles.css create mode 100644 src/calibration-verification.js create mode 100644 src/campaign-dashboard.js create mode 100644 src/campaign.js create mode 100644 test/campaign.test.js diff --git a/README.ko.md b/README.ko.md index 62c1483..dc04c4c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -62,6 +62,7 @@ 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 # 이미 빌드된 번들 사용 @@ -70,6 +71,19 @@ shotkit ../my-extension --json # 다른 체크아웃 대상 실행; 결과 JSON 산출물은 `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`에 추적할 @@ -82,8 +96,9 @@ JSON만 기록합니다. 현재 profile로 실제 story를 재촬영해 `publish 이는 상시 수동 검수나 범용 timeline/layer editor가 아니라 예외 구도용 calibration surface입니다. 에이전트가 이 제어면에서 구도를 수정하고 기술 QA를 -통과시키면, 사용자는 같은 대시보드에서 최종 영상만 확인해 Approve 또는 -Request changes를 선택합니다. +통과시키면, 사용자는 Campaign Dashboard에서 최종 영상만 확인해 Approve 또는 +Request changes를 선택합니다. 기존 Calibrator는 Advanced/Debug 도구로 그대로 +남습니다. ### Handoff Pack diff --git a/README.md b/README.md index 1ecbd6d..adb257a 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ shotkit # produce everything into outDir shotkit --scene 01-feature # just one scene/promoTile/demo/demos entry, "description", or "privacy" shotkit --target x # render/retry only the configured X variants shotkit --attempt 2 --json # next autonomous fix attempt +shotkit --campaign # choose a recipe, produce, and review final media shotkit --calibrate # open the local composition calibrator shotkit --no-video # skip the screencast (faster/CI) shotkit --no-build # use an already-built bundle @@ -79,6 +80,39 @@ shotkit ../my-extension --json # run against another checkout; JSON result on s Outputs land in `outDir` (default `store-assets/`): `.png`, `.png`, `.webm`, optional `.mp4`, optional `-thumbnail.png`, `description.md`, optional `privacy-disclosure.md`, and, by default, `storyboard.json`, `captions.json`, `shotkit-manifest.json`, plus four schemas under `schemas/` (`handoff: false` disables the handoff pack). The first review decision creates `shotkit-approval.json`. +### Campaign Dashboard + +Run `shotkit --campaign` to open the local campaign dashboard. A channel-targeted +story becomes a Campaign Recipe automatically; the recipe owns its configured +channel profiles, so the user selects one story rather than assembling outputs +one by one. The dashboard starts production, follows agent-owned capture and QA, +and presents the resulting media for the user's digest-bound Approve or Request +changes decision. + +Recipe labels can be added without changing the existing demo contract: + +```js +module.exports = { + campaign: { + defaultRecipe: 'before-after-proof', + recipes: [{ + id: 'before-after-proof', + name: 'Before / After Proof', + description: 'Show the original workflow, the product action, and the verified result.', + story: 'launch-story', + targets: ['cws-youtube', 'x', 'youtube-shorts'], + }], + }, +}; +``` + +The existing Calibrator remains available through `shotkit --calibrate` and the +dashboard's Advanced control. Campaign selection is stored separately in +`shotkit-campaign.json`; it does not rewrite config source or replace the +manifest, calibration, or approval contracts. Projects without +`config.calibration` can still use the Campaign Dashboard; Advanced is exposed +only when calibration is configured. + ### Composition Calibrator When automated retries cannot settle a vertical composition, a repo can expose diff --git a/calibrator/app.js b/calibrator/app.js index 3de8473..f041f5c 100644 --- a/calibrator/app.js +++ b/calibrator/app.js @@ -165,7 +165,12 @@ import { createRegionEditor } from './regions.js'; async function loadState(preferredKey = state.selectedKey) { setBusy(true, 'Loading'); try { - state.document = await api('/api/state'); + 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); @@ -390,7 +395,9 @@ import { createRegionEditor } from './regions.js'; story: state.target.story, target: state.target.target, status, - ...(note ? { note } : {}), + assetDigest: state.target.assetDigest, + ...(state.target.profileHash ? { profileHash: state.target.profileHash } : {}), + ...(status === 'changes-requested' ? { note } : {}), }), }); await loadState(selectedKey); diff --git a/calibrator/index.html b/calibrator/index.html index c26647b..6fbfdbd 100644 --- a/calibrator/index.html +++ b/calibrator/index.html @@ -25,6 +25,9 @@
+ + + + + + + +
+ Connecting + + + +
+ + +
+
+
+
+

Campaign recipe

+

Select a story

+
+ 0 recipes +
+ +
+
+ + +
+
+ + + + + + +
+
+ + + + + + + + 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/eslint.config.js b/eslint.config.js index 25bb515..07e3bbb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,7 +25,7 @@ module.exports = [ languageOptions: { sourceType: 'module' }, }, { - files: ['calibrator/**/*.js'], + files: ['calibrator/**/*.js', 'campaign/**/*.js'], languageOptions: { sourceType: 'module' }, }, { diff --git a/package.json b/package.json index b2a9285..90a4ed1 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,13 @@ "src", "bin", "calibrator", + "campaign", "skills/capture", "docs/handoff-conventions.md", "schemas" ], "scripts": { - "lint": "eslint src/ bin/ calibrator/ scripts/ test/", + "lint": "eslint src/ bin/ calibrator/ campaign/ scripts/ test/", "research": "node scripts/research-to-product-fit.mjs", "test": "jest --verbose --coverage", "pack:check": "node scripts/verify-pack.mjs", @@ -70,6 +71,9 @@ "src/approval.js", "src/calibration.js", "src/calibrator-server.js", + "src/campaign.js", + "src/campaign-dashboard.js", + "src/calibration-verification.js", "src/presets.js", "src/describe.js", "src/extension.js", diff --git a/scripts/verify-pack.mjs b/scripts/verify-pack.mjs index 5cce3d8..451810a 100644 --- a/scripts/verify-pack.mjs +++ b/scripts/verify-pack.mjs @@ -23,6 +23,9 @@ const requiredFiles = [ "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", @@ -55,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\/|calibrator\/|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 2ff936e..d4b2b83 100644 --- a/skills/capture/SKILL.md +++ b/skills/capture/SKILL.md @@ -57,7 +57,7 @@ rendered from the shipped code. By default, it also writes a handoff pack: 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, use the same dashboard for the user's final media decision. + 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 --attempt 1`, or use a project wrapper such as @@ -80,8 +80,9 @@ rendered from the shipped code. By default, it also writes a handoff pack: `--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 Calibrator and present - the rendered candidate to the user. Do not approve on the user's behalf. + - `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. diff --git a/src/approval.js b/src/approval.js index 7858d7f..1d55d7a 100644 --- a/src/approval.js +++ b/src/approval.js @@ -93,17 +93,33 @@ function loadApproval(outDir) { } } -function updateApprovalDecision(outDir, story, target, decision) { - story = approvalKey(story, 'approval story'); - target = approvalKey(target, 'approval target'); +function updateApprovalDecisions(outDir, updates, now = () => new Date()) { + if (!Array.isArray(updates) || !updates.length) { + throw new Error('shotkit: approval updates must be a non-empty array'); + } + const decidedAt = now().toISOString(); + const normalizedUpdates = updates.map((update, index) => ({ + story: approvalKey(update.story, `approval updates[${index}].story`), + target: approvalKey(update.target, `approval updates[${index}].target`), + decision: normalizeDecision({ + ...update.decision, + decidedAt: update.decision.decidedAt || decidedAt, + }, `approval updates[${index}].decision`), + })); const loaded = loadApproval(outDir); - const normalized = normalizeDecision({ ...decision, decidedAt: decision.decidedAt || new Date().toISOString() }); - if (!Object.prototype.hasOwnProperty.call(loaded.document.decisions, story)) { - loaded.document.decisions[story] = {}; + for (const update of normalizedUpdates) { + if (!Object.prototype.hasOwnProperty.call(loaded.document.decisions, update.story)) { + loaded.document.decisions[update.story] = {}; + } + loaded.document.decisions[update.story][update.target] = update.decision; } - loaded.document.decisions[story][target] = normalized; writeJson(loaded.path, loaded.document); - return { ...loaded, decision: normalized }; + return { ...loaded, updates: normalizedUpdates }; +} + +function updateApprovalDecision(outDir, story, target, decision) { + const updated = updateApprovalDecisions(outDir, [{ story, target, decision }]); + return { ...updated, decision: updated.updates[0].decision }; } function decisionFor(document, story, target) { @@ -198,4 +214,5 @@ module.exports = { normalizeDecision, syncManifestApproval, updateApprovalDecision, + updateApprovalDecisions, }; diff --git a/src/calibration-verification.js b/src/calibration-verification.js new file mode 100644 index 0000000..4890b32 --- /dev/null +++ b/src/calibration-verification.js @@ -0,0 +1,52 @@ +const { + calibrationProfileHash, + loadCalibration, + updateCalibrationProfile, +} = require('./calibration'); + +function hasCalibration(config) { + return !!(config.calibration && config.calibration !== false); +} + +function profileFor(document, story, target) { + return document.profiles && document.profiles[story] && document.profiles[story][target] + ? document.profiles[story][target] + : {}; +} + +function captureProfileSnapshot(config, cwd, story, target) { + if (!hasCalibration(config)) return null; + const calibration = loadCalibration(config, cwd); + const profile = profileFor(calibration.document, story, target); + return { profileHash: calibrationProfileHash(profile) }; +} + +function updateProfileVerification(config, cwd, story, target, machineStatus, snapshot) { + if (!snapshot) return { verified: machineStatus === 'publish-ready', profileHash: null }; + const calibration = loadCalibration(config, cwd); + const profile = profileFor(calibration.document, story, target); + const profileHash = calibrationProfileHash(profile); + if (profileHash !== snapshot.profileHash) return { verified: false, profileHash }; + if (machineStatus === 'publish-ready') { + updateCalibrationProfile(config, cwd, story, target, { + ...profile, + verification: { + profileHash, + status: machineStatus, + verifiedAt: new Date().toISOString(), + }, + }); + return { verified: true, profileHash }; + } + if (profile.verification) { + const { verification: _verification, ...unverifiedProfile } = profile; + updateCalibrationProfile(config, cwd, story, target, unverifiedProfile); + } + return { verified: false, profileHash }; +} + +module.exports = { + captureProfileSnapshot, + hasCalibration, + updateProfileVerification, +}; diff --git a/src/calibrator-server.js b/src/calibrator-server.js index dce8b73..b3bde96 100644 --- a/src/calibrator-server.js +++ b/src/calibrator-server.js @@ -13,12 +13,45 @@ const { loadApproval, syncManifestApproval, updateApprovalDecision, + updateApprovalDecisions, } = require('./approval'); +const { + resolveCampaignRecipes, + saveCampaignSelection, +} = require('./campaign'); +const { + createCampaignRunController, + createCampaignStateReader, + nextAttempt, +} = require('./campaign-dashboard'); +const { + captureProfileSnapshot, + hasCalibration, + updateProfileVerification, +} = require('./calibration-verification'); const { normalizeDemoConfigs } = require('./demo'); const { writeJson } = require('./handoff-files'); const STATIC_DIR = path.join(__dirname, '..', 'calibrator'); +const CAMPAIGN_STATIC_DIR = path.join(__dirname, '..', 'campaign'); const MAX_BODY_BYTES = 256 * 1024; +const CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"; + +class HttpError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +function securityHeaders() { + return { + 'Content-Security-Policy': CONTENT_SECURITY_POLICY, + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'no-referrer', + }; +} function readJson(filePath, fallback = null) { try { @@ -31,6 +64,7 @@ function readJson(filePath, fallback = null) { function json(res, status, payload) { const body = Buffer.from(`${JSON.stringify(payload)}\n`); res.writeHead(status, { + ...securityHeaders(), 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': body.length, 'Cache-Control': 'no-store', @@ -52,7 +86,7 @@ function contentType(filePath) { } } -function safeStaticPath(urlPath) { +function safeStaticPathIn(staticDir, urlPath) { let decoded; try { decoded = decodeURIComponent(urlPath); @@ -62,13 +96,24 @@ function safeStaticPath(urlPath) { if (decoded.includes('\0')) return null; const relative = urlPath === '/' ? 'index.html' : decoded.replace(/^\/+/, ''); const normalized = path.normalize(relative).replace(/^(\.\.(\/|\\|$))+/, ''); - const resolved = path.resolve(STATIC_DIR, normalized); - return resolved.startsWith(`${path.resolve(STATIC_DIR)}${path.sep}`) ? resolved : null; + const resolved = path.resolve(staticDir, normalized); + return resolved.startsWith(`${path.resolve(staticDir)}${path.sep}`) ? resolved : null; +} + +function safeStaticPath(urlPath) { + return safeStaticPathIn(STATIC_DIR, urlPath); +} + +function safeCampaignStaticPath(urlPath) { + const relative = urlPath === '/campaign/' + ? '/' + : urlPath.slice('/campaign'.length) || '/'; + return safeStaticPathIn(CAMPAIGN_STATIC_DIR, relative); } function serveFile(req, res, filePath) { if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { - res.writeHead(404).end('Not found'); + res.writeHead(404, securityHeaders()).end('Not found'); return; } const size = fs.statSync(filePath).size; @@ -77,10 +122,11 @@ function serveFile(req, res, filePath) { const start = Number(range[1]); const end = range[2] ? Math.min(Number(range[2]), size - 1) : size - 1; if (!Number.isInteger(start) || start < 0 || start > end || start >= size) { - res.writeHead(416, { 'Content-Range': `bytes */${size}` }).end(); + res.writeHead(416, { ...securityHeaders(), 'Content-Range': `bytes */${size}` }).end(); return; } res.writeHead(206, { + ...securityHeaders(), 'Content-Type': contentType(filePath), 'Content-Length': end - start + 1, 'Content-Range': `bytes ${start}-${end}/${size}`, @@ -91,6 +137,7 @@ function serveFile(req, res, filePath) { return; } res.writeHead(200, { + ...securityHeaders(), 'Content-Type': contentType(filePath), 'Content-Length': size, 'Accept-Ranges': 'bytes', @@ -104,11 +151,51 @@ async function requestBody(req) { let size = 0; for await (const chunk of req) { size += chunk.length; - if (size > MAX_BODY_BYTES) throw new Error('request body is too large'); + if (size > MAX_BODY_BYTES) throw new HttpError(413, 'request body is too large'); chunks.push(chunk); } if (!chunks.length) return {}; - return JSON.parse(Buffer.concat(chunks).toString('utf8')); + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch (_error) { + throw new HttpError(400, 'request body must be valid JSON'); + } +} + +function isLoopbackHost(value) { + return typeof value === 'string' && /^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(value); +} + +function validateRequestHost(req) { + if (!isLoopbackHost(req.headers.host)) throw new HttpError(403, 'request host must be local'); +} + +function validateWriteRequest(req) { + const contentTypeHeader = String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase(); + if (contentTypeHeader !== 'application/json') { + throw new HttpError(415, 'write requests require application/json'); + } + if (!req.headers.origin) return; + let origin; + try { + origin = new URL(req.headers.origin); + } catch (_error) { + throw new HttpError(403, 'request origin must match the local dashboard'); + } + if (origin.protocol !== 'http:' || origin.host.toLowerCase() !== String(req.headers.host).toLowerCase()) { + throw new HttpError(403, 'request origin must match the local dashboard'); + } +} + +function safeMediaPath(outDir, name) { + const candidate = path.join(outDir, name); + try { + const root = fs.realpathSync(outDir); + const resolved = fs.realpathSync(candidate); + return resolved.startsWith(`${root}${path.sep}`) ? resolved : null; + } catch (_error) { + return null; + } } function safeArea(target, viewport) { @@ -179,13 +266,14 @@ function hasProfile(document, story, target) { function createStateReader({ cwd, config }) { const outDir = path.resolve(cwd, config.outDir || 'store-assets'); + const calibrationEnabled = hasCalibration(config); return function state(selected = {}) { const calibration = loadCalibration(config, cwd); const approval = loadApproval(outDir); const demos = applyCalibrationProfiles(normalizeDemoConfigs(config), calibration.document) .filter((demo) => demo.target); const manifest = readJson(path.join(outDir, 'shotkit-manifest.json'), {}); - applyCalibrationHashes(manifest, calibration.document); + if (calibrationEnabled) applyCalibrationHashes(manifest, calibration.document); const storyboard = readJson(path.join(outDir, 'storyboard.json'), {}); const captions = readJson(path.join(outDir, 'captions.json'), {}); const assets = manifest.assets || []; @@ -195,7 +283,7 @@ function createStateReader({ cwd, config }) { const approvalGate = syncManifestApproval( manifest, approval.document, - calibrationApprovalOptions(calibration.document), + calibrationEnabled ? calibrationApprovalOptions(calibration.document) : {}, ); const approvalByKey = new Map((approvalGate.targets || []).map((item) => [`${item.story}::${item.target}`, item])); const storyboardByName = new Map((storyboard.demos || []).map((demo) => [demo.name, demo])); @@ -221,11 +309,13 @@ function createStateReader({ cwd, config }) { }; const profile = profileFor(calibration.document, story, demo.target); const savedProfile = hasProfile(calibration.document, story, demo.target); - const profileHash = savedProfile ? calibrationProfileHash(profile) : null; - const verified = !!(savedProfile && profile.verification - && publish && publish.status === 'publish-ready' - && profile.verification.status === 'publish-ready' - && profile.verification.profileHash === profileHash); + const profileHash = calibrationEnabled + ? savedProfile ? calibrationProfileHash(profile) : null + : approvalTarget.profileHash || (publish && publish.profileHash) || null; + const verified = !calibrationEnabled || !!(savedProfile && profile.verification + && publish && publish.status === 'publish-ready' + && profile.verification.status === 'publish-ready' + && profile.verification.profileHash === profileHash); const publishStatus = publish ? publish.status : 'not-requested'; const reviewable = publishStatus === 'publish-ready' && verified && !!approvalTarget.assetDigest; const reviewStatus = reviewable ? approvalTarget.status : 'not-ready'; @@ -265,6 +355,7 @@ function createStateReader({ cwd, config }) { const active = targets.find((item) => item.story === selected.story && item.target === selected.target) || targets[0] || null; return { project: path.basename(cwd), + calibratorAvailable: calibrationEnabled, calibrationPath: calibration.path ? path.relative(cwd, calibration.path) : null, targets, selected: active ? { story: active.story, target: active.target } : null, @@ -279,9 +370,23 @@ function configuredTarget(config, story, target) { )); } -function runRecapture({ cwd, configPath, story, target, attempt }) { +async function recaptureTarget({ cwd, config, configPath, outDir, story, target, runner }) { + const snapshot = captureProfileSnapshot(config, cwd, story, target); + const result = await runner({ + cwd, + configPath, + story, + target, + attempt: nextAttempt(outDir), + }); + updateProfileVerification(config, cwd, story, target, result.machineStatus, snapshot); + return result; +} + +function runRecapture({ cwd, configPath, story, target, targets, attempt }) { const cliPath = path.join(__dirname, '..', 'bin', 'shotkit.js'); - const args = [cliPath, cwd, '--json', '--scene', story, '--target', target, '--mp4', '--no-build', '--attempt', String(attempt)]; + const targetList = targets || [target]; + const args = [cliPath, cwd, '--json', '--scene', story, '--target', targetList.join(','), '--mp4', '--no-build', '--attempt', String(attempt)]; const defaultConfigNames = new Set(['shotkit.config.js', 'store.config.js']); if (!defaultConfigNames.has(path.basename(configPath))) { args.push('--config', path.relative(cwd, configPath)); @@ -316,22 +421,167 @@ function openBrowser(url) { child.unref(); } -async function startCalibrator({ cwd, config, configPath, port = 0, open = true }) { - if (!config.calibration || config.calibration === false) { +async function startCalibrator({ + cwd, + config, + configPath, + port = 0, + open = true, + view = 'calibrator', + captureTarget = runRecapture, +}) { + const calibrationEnabled = hasCalibration(config); + if (view === 'calibrator' && !calibrationEnabled) { throw new Error('shotkit: calibrator requires config.calibration = { from, layouts? }'); } + if (!['calibrator', 'campaign'].includes(view)) { + throw new Error('shotkit: dashboard view must be calibrator or campaign'); + } const state = createStateReader({ cwd, config }); let recapturing = false; const outDir = path.resolve(cwd, config.outDir || 'store-assets'); - syncApprovalManifest(outDir, loadApproval(outDir).document, loadCalibration(config, cwd).document); + const calibrationDocument = () => (calibrationEnabled ? loadCalibration(config, cwd).document : null); + const recipes = resolveCampaignRecipes(config); + const campaignRuns = createCampaignRunController({ + cwd, + config, + configPath, + outDir, + recipes, + runner: captureTarget, + onRunningChange(value) { recapturing = value; }, + }); + const campaignState = createCampaignStateReader({ + cwd, + config, + outDir, + state, + recipes, + getRun: campaignRuns.snapshot, + }); + + syncApprovalManifest(outDir, loadApproval(outDir).document, calibrationDocument()); const server = http.createServer(async (req, res) => { try { + validateRequestHost(req); + if (req.method === 'POST') validateWriteRequest(req); const url = new URL(req.url || '/', 'http://127.0.0.1'); if (req.method === 'GET' && url.pathname === '/api/state') { json(res, 200, state({ story: url.searchParams.get('story'), target: url.searchParams.get('target') })); return; } + if (req.method === 'GET' && url.pathname === '/api/campaign') { + json(res, 200, campaignState()); + return; + } + if (req.method === 'POST' && url.pathname === '/api/campaign/select') { + if (recapturing) { + json(res, 409, { ok: false, error: 'campaign selection cannot change while capture is running' }); + return; + } + const body = await requestBody(req); + try { + const selection = saveCampaignSelection(outDir, recipes, body); + campaignRuns.reset(); + json(res, 200, { ok: true, selection, campaign: campaignState() }); + } catch (error) { + json(res, 400, { ok: false, error: error.message }); + } + return; + } + if (req.method === 'POST' && url.pathname === '/api/campaign/run') { + if (recapturing) { + json(res, 409, { ok: false, error: 'a capture is already running' }); + return; + } + const body = await requestBody(req); + try { + const selection = saveCampaignSelection(outDir, recipes, body); + const run = campaignRuns.start(selection); + json(res, 202, { ok: true, run }); + } catch (error) { + json(res, 400, { ok: false, error: error.message }); + } + return; + } + if (req.method === 'POST' && url.pathname === '/api/campaign/review') { + if (recapturing) { + json(res, 409, { ok: false, error: 'review is unavailable while capture is running' }); + return; + } + const body = await requestBody(req); + if (!['approved', 'changes-requested'].includes(body.status)) { + json(res, 400, { ok: false, error: 'review status must be approved or changes-requested' }); + return; + } + if (body.status === 'changes-requested' && (typeof body.note !== 'string' || !body.note.trim())) { + json(res, 400, { ok: false, error: 'review feedback is required when changes are requested' }); + return; + } + if (typeof body.note === 'string' && body.note.trim().length > 2000) { + json(res, 400, { ok: false, error: 'review feedback must be at most 2000 characters' }); + return; + } + const recipe = recipes.find((item) => item.id === body.recipeId); + if (!recipe) { + json(res, 400, { ok: false, error: 'shotkit: campaign recipe was not found' }); + return; + } + if (!Array.isArray(body.candidates) || !body.candidates.length) { + json(res, 400, { ok: false, error: 'review candidates must be a non-empty array' }); + return; + } + const availableTargets = new Set(recipe.targets.map((target) => target.id)); + const candidateTargets = body.candidates.map((candidate) => candidate && candidate.target); + if (candidateTargets.some((target) => typeof target !== 'string' || !availableTargets.has(target)) + || new Set(candidateTargets).size !== candidateTargets.length) { + json(res, 400, { ok: false, error: 'review candidates contain an unavailable or duplicate target' }); + return; + } + const current = state(); + const selected = candidateTargets.map((target) => current.targets.find((item) => ( + item.story === recipe.story && item.target === target + ))); + if (selected.some((target) => !target)) { + json(res, 404, { ok: false, error: 'configured story/target was not found' }); + return; + } + if (selected.some((target) => !target.reviewable)) { + json(res, 409, { ok: false, error: 'every selected target must be ready for user review' }); + return; + } + const stale = selected.some((target, index) => { + const candidate = body.candidates[index]; + return candidate.assetDigest !== target.assetDigest + || (candidate.profileHash || null) !== (target.profileHash || null); + }); + if (stale) { + json(res, 409, { ok: false, error: 'review candidate is stale; reload the final media before deciding' }); + return; + } + const approval = updateApprovalDecisions(outDir, selected.map((target) => ({ + story: recipe.story, + target: target.target, + decision: { + status: body.status, + assetDigest: target.assetDigest, + ...(target.profileHash ? { profileHash: target.profileHash } : {}), + note: body.note, + }, + }))); + syncApprovalManifest(outDir, approval.document, calibrationDocument()); + json(res, 200, { ok: true, campaign: campaignState() }); + return; + } if (req.method === 'POST' && url.pathname === '/api/profile') { + if (!calibrationEnabled) { + json(res, 409, { ok: false, error: 'calibration is not configured for this project' }); + return; + } + if (recapturing) { + json(res, 409, { ok: false, error: 'profile changes are unavailable while capture is running' }); + return; + } const body = await requestBody(req); if (!configuredTarget(config, body.story, body.target)) { json(res, 404, { ok: false, error: 'configured story/target was not found' }); @@ -358,37 +608,15 @@ async function startCalibrator({ cwd, config, configPath, port = 0, open = true } recapturing = true; try { - const manifest = readJson(path.join(outDir, 'shotkit-manifest.json'), {}); - const currentAttempt = manifest.handoff && manifest.handoff.automation - ? Number(manifest.handoff.automation.attempt) || 0 - : 0; - const result = await runRecapture({ + const result = await recaptureTarget({ cwd, + config, configPath, + outDir, story: body.story, target: body.target, - attempt: Math.max(1, currentAttempt + 1), + runner: captureTarget, }); - if (result.machineStatus === 'publish-ready') { - const calibration = loadCalibration(config, cwd); - const profile = profileFor(calibration.document, body.story, body.target); - const profileHash = calibrationProfileHash(profile); - updateCalibrationProfile(config, cwd, body.story, body.target, { - ...profile, - verification: { - profileHash, - status: result.machineStatus, - verifiedAt: new Date().toISOString(), - }, - }); - } else { - const calibration = loadCalibration(config, cwd); - const profile = profileFor(calibration.document, body.story, body.target); - if (profile.verification) { - const { verification: _verification, ...unverifiedProfile } = profile; - updateCalibrationProfile(config, cwd, body.story, body.target, unverifiedProfile); - } - } json(res, 200, result); } finally { recapturing = false; @@ -396,6 +624,10 @@ async function startCalibrator({ cwd, config, configPath, port = 0, open = true return; } if (req.method === 'POST' && url.pathname === '/api/review') { + if (recapturing) { + json(res, 409, { ok: false, error: 'review is unavailable while capture is running' }); + return; + } const body = await requestBody(req); if (!['approved', 'changes-requested'].includes(body.status)) { json(res, 400, { ok: false, error: 'review status must be approved or changes-requested' }); @@ -405,6 +637,10 @@ async function startCalibrator({ cwd, config, configPath, port = 0, open = true json(res, 400, { ok: false, error: 'review feedback is required when changes are requested' }); return; } + if (typeof body.note === 'string' && body.note.trim().length > 2000) { + json(res, 400, { ok: false, error: 'review feedback must be at most 2000 characters' }); + return; + } const current = state({ story: body.story, target: body.target }); const selected = current.targets.find((item) => item.story === body.story && item.target === body.target); if (!selected) { @@ -415,13 +651,18 @@ async function startCalibrator({ cwd, config, configPath, port = 0, open = true json(res, 409, { ok: false, error: 'target must pass machine QA and recapture verification before user review' }); return; } + if (body.assetDigest !== selected.assetDigest + || (body.profileHash || null) !== (selected.profileHash || null)) { + json(res, 409, { ok: false, error: 'review candidate is stale; reload the final media before deciding' }); + return; + } const updated = updateApprovalDecision(outDir, body.story, body.target, { status: body.status, assetDigest: selected.assetDigest, ...(selected.profileHash ? { profileHash: selected.profileHash } : {}), note: body.note, }); - syncApprovalManifest(outDir, updated.document, loadCalibration(config, cwd).document); + syncApprovalManifest(outDir, updated.document, calibrationDocument()); const refreshed = state({ story: body.story, target: body.target }); json(res, 200, { ok: true, @@ -431,21 +672,39 @@ async function startCalibrator({ cwd, config, configPath, port = 0, open = true return; } if (req.method === 'GET' && url.pathname.startsWith('/media/')) { - const name = decodeURIComponent(url.pathname.slice('/media/'.length)); + let name; + try { + name = decodeURIComponent(url.pathname.slice('/media/'.length)); + } catch (_error) { + throw new HttpError(400, 'invalid media path encoding'); + } if (!name || path.basename(name) !== name || !/\.(mp4|webm|png|jpe?g)$/i.test(name)) { - res.writeHead(400).end('Invalid media path'); + res.writeHead(400, securityHeaders()).end('Invalid media path'); + return; + } + const mediaPath = safeMediaPath(outDir, name); + if (!mediaPath) { + res.writeHead(404, securityHeaders()).end('Not found'); return; } - serveFile(req, res, path.join(outDir, name)); + serveFile(req, res, mediaPath); + return; + } + if (req.method === 'GET' && url.pathname === '/campaign') { + res.writeHead(302, { ...securityHeaders(), Location: '/campaign/' }).end(); + return; + } + if (req.method === 'GET' && url.pathname.startsWith('/campaign/')) { + serveFile(req, res, safeCampaignStaticPath(url.pathname)); return; } if (req.method === 'GET') { serveFile(req, res, safeStaticPath(url.pathname)); return; } - res.writeHead(405, { Allow: 'GET, POST' }).end('Method not allowed'); + res.writeHead(405, { ...securityHeaders(), Allow: 'GET, POST' }).end('Method not allowed'); } catch (error) { - json(res, 500, { ok: false, error: error.message }); + json(res, Number.isInteger(error.status) ? error.status : 500, { ok: false, error: error.message }); } }); await new Promise((resolve, reject) => { @@ -454,12 +713,24 @@ async function startCalibrator({ cwd, config, configPath, port = 0, open = true }); const address = server.address(); const url = `http://127.0.0.1:${address.port}`; - if (open) openBrowser(url); - return { server, url, close: () => new Promise((resolve) => server.close(resolve)) }; + const campaignUrl = `${url}/campaign/`; + if (open) openBrowser(view === 'campaign' ? campaignUrl : url); + return { + server, + url, + campaignUrl, + calibratorUrl: url, + close: async () => { + await campaignRuns.wait(); + await new Promise((resolve) => server.close(resolve)); + }, + }; } module.exports = { + createCampaignStateReader, createStateReader, + safeCampaignStaticPath, safeStaticPath, startCalibrator, }; diff --git a/src/campaign-dashboard.js b/src/campaign-dashboard.js new file mode 100644 index 0000000..27083e0 --- /dev/null +++ b/src/campaign-dashboard.js @@ -0,0 +1,216 @@ +const fs = require('fs'); +const path = require('path'); + +const { loadCampaignSelection, resolveCampaignRecipes } = require('./campaign'); +const { + captureProfileSnapshot, + updateProfileVerification, +} = require('./calibration-verification'); + +function readJson(filePath, fallback = null) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (_error) { + return fallback; + } +} + +function campaignTargetView(recipe, descriptor, targetsByKey) { + const capture = targetsByKey.get(`${recipe.story}::${descriptor.id}`); + return { + ...descriptor, + story: recipe.story, + status: capture ? capture.status : 'not-requested', + machineStatus: capture ? capture.machineStatus : 'not-requested', + reviewable: !!(capture && capture.reviewable), + publishable: !!(capture && capture.publishable), + videoUrl: capture ? capture.videoUrl : null, + thumbnailUrl: capture ? capture.thumbnailUrl : null, + warnings: capture ? capture.warnings : [], + review: capture ? capture.review : { status: 'not-ready', stale: false }, + assetDigest: capture ? capture.assetDigest : null, + profileHash: capture ? capture.profileHash : null, + candidate: capture && capture.assetDigest ? { + assetDigest: capture.assetDigest, + profileHash: capture.profileHash, + videoUrl: capture.videoUrl, + thumbnailUrl: capture.thumbnailUrl, + } : null, + }; +} + +function createCampaignStateReader({ cwd, config, outDir, state, getRun, recipes = resolveCampaignRecipes(config) }) { + const preferredId = config.campaign && config.campaign.defaultRecipe; + return function campaignState() { + const captureState = state(); + const targetsByKey = new Map(captureState.targets.map((target) => [ + `${target.story}::${target.target}`, + target, + ])); + const selection = loadCampaignSelection(outDir, recipes, preferredId); + const recipeViews = recipes.map((recipe) => { + const targets = recipe.targets.map((target) => campaignTargetView(recipe, target, targetsByKey)); + const preview = targets.find((target) => target.thumbnailUrl || target.videoUrl) || null; + return { + ...recipe, + targets, + previewUrl: preview ? preview.thumbnailUrl : null, + selected: !!(selection && selection.recipeId === recipe.id), + }; + }); + const activeRecipe = selection + ? recipeViews.find((recipe) => recipe.id === selection.recipeId) || null + : null; + const selectedTargets = activeRecipe + ? activeRecipe.targets.filter((target) => selection.targets.includes(target.id)) + : []; + const allReviewable = selectedTargets.length > 0 && selectedTargets.every((target) => target.reviewable); + const allApproved = selectedTargets.length > 0 && selectedTargets.every((target) => target.publishable); + const hasChangesRequested = selectedTargets.some((target) => target.review.status === 'changes-requested'); + const run = getRun(); + let phase = 'plan'; + if (selection && selection.persisted) { + if (run.status === 'running') phase = 'production'; + else if (allApproved) phase = 'complete'; + else if (hasChangesRequested) phase = 'production'; + else if (allReviewable) phase = 'review'; + else phase = 'production'; + } + return { + version: 1, + project: path.basename(cwd), + calibratorAvailable: captureState.calibratorAvailable, + calibratorUrl: captureState.calibratorAvailable ? '/' : null, + phase, + recipes: recipeViews, + selection, + run, + summary: { + selected: selectedTargets.length, + publishReady: selectedTargets.filter((target) => target.machineStatus === 'publish-ready').length, + reviewable: selectedTargets.filter((target) => target.reviewable).length, + approved: selectedTargets.filter((target) => target.publishable).length, + }, + }; + }; +} + +function nextAttempt(outDir) { + const manifest = readJson(path.join(outDir, 'shotkit-manifest.json'), {}); + const currentAttempt = manifest.handoff && manifest.handoff.automation + ? Number(manifest.handoff.automation.attempt) || 0 + : 0; + return Math.max(1, currentAttempt + 1); +} + +async function recaptureCampaign({ cwd, config, configPath, outDir, story, targets, runner }) { + const attempt = nextAttempt(outDir); + const snapshots = new Map(targets.map((target) => [ + target, + captureProfileSnapshot(config, cwd, story, target), + ])); + const result = await runner({ cwd, configPath, story, targets, attempt }); + const manifest = readJson(path.join(outDir, 'shotkit-manifest.json'), {}); + const automationTargets = manifest.handoff && manifest.handoff.automation + ? manifest.handoff.automation.targets || [] + : []; + const results = targets.map((target) => { + const technical = automationTargets.find((item) => item.story === story && item.target === target); + const machineStatus = technical ? technical.status : result.machineStatus || 'not-requested'; + const verification = updateProfileVerification( + config, + cwd, + story, + target, + machineStatus, + snapshots.get(target), + ); + return { target, machineStatus, ...verification }; + }); + return { attempt, result, targets: results }; +} + +function createCampaignRunController({ + cwd, + config, + configPath, + outDir, + recipes, + runner, + onRunningChange = () => {}, +}) { + let activeRun = null; + let sequence = 0; + let run = { id: null, status: 'idle', targets: [] }; + + function snapshot() { + return { ...run, targets: run.targets.map((target) => ({ ...target })) }; + } + + function reset() { + if (run.status === 'running') throw new Error('shotkit: campaign capture is running'); + run = { id: null, status: 'idle', targets: [] }; + } + + function start(selection) { + const recipe = recipes.find((item) => item.id === selection.recipeId); + run = { + id: `${Date.now().toString(36)}-${++sequence}`, + status: 'running', + recipeId: recipe.id, + story: recipe.story, + startedAt: new Date().toISOString(), + targets: selection.targets.map((target) => ({ target, status: 'queued' })), + }; + onRunningChange(true); + activeRun = (async () => { + for (const item of run.targets) item.status = 'running'; + try { + const batch = await recaptureCampaign({ + cwd, + config, + configPath, + outDir, + story: recipe.story, + targets: selection.targets, + runner, + }); + run.attempt = batch.attempt; + for (const result of batch.targets) { + const item = run.targets.find((target) => target.target === result.target); + item.machineStatus = result.machineStatus; + item.deliveryStatus = batch.result.status || 'not-requested'; + item.status = result.machineStatus === 'publish-ready' && result.verified + ? 'publish-ready' + : 'needs-fix'; + } + } catch (error) { + for (const item of run.targets) { + item.status = 'failed'; + item.error = error.message; + } + } + const failed = run.targets.filter((target) => target.status === 'failed'); + const needsFix = run.targets.filter((target) => target.status === 'needs-fix'); + run.status = failed.length ? 'failed' : needsFix.length ? 'needs-fix' : 'completed'; + run.completedAt = new Date().toISOString(); + if (failed.length) run.error = `${failed.length} target(s) failed`; + })().finally(() => { + onRunningChange(false); + activeRun = null; + }); + return snapshot(); + } + + async function wait() { + if (activeRun) await activeRun; + } + + return { reset, snapshot, start, wait }; +} + +module.exports = { + createCampaignRunController, + createCampaignStateReader, + nextAttempt, +}; diff --git a/src/campaign.js b/src/campaign.js new file mode 100644 index 0000000..a50a8f4 --- /dev/null +++ b/src/campaign.js @@ -0,0 +1,176 @@ +const fs = require('fs'); +const path = require('path'); + +const { CHANNEL_PROFILES } = require('./channels'); +const { normalizeDemoConfigs } = require('./demo'); +const { readJsonIfExists, writeJson } = require('./handoff-files'); + +const CAMPAIGN_FILE = 'shotkit-campaign.json'; +const CAMPAIGN_KIND = 'shotkit.campaign-selection'; +const CAMPAIGN_VERSION = 1; + +function humanize(value) { + return String(value) + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function recipeId(value, fallback) { + const id = String(value || fallback || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!id) throw new Error('shotkit: campaign recipe needs an id'); + return id; +} + +function targetDescriptor(demo) { + const id = demo.target; + const profile = demo.targetProfile || CHANNEL_PROFILES[id]; + return { + id, + variantName: demo.name, + label: profile ? profile.label : humanize(id), + platform: demo.channel || (profile ? profile.platform : id), + delivery: profile ? profile.delivery : null, + viewport: profile ? profile.viewport : null, + outputRoles: ['sns-demo-mp4', 'thumbnail'], + }; +} + +function configuredStories(config) { + const stories = new Map(); + for (const demo of normalizeDemoConfigs(config)) { + if (!demo.target) continue; + const story = demo.story || demo.name; + if (!stories.has(story)) stories.set(story, new Map()); + const targets = stories.get(story); + if (!targets.has(demo.target)) targets.set(demo.target, demo); + } + return stories; +} + +function validateCampaignConfig(config) { + if (config.campaign == null) return null; + if (!config.campaign || typeof config.campaign !== 'object' || Array.isArray(config.campaign)) { + throw new Error('shotkit: config.campaign must be an object'); + } + if (config.campaign.recipes == null) return null; + if (!Array.isArray(config.campaign.recipes) || !config.campaign.recipes.length) { + throw new Error('shotkit: config.campaign.recipes must be a non-empty array'); + } + return config.campaign.recipes; +} + +function resolveCampaignRecipes(config = {}) { + const stories = configuredStories(config); + const configured = validateCampaignConfig(config); + const source = configured || [...stories].map(([story, targets]) => ({ story, targets: [...targets.keys()] })); + const seen = new Set(); + + return source.map((entry, index) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error(`shotkit: campaign recipe ${index} must be an object`); + } + const story = entry.story; + if (typeof story !== 'string' || !stories.has(story)) { + throw new Error(`shotkit: campaign recipe ${index} references an unknown story`); + } + const availableTargets = stories.get(story); + const targets = entry.targets == null ? [...availableTargets.keys()] : entry.targets; + if (!Array.isArray(targets) || !targets.length) { + throw new Error(`shotkit: campaign recipe "${story}" needs at least one target`); + } + if (targets.some((target) => typeof target !== 'string' || !availableTargets.has(target))) { + throw new Error(`shotkit: campaign recipe "${story}" contains an unconfigured target`); + } + const id = recipeId(entry.id, story); + if (seen.has(id)) throw new Error(`shotkit: duplicate campaign recipe id "${id}"`); + seen.add(id); + const uniqueTargets = [...new Set(targets)]; + return { + id, + name: typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : humanize(story), + description: typeof entry.description === 'string' ? entry.description.trim() : '', + story, + targets: uniqueTargets.map((target) => targetDescriptor(availableTargets.get(target))), + }; + }); +} + +function defaultSelection(recipes, preferredId) { + const preferred = recipes.find((recipe) => recipe.id === preferredId) || recipes[0] || null; + return preferred ? { + kind: CAMPAIGN_KIND, + version: CAMPAIGN_VERSION, + recipeId: preferred.id, + targets: preferred.targets.map((target) => target.id), + persisted: false, + } : null; +} + +function normalizeCampaignSelection(recipes, input, options = {}) { + if (!recipes.length) throw new Error('shotkit: no campaign recipes are configured'); + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('shotkit: campaign selection must be an object'); + } + const recipe = recipes.find((item) => item.id === input.recipeId); + if (!recipe) throw new Error('shotkit: campaign recipe was not found'); + return { + kind: CAMPAIGN_KIND, + version: CAMPAIGN_VERSION, + recipeId: recipe.id, + targets: recipe.targets.map((target) => target.id), + persisted: options.persisted !== false, + ...(options.updatedAt ? { updatedAt: options.updatedAt } : {}), + }; +} + +function selectionPath(outDir) { + return path.join(outDir, CAMPAIGN_FILE); +} + +function loadCampaignSelection(outDir, recipes, preferredId) { + const fallback = defaultSelection(recipes, preferredId); + const document = readJsonIfExists(selectionPath(outDir)); + if (!document) return fallback; + try { + if (document.kind !== CAMPAIGN_KIND || document.version !== CAMPAIGN_VERSION) { + throw new Error('stale campaign selection'); + } + return normalizeCampaignSelection(recipes, document, { + persisted: true, + updatedAt: document.updatedAt, + }); + } catch (_error) { + return fallback ? { ...fallback, stale: true } : null; + } +} + +function saveCampaignSelection(outDir, recipes, input, now = () => new Date()) { + const selected = normalizeCampaignSelection(recipes, input, { + persisted: true, + updatedAt: now().toISOString(), + }); + fs.mkdirSync(outDir, { recursive: true }); + const document = { + kind: CAMPAIGN_KIND, + version: CAMPAIGN_VERSION, + recipeId: selected.recipeId, + updatedAt: selected.updatedAt, + }; + writeJson(selectionPath(outDir), document); + return selected; +} + +module.exports = { + CAMPAIGN_FILE, + CAMPAIGN_KIND, + CAMPAIGN_VERSION, + defaultSelection, + loadCampaignSelection, + normalizeCampaignSelection, + resolveCampaignRecipes, + saveCampaignSelection, +}; diff --git a/src/cli-runner.js b/src/cli-runner.js index a276431..f1f5b15 100644 --- a/src/cli-runner.js +++ b/src/cli-runner.js @@ -44,16 +44,19 @@ async function runCli(argv, io = {}, deps = {}) { try { const config = loadConfig(configPath); - if (opts.calibrate) { + if (opts.calibrate || opts.campaign) { const calibrator = await startCalibrator({ cwd, config, configPath, port: opts.port == null ? 0 : opts.port, open: opts.open, + ...(opts.campaign ? { view: 'campaign' } : {}), }); - if (opts.json) writeJson(stdout, { ok: true, status: 'calibrating', url: calibrator.url }); - else stdout.write(`[shotkit] calibrator: ${calibrator.url}\n`); + const dashboardUrl = opts.campaign ? calibrator.campaignUrl || `${calibrator.url}/campaign/` : calibrator.url; + const status = opts.campaign ? 'campaign-dashboard' : 'calibrating'; + if (opts.json) writeJson(stdout, { ok: true, status, url: dashboardUrl }); + else stdout.write(`[shotkit] ${opts.campaign ? 'campaign dashboard' : 'calibrator'}: ${dashboardUrl}\n`); return 0; } const log = opts.json ? (m) => stderr.write(`[shotkit] ${m}\n`) : undefined; diff --git a/src/cli.js b/src/cli.js index a07723e..17bfb7f 100644 --- a/src/cli.js +++ b/src/cli.js @@ -26,9 +26,10 @@ Options: --target only render configured channel targets (cws-youtube, x, youtube-shorts); repeatable, or comma-separated --attempt automation retry number (default: 1; agents increment it) + --campaign open the local campaign recipe dashboard --calibrate open the local exception-only composition calibrator - --port calibrator port (default: choose an available local port) - --no-open start the calibrator without opening a browser window + --port local dashboard port (default: choose an available port) + --no-open start the dashboard without opening a browser window --json machine-readable mode: stdout gets one JSON object {ok, status, machineStatus, outDir, manifest, produced[]}; logs go to stderr. machineStatus is technical QA; status @@ -52,6 +53,7 @@ function parseArgs(argv) { scenes: [], targets: [], attempt: 1, + campaign: false, calibrate: false, port: null, open: true, @@ -122,6 +124,7 @@ function parseArgs(argv) { opts.port = Number(value); } } + else if (a === '--campaign') opts.campaign = true; else if (a === '--calibrate') opts.calibrate = true; else if (a === '--no-open') opts.open = false; else if (a === '--json') opts.json = true; @@ -135,6 +138,7 @@ function parseArgs(argv) { else if (!a.startsWith('-')) opts.errors.push(`unexpected positional argument: ${a}`); else opts.errors.push(`unknown option: ${a}`); } + if (opts.campaign && opts.calibrate) opts.errors.push('--campaign and --calibrate cannot be used together'); return opts; } diff --git a/test/approval.test.js b/test/approval.test.js index 21c134e..a334de3 100644 --- a/test/approval.test.js +++ b/test/approval.test.js @@ -9,6 +9,7 @@ const { loadApproval, syncManifestApproval, updateApprovalDecision, + updateApprovalDecisions, } = require('../src/approval'); const DIGEST = 'a'.repeat(64); @@ -142,4 +143,25 @@ describe('user approval gate', () => { fs.rmSync(outDir, { recursive: true, force: true }); } }); + + test('validates a multi-target decision batch before writing it once', () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-approval-')); + const filePath = path.join(outDir, 'shotkit-approval.json'); + try { + expect(() => updateApprovalDecisions(outDir, [ + { story: 'demo', target: 'x', decision: { status: 'approved', assetDigest: DIGEST } }, + { story: 'demo', target: 'youtube-shorts', decision: { status: 'approved', assetDigest: 'invalid' } }, + ])).toThrow('must be a SHA-256 digest'); + expect(fs.existsSync(filePath)).toBe(false); + + const updated = updateApprovalDecisions(outDir, [ + { story: 'demo', target: 'x', decision: { status: 'approved', assetDigest: DIGEST } }, + { story: 'demo', target: 'youtube-shorts', decision: { status: 'approved', assetDigest: DIGEST } }, + ], () => new Date('2026-07-11T12:00:00.000Z')); + expect(updated.document.decisions.demo.x.decidedAt).toBe('2026-07-11T12:00:00.000Z'); + expect(updated.document.decisions.demo['youtube-shorts'].decidedAt).toBe('2026-07-11T12:00:00.000Z'); + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } + }); }); diff --git a/test/calibrator-server.test.js b/test/calibrator-server.test.js index 7894a6c..700f08b 100644 --- a/test/calibrator-server.test.js +++ b/test/calibrator-server.test.js @@ -1,8 +1,9 @@ const fs = require('fs'); +const http = require('http'); const os = require('os'); const path = require('path'); -const { safeStaticPath, startCalibrator } = require('../src/calibrator-server'); +const { safeCampaignStaticPath, safeStaticPath, startCalibrator } = require('../src/calibrator-server'); const DIGEST = 'a'.repeat(64); @@ -56,6 +57,39 @@ function projectFixture() { return { cwd, config, name }; } +function multiTargetFixture() { + const fixture = projectFixture(); + const outDir = path.join(fixture.cwd, 'store-assets'); + const xName = 'demo-x'; + const xDigest = 'b'.repeat(64); + fs.writeFileSync(path.join(outDir, `${xName}.mp4`), Buffer.from('x-video')); + const manifestPath = path.join(outDir, 'shotkit-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + manifest.assets.push({ + id: `sns-demo-mp4:${xName}`, + role: 'sns-demo-mp4', + type: 'video', + outPath: `${xName}.mp4`, + source: { kind: 'demo', name: xName, story: 'demo', target: 'x' }, + integrity: { algorithm: 'sha256', digest: xDigest }, + }); + manifest.handoff.automation.targets.push({ + demo: xName, + story: 'demo', + target: 'x', + status: 'publish-ready', + deliverable: { id: `sns-demo-mp4:${xName}` }, + }); + writeJson(manifestPath, manifest); + const storyboardPath = path.join(outDir, 'storyboard.json'); + const storyboard = JSON.parse(fs.readFileSync(storyboardPath, 'utf8')); + storyboard.demos.push({ name: xName, viewport: { width: 1280, height: 720 }, beats: [] }); + storyboard.storyboardLint.push({ name: xName, warnings: [] }); + writeJson(storyboardPath, storyboard); + fixture.config.demos[0].targets = ['youtube-shorts', 'x']; + return { ...fixture, xDigest }; +} + describe('calibrator server', () => { test('confines static paths and rejects malformed encodings', () => { const staticRoot = path.dirname(safeStaticPath('/')); @@ -63,6 +97,346 @@ describe('calibrator server', () => { expect(traversal.startsWith(`${staticRoot}${path.sep}`)).toBe(true); expect(safeStaticPath('/%E0%A4%A')).toBeNull(); expect(safeStaticPath('/%00')).toBeNull(); + const campaignRoot = path.dirname(safeCampaignStaticPath('/campaign/')); + const campaignTraversal = safeCampaignStaticPath('/campaign/%2e%2e/%2e%2e/package.json'); + expect(campaignTraversal.startsWith(`${campaignRoot}${path.sep}`)).toBe(true); + expect(safeCampaignStaticPath('/campaign/%E0%A4%A')).toBeNull(); + }); + + test.each(['calibrator', 'campaign'])('%s static DOM keeps every JavaScript id binding valid', (surface) => { + const root = path.join(__dirname, '..', surface); + const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8'); + const script = fs.readFileSync(path.join(root, 'app.js'), 'utf8'); + const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); + expect(new Set(ids).size).toBe(ids.length); + const bindings = [...script.matchAll(/\$\('([^']+)'\)/g)].map((match) => match[1]); + for (const binding of bindings) expect(ids).toContain(binding); + }); + + test('adds a campaign workflow while preserving the calibrator and approval APIs', async () => { + const { cwd, config } = projectFixture(); + const campaignConfig = { ...config, calibration: undefined }; + const captureTarget = jest.fn(async () => ({ + ok: true, + status: 'awaiting-approval', + machineStatus: 'publish-ready', + produced: [], + })); + const calibrator = await startCalibrator({ + cwd, + config: campaignConfig, + configPath: path.join(cwd, 'shotkit.config.js'), + port: 0, + open: false, + view: 'campaign', + captureTarget, + }); + + try { + const root = await fetch(calibrator.url).then((response) => response.text()); + expect(root).toContain('Shotkit Calibrator'); + expect(root).toContain('href="/campaign/"'); + const redirect = await fetch(`${calibrator.url}/campaign`, { redirect: 'manual' }); + expect(redirect.status).toBe(302); + expect(redirect.headers.get('location')).toBe('/campaign/'); + const dashboard = await fetch(calibrator.campaignUrl).then((response) => response.text()); + expect(dashboard).toContain('Shotkit Campaigns'); + expect(dashboard).toContain('href="/"'); + for (const [assetPath, contentType] of [ + ['/styles.css', 'text/css'], + ['/app.js', 'text/javascript'], + ['/model.js', 'text/javascript'], + ['/preview.js', 'text/javascript'], + ['/regions.js', 'text/javascript'], + ['/campaign/styles.css', 'text/css'], + ['/campaign/app.js', 'text/javascript'], + ]) { + const response = await fetch(`${calibrator.url}${assetPath}`); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain(contentType); + expect(response.headers.get('x-content-type-options')).toBe('nosniff'); + } + + const initial = await fetch(`${calibrator.url}/api/campaign`).then((response) => response.json()); + expect(initial).toMatchObject({ + version: 1, + project: path.basename(cwd), + phase: 'plan', + calibratorAvailable: false, + calibratorUrl: null, + recipes: [{ + id: 'demo', + story: 'demo', + targets: [expect.objectContaining({ id: 'youtube-shorts', status: 'awaiting-approval' })], + }], + selection: { recipeId: 'demo', persisted: false }, + run: { status: 'idle' }, + }); + + const started = await fetch(`${calibrator.url}/api/campaign/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipeId: 'demo' }), + }); + expect(started.status).toBe(202); + + let campaign; + for (let index = 0; index < 20; index++) { + campaign = await fetch(`${calibrator.url}/api/campaign`).then((response) => response.json()); + if (campaign.run.status !== 'running') break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(campaign).toMatchObject({ + phase: 'review', + selection: { recipeId: 'demo', targets: ['youtube-shorts'], persisted: true }, + run: { + status: 'completed', + targets: [{ target: 'youtube-shorts', status: 'publish-ready' }], + }, + summary: { selected: 1, publishReady: 1, reviewable: 1, approved: 0 }, + }); + expect(captureTarget).toHaveBeenCalledWith(expect.objectContaining({ + cwd, + story: 'demo', + targets: ['youtube-shorts'], + attempt: 2, + })); + expect(fs.existsSync(path.join(cwd, 'shotkit.calibration.json'))).toBe(false); + + const approved = await fetch(`${calibrator.url}/api/campaign/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipeId: 'demo', + candidates: [{ target: 'youtube-shorts', assetDigest: DIGEST }], + status: 'approved', + }), + }).then((response) => response.json()); + expect(approved).toMatchObject({ + ok: true, + campaign: { phase: 'complete', summary: { approved: 1 } }, + }); + } finally { + await calibrator.close(); + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('rejects unsafe write requests and media paths without side effects', async () => { + const { cwd, config } = projectFixture(); + const captureTarget = jest.fn(async () => ({ machineStatus: 'publish-ready' })); + const calibrator = await startCalibrator({ + cwd, + config, + configPath: path.join(cwd, 'shotkit.config.js'), + port: 0, + open: false, + view: 'campaign', + captureTarget, + }); + + try { + const html = await fetch(calibrator.campaignUrl); + expect(html.headers.get('content-security-policy')).toContain("default-src 'self'"); + + const wrongType = await fetch(`${calibrator.url}/api/campaign/run`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: '{}', + }); + expect(wrongType.status).toBe(415); + + const crossOrigin = await fetch(`${calibrator.url}/api/campaign/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: 'https://example.invalid' }, + body: JSON.stringify({ recipeId: 'demo' }), + }); + expect(crossOrigin.status).toBe(403); + + const rebindingStatus = await new Promise((resolve, reject) => { + const request = http.get(`${calibrator.url}/api/campaign`, { headers: { Host: 'example.invalid' } }, (response) => { + response.resume(); + response.once('end', () => resolve(response.statusCode)); + }); + request.once('error', reject); + }); + expect(rebindingStatus).toBe(403); + + const malformed = await fetch(`${calibrator.url}/api/campaign/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{', + }); + expect(malformed.status).toBe(400); + + const oversized = await fetch(`${calibrator.url}/api/campaign/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipeId: 'demo', padding: 'x'.repeat(300_000) }), + }); + expect(oversized.status).toBe(413); + + const malformedMedia = await fetch(`${calibrator.url}/media/%E0%A4%A`); + expect(malformedMedia.status).toBe(400); + const outside = path.join(os.tmpdir(), `shotkit-outside-${Date.now()}.mp4`); + fs.writeFileSync(outside, 'outside'); + fs.symlinkSync(outside, path.join(cwd, 'store-assets', 'outside.mp4')); + expect((await fetch(`${calibrator.url}/media/outside.mp4`)).status).toBe(404); + fs.rmSync(outside, { force: true }); + + expect(captureTarget).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(cwd, 'store-assets', 'shotkit-campaign.json'))).toBe(false); + } finally { + await calibrator.close(); + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('does not verify a calibration profile changed during capture', async () => { + const { cwd, config } = projectFixture(); + let finishCapture; + const captureTarget = jest.fn(() => new Promise((resolve) => { finishCapture = resolve; })); + const calibrator = await startCalibrator({ + cwd, + config, + configPath: path.join(cwd, 'shotkit.config.js'), + port: 0, + open: false, + view: 'campaign', + captureTarget, + }); + + try { + const profile = { + layoutPreset: 'focus-column', + framing: { scale: 1.04, focusX: 0.5, focusY: 0.45 }, + captionOptions: { position: 'bottom-left', appearance: 'outline', bottomOffset: 80 }, + protectedRegions: [], + }; + expect((await fetch(`${calibrator.url}/api/profile`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ story: 'demo', target: 'youtube-shorts', profile }), + })).status).toBe(200); + + expect((await fetch(`${calibrator.url}/api/campaign/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipeId: 'demo' }), + })).status).toBe(202); + expect(captureTarget).toHaveBeenCalledTimes(1); + + const blockedProfile = await fetch(`${calibrator.url}/api/profile`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + story: 'demo', + target: 'youtube-shorts', + profile: { ...profile, captionOptions: { ...profile.captionOptions, bottomOffset: 120 } }, + }), + }); + expect(blockedProfile.status).toBe(409); + + const calibrationPath = path.join(cwd, 'shotkit.calibration.json'); + const calibration = JSON.parse(fs.readFileSync(calibrationPath, 'utf8')); + calibration.profiles.demo['youtube-shorts'] = { + ...profile, + captionOptions: { ...profile.captionOptions, bottomOffset: 160 }, + }; + writeJson(calibrationPath, calibration); + finishCapture({ ok: true, status: 'awaiting-approval', machineStatus: 'publish-ready' }); + + let campaign; + for (let index = 0; index < 20; index++) { + campaign = await fetch(`${calibrator.url}/api/campaign`).then((response) => response.json()); + if (campaign.run.status !== 'running') break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(campaign.run.status).toBe('needs-fix'); + const saved = JSON.parse(fs.readFileSync(calibrationPath, 'utf8')) + .profiles.demo['youtube-shorts']; + expect(saved.captionOptions.bottomOffset).toBe(160); + expect(saved.verification).toBeUndefined(); + } finally { + await calibrator.close(); + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('runs a recipe in one attempt and rejects an atomic review when any candidate is stale', async () => { + const { cwd, config, xDigest } = multiTargetFixture(); + const campaignConfig = { ...config, calibration: undefined }; + const captureTarget = jest.fn(async () => ({ + ok: true, + status: 'awaiting-approval', + machineStatus: 'publish-ready', + })); + const calibrator = await startCalibrator({ + cwd, + config: campaignConfig, + configPath: path.join(cwd, 'shotkit.config.js'), + port: 0, + open: false, + view: 'campaign', + captureTarget, + }); + + try { + await fetch(`${calibrator.url}/api/campaign/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recipeId: 'demo' }), + }); + let campaign; + for (let index = 0; index < 20; index++) { + campaign = await fetch(`${calibrator.url}/api/campaign`).then((response) => response.json()); + if (campaign.run.status !== 'running') break; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(campaign.run).toMatchObject({ status: 'completed', attempt: 2 }); + expect(captureTarget).toHaveBeenCalledTimes(1); + expect(captureTarget).toHaveBeenCalledWith(expect.objectContaining({ + targets: ['youtube-shorts', 'x'], + attempt: 2, + })); + + const stale = await fetch(`${calibrator.url}/api/campaign/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipeId: 'demo', + status: 'approved', + candidates: [ + { target: 'youtube-shorts', assetDigest: DIGEST }, + { target: 'x', assetDigest: 'c'.repeat(64) }, + ], + }), + }); + expect(stale.status).toBe(409); + expect(fs.existsSync(path.join(cwd, 'store-assets', 'shotkit-approval.json'))).toBe(false); + + const approved = await fetch(`${calibrator.url}/api/campaign/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipeId: 'demo', + status: 'approved', + candidates: [ + { target: 'youtube-shorts', assetDigest: DIGEST }, + { target: 'x', assetDigest: xDigest }, + ], + }), + }); + expect(approved.status).toBe(200); + const decisions = JSON.parse(fs.readFileSync( + path.join(cwd, 'store-assets', 'shotkit-approval.json'), + 'utf8', + )).decisions.demo; + expect(Object.keys(decisions).sort()).toEqual(['x', 'youtube-shorts']); + } finally { + await calibrator.close(); + fs.rmSync(cwd, { recursive: true, force: true }); + } }); test('reads capture state, serves media ranges, and persists an unverified profile', async () => { @@ -150,7 +524,13 @@ describe('calibrator server', () => { const approved = await fetch(`${calibrator.url}/api/review`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ story: 'demo', target: 'youtube-shorts', status: 'approved' }), + body: JSON.stringify({ + story: 'demo', + target: 'youtube-shorts', + status: 'approved', + assetDigest: DIGEST, + profileHash: saved.profileHash, + }), }).then((response) => response.json()); expect(approved).toMatchObject({ ok: true, approvalStatus: 'approved', review: { status: 'approved' } }); const approvedState = await fetch(`${calibrator.url}/api/state`).then((response) => response.json()); @@ -163,6 +543,8 @@ describe('calibrator server', () => { story: 'demo', target: 'youtube-shorts', status: 'changes-requested', + assetDigest: DIGEST, + profileHash: saved.profileHash, note: 'Move the result higher.', }), }).then((response) => response.json()); diff --git a/test/campaign.test.js b/test/campaign.test.js new file mode 100644 index 0000000..fb7a193 --- /dev/null +++ b/test/campaign.test.js @@ -0,0 +1,135 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + CAMPAIGN_FILE, + CAMPAIGN_KIND, + CAMPAIGN_VERSION, + loadCampaignSelection, + normalizeCampaignSelection, + resolveCampaignRecipes, + saveCampaignSelection, +} = require('../src/campaign'); + +function targetedConfig(overrides = {}) { + return { + demos: [ + { name: 'proof', targets: ['x', 'youtube-shorts'], run: async () => {} }, + { name: 'onboarding', targets: ['cws-youtube'], run: async () => {} }, + ], + ...overrides, + }; +} + +describe('campaign recipes', () => { + test('infers one recipe per targeted story with channel descriptors', () => { + expect(resolveCampaignRecipes(targetedConfig())).toEqual([ + expect.objectContaining({ + id: 'proof', + name: 'Proof', + story: 'proof', + targets: [ + expect.objectContaining({ id: 'x', platform: 'x', viewport: { width: 1280, height: 720 } }), + expect.objectContaining({ id: 'youtube-shorts', platform: 'youtube', viewport: { width: 720, height: 1280 } }), + ], + }), + expect.objectContaining({ + id: 'onboarding', + name: 'Onboarding', + story: 'onboarding', + targets: [expect.objectContaining({ id: 'cws-youtube' })], + }), + ]); + }); + + test('applies optional recipe metadata without changing the demo contract', () => { + const recipes = resolveCampaignRecipes(targetedConfig({ + campaign: { + recipes: [{ + id: 'before-after-proof', + name: 'Before / After Proof', + description: 'Show the original task and verified result.', + story: 'proof', + targets: ['youtube-shorts', 'x'], + }], + }, + })); + + expect(recipes).toEqual([expect.objectContaining({ + id: 'before-after-proof', + name: 'Before / After Proof', + description: 'Show the original task and verified result.', + story: 'proof', + targets: [expect.objectContaining({ id: 'youtube-shorts' }), expect.objectContaining({ id: 'x' })], + })]); + }); + + test('rejects duplicate ids and unavailable stories or targets', () => { + expect(() => resolveCampaignRecipes(targetedConfig({ + campaign: { recipes: [{ story: 'missing' }] }, + }))).toThrow('unknown story'); + expect(() => resolveCampaignRecipes(targetedConfig({ + campaign: { recipes: [{ story: 'proof', targets: ['cws-youtube'] }] }, + }))).toThrow('unconfigured target'); + expect(() => resolveCampaignRecipes(targetedConfig({ + campaign: { recipes: [{ id: 'same', story: 'proof' }, { id: 'same', story: 'onboarding' }] }, + }))).toThrow('duplicate campaign recipe id'); + }); +}); + +describe('campaign selection', () => { + test('validates, persists, and reloads a recipe selection', () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-campaign-')); + const recipes = resolveCampaignRecipes(targetedConfig()); + const now = () => new Date('2026-07-11T12:00:00.000Z'); + + const saved = saveCampaignSelection(outDir, recipes, { + recipeId: 'proof', + }, now); + + expect(saved).toEqual({ + kind: CAMPAIGN_KIND, + version: CAMPAIGN_VERSION, + recipeId: 'proof', + targets: ['x', 'youtube-shorts'], + persisted: true, + updatedAt: '2026-07-11T12:00:00.000Z', + }); + expect(loadCampaignSelection(outDir, recipes)).toEqual(saved); + expect(JSON.parse(fs.readFileSync(path.join(outDir, CAMPAIGN_FILE), 'utf8'))).toEqual({ + kind: CAMPAIGN_KIND, + version: CAMPAIGN_VERSION, + recipeId: 'proof', + updatedAt: '2026-07-11T12:00:00.000Z', + }); + }); + + test('uses a non-persisted default and safely replaces stale selections', () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-campaign-')); + const recipes = resolveCampaignRecipes(targetedConfig()); + expect(loadCampaignSelection(outDir, recipes, 'onboarding')).toMatchObject({ + recipeId: 'onboarding', + targets: ['cws-youtube'], + persisted: false, + }); + + fs.writeFileSync(path.join(outDir, CAMPAIGN_FILE), JSON.stringify({ + version: CAMPAIGN_VERSION, + recipeId: 'removed', + targets: ['x'], + })); + expect(loadCampaignSelection(outDir, recipes)).toMatchObject({ + recipeId: 'proof', + persisted: false, + stale: true, + }); + }); + + test('rejects malformed and unknown recipe selections', () => { + const recipes = resolveCampaignRecipes(targetedConfig()); + expect(() => normalizeCampaignSelection(recipes, null)).toThrow('must be an object'); + expect(() => normalizeCampaignSelection(recipes, { recipeId: 'missing' })) + .toThrow('recipe was not found'); + }); +}); diff --git a/test/cli-runner.test.js b/test/cli-runner.test.js index bc35b66..d2dcc52 100644 --- a/test/cli-runner.test.js +++ b/test/cli-runner.test.js @@ -110,4 +110,42 @@ describe('runCli', () => { }); expect(capture).not.toHaveBeenCalled(); }); + + test('starts the campaign dashboard without changing the calibrator entrypoint', async () => { + const { cwd, configPath } = tmpProject(); + const stdout = streamBuffer(); + const stderr = streamBuffer(); + const capture = jest.fn(); + const startCalibrator = jest.fn(async () => ({ + url: 'http://127.0.0.1:4312', + campaignUrl: 'http://127.0.0.1:4312/campaign/', + })); + const config = { calibration: { from: 'shotkit.calibration.json' } }; + + const code = await runCli(['--campaign', '--port', '4312', '--no-open', '--json'], { + stdout: stdout.stream, + stderr: stderr.stream, + processCwd: () => cwd, + }, { + capture, + startCalibrator, + loadConfig: jest.fn(() => config), + }); + + expect(code).toBe(0); + expect(JSON.parse(stdout.read())).toEqual({ + ok: true, + status: 'campaign-dashboard', + url: 'http://127.0.0.1:4312/campaign/', + }); + expect(startCalibrator).toHaveBeenCalledWith({ + cwd, + config, + configPath, + port: 4312, + open: false, + view: 'campaign', + }); + expect(capture).not.toHaveBeenCalled(); + }); }); diff --git a/test/cli.test.js b/test/cli.test.js index eb5994f..97bd829 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -10,7 +10,7 @@ describe('parseArgs', () => { test('defaults', () => { expect(parseArgs([])).toMatchObject({ scenes: [], targets: [], errors: [], json: false, noVideo: false, - calibrate: false, port: null, open: true, help: false, path: null, + campaign: false, calibrate: false, port: null, open: true, help: false, path: null, }); }); @@ -47,6 +47,17 @@ describe('parseArgs', () => { expect(parseArgs(['--port=65536']).errors).toEqual(['--port requires an integer between 0 and 65535']); }); + test('--campaign is additive and cannot be combined with --calibrate', () => { + expect(parseArgs(['--campaign', '--port', '4312', '--no-open'])).toMatchObject({ + campaign: true, + calibrate: false, + port: 4312, + open: false, + }); + expect(parseArgs(['--campaign', '--calibrate']).errors) + .toEqual(['--campaign and --calibrate cannot be used together']); + }); + test('usage errors are explicit for missing values and unknown options', () => { expect(parseArgs(['--scene']).errors).toEqual(['--scene requires a scene name']); expect(parseArgs(['--scene=']).errors).toEqual(['--scene requires a scene name']); @@ -77,6 +88,7 @@ describe('parseArgs', () => { test('USAGE documents the agent contract', () => { expect(USAGE).toContain('--json'); + expect(USAGE).toContain('--campaign'); expect(USAGE).toContain('--calibrate'); expect(USAGE).toContain('Exit codes'); }); diff --git a/test/package-boundary.test.js b/test/package-boundary.test.js index 577e9c6..f2c21a3 100644 --- a/test/package-boundary.test.js +++ b/test/package-boundary.test.js @@ -7,6 +7,7 @@ describe('npm package boundary', () => { 'src', 'bin', 'calibrator', + 'campaign', 'skills/capture', 'docs/handoff-conventions.md', 'schemas', From 1911bebe6f305e9381c6934282399c61ce001ce8 Mon Sep 17 00:00:00 2001 From: heznpc Date: Sat, 11 Jul 2026 09:22:11 +0900 Subject: [PATCH 11/13] fix: build campaign assets before capture --- src/calibrator-server.js | 14 ++++++++++++-- src/campaign-dashboard.js | 2 +- test/calibrator-server.test.js | 30 +++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/calibrator-server.js b/src/calibrator-server.js index b3bde96..82c6635 100644 --- a/src/calibrator-server.js +++ b/src/calibrator-server.js @@ -377,20 +377,29 @@ async function recaptureTarget({ cwd, config, configPath, outDir, story, target, configPath, story, target, + noBuild: true, attempt: nextAttempt(outDir), }); updateProfileVerification(config, cwd, story, target, result.machineStatus, snapshot); return result; } -function runRecapture({ cwd, configPath, story, target, targets, attempt }) { +function recaptureCliArgs({ cwd, configPath, story, target, targets, attempt, noBuild = false }) { const cliPath = path.join(__dirname, '..', 'bin', 'shotkit.js'); const targetList = targets || [target]; - const args = [cliPath, cwd, '--json', '--scene', story, '--target', targetList.join(','), '--mp4', '--no-build', '--attempt', String(attempt)]; + const args = [cliPath, cwd, '--json', '--scene', story, '--target', targetList.join(','), '--mp4']; + if (noBuild) args.push('--no-build'); + args.push('--attempt', String(attempt)); const defaultConfigNames = new Set(['shotkit.config.js', 'store.config.js']); if (!defaultConfigNames.has(path.basename(configPath))) { args.push('--config', path.relative(cwd, configPath)); } + return args; +} + +function runRecapture(options) { + const { cwd } = options; + const args = recaptureCliArgs(options); return new Promise((resolve, reject) => { execFile(process.execPath, args, { cwd, @@ -730,6 +739,7 @@ async function startCalibrator({ module.exports = { createCampaignStateReader, createStateReader, + recaptureCliArgs, safeCampaignStaticPath, safeStaticPath, startCalibrator, diff --git a/src/campaign-dashboard.js b/src/campaign-dashboard.js index 27083e0..efa3a4f 100644 --- a/src/campaign-dashboard.js +++ b/src/campaign-dashboard.js @@ -109,7 +109,7 @@ async function recaptureCampaign({ cwd, config, configPath, outDir, story, targe target, captureProfileSnapshot(config, cwd, story, target), ])); - const result = await runner({ cwd, configPath, story, targets, attempt }); + const result = await runner({ cwd, configPath, story, targets, attempt, noBuild: false }); const manifest = readJson(path.join(outDir, 'shotkit-manifest.json'), {}); const automationTargets = manifest.handoff && manifest.handoff.automation ? manifest.handoff.automation.targets || [] diff --git a/test/calibrator-server.test.js b/test/calibrator-server.test.js index 700f08b..74a0523 100644 --- a/test/calibrator-server.test.js +++ b/test/calibrator-server.test.js @@ -3,7 +3,12 @@ const http = require('http'); const os = require('os'); const path = require('path'); -const { safeCampaignStaticPath, safeStaticPath, startCalibrator } = require('../src/calibrator-server'); +const { + recaptureCliArgs, + safeCampaignStaticPath, + safeStaticPath, + startCalibrator, +} = require('../src/calibrator-server'); const DIGEST = 'a'.repeat(64); @@ -91,6 +96,28 @@ function multiTargetFixture() { } describe('calibrator server', () => { + test('builds campaign captures while keeping calibrator recaptures build-free', () => { + const base = { + cwd: '/tmp/project', + configPath: '/tmp/project/shotkit.config.js', + story: 'demo', + target: 'youtube-shorts', + attempt: 2, + }; + const campaignArgs = recaptureCliArgs({ + ...base, + targets: ['youtube-shorts', 'x'], + noBuild: false, + }); + const calibratorArgs = recaptureCliArgs({ ...base, noBuild: true }); + + expect(campaignArgs).not.toContain('--no-build'); + expect(campaignArgs).toEqual(expect.arrayContaining([ + '--target', 'youtube-shorts,x', '--attempt', '2', + ])); + expect(calibratorArgs).toContain('--no-build'); + }); + test('confines static paths and rejects malformed encodings', () => { const staticRoot = path.dirname(safeStaticPath('/')); const traversal = safeStaticPath('/%2e%2e/%2e%2e/package.json'); @@ -199,6 +226,7 @@ describe('calibrator server', () => { cwd, story: 'demo', targets: ['youtube-shorts'], + noBuild: false, attempt: 2, })); expect(fs.existsSync(path.join(cwd, 'shotkit.calibration.json'))).toBe(false); From 3c3449c17955cfeada3b7fb3979bee0d0e932e0a Mon Sep 17 00:00:00 2001 From: heznpc Date: Sun, 12 Jul 2026 02:14:00 +0900 Subject: [PATCH 12/13] feat: add locale-aware caption typography --- AGENTS.md | 6 + README.ko.md | 39 ++++- README.md | 38 +++- docs/handoff-conventions.md | 32 +++- package-lock.json | 186 +++++++++++++++++++- package.json | 6 +- schemas/captions.schema.json | 96 ++++++++++- schemas/storyboard.schema.json | 24 ++- skills/capture/SKILL.md | 14 ++ src/caption-language.js | 89 ++++++++++ src/caption-typography.js | 296 ++++++++++++++++++++++++++++++++ src/capture.js | 20 ++- src/demo-caption-focus.js | 90 ++++++---- src/demo-caption-qa.js | 62 +++++++ src/demo-storyboard.js | 3 +- src/demo-time.js | 6 + src/demo.js | 251 +++++++++++++++++++++++---- src/handoff.js | 75 +++++++- test/caption-language.test.js | 37 ++++ test/caption-typography.test.js | 115 +++++++++++++ test/demo-caption-focus.test.js | 48 +++++- test/demo-caption-qa.test.js | 35 ++++ test/demo.test.js | 57 +++++- test/handoff.test.js | 65 +++++++ 24 files changed, 1592 insertions(+), 98 deletions(-) create mode 100644 src/caption-language.js create mode 100644 src/caption-typography.js create mode 100644 test/caption-language.test.js create mode 100644 test/caption-typography.test.js diff --git a/AGENTS.md b/AGENTS.md index 875961d..d156cf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,12 @@ test/ → unit tests for the pure/safe modules (no browser) 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 diff --git a/README.ko.md b/README.ko.md index dc04c4c..73f3d40 100644 --- a/README.ko.md +++ b/README.ko.md @@ -108,7 +108,7 @@ handoff pack은 에이전트가 target별 최종 파일을 검증하고 자동 - `storyboard.json` — demo 이름, audience, viewport, trim/framing hint, beats, 구조화된 storyboard lint warning, 추천 next tool. -- `captions.json` — demo별 caption timing/text. +- `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`의 최종 게시 게이트. @@ -120,6 +120,39 @@ handoff pack은 에이전트가 target별 최종 파일을 검증하고 자동 target workflow에서는 수동 editor hint를 기본으로 숨기며, `automation.manualFallback:true`일 때만 명시적으로 다시 노출합니다. +다국어 campaign variant는 실행 환경의 시스템 폰트 대신 locale과 프로젝트 내부 +폰트를 명시할 수 있습니다. Shotkit은 작성된 글자의 glyph coverage를 먼저 +확인하고, 필요한 글자만 WOFF2로 줄여 녹화 페이지에 삽입한 뒤 브라우저 로드를 +기다립니다. 각 caption은 선언된 최소 크기와 최대 줄 수 안에서만 자동으로 +축소됩니다. + +```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', + }], + }, +} +``` + +폰트 경로는 consumer 프로젝트 내부여야 합니다. OTF, TTF, WOFF, WOFF2를 +최대 4개, 파일당 24 MB까지 사용할 수 있습니다. focus caption은 locale-aware +word segmentation으로 원문의 문장부호와 구분자를 보존하므로 일본어·중국어에 +임의의 공백을 넣지 않습니다. 누락 glyph, 폰트 로드 실패, 최소 크기 overflow, +불균형한 두 줄 구성은 에이전트가 수정할 구조화 warning이 됩니다. + handoff 규약은 버전과 schema를 갖습니다. `$schema` 값은 안정적인 URN 식별자이고, `handoff.schemaFiles`가 output pack 안의 실제 schema로 연결합니다. 자기 자신을 참조하는 manifest를 제외한 실파일에는 byte 크기와 SHA-256이 @@ -225,7 +258,9 @@ storyboard lint는 기본으로 켜져 있으며 실패 대신 warning을 남깁 형태로 기록되므로, 에이전트가 다음 패스에서 `shotkit.config.js`를 고치기 쉽습니다. mp4 누락, 3초 이후 첫 캡션, 홀수 영상 크기, 너무 긴 캡션, safety/restore beat 누락, crop/zoom edge risk, 20~40초 바깥 trim을 -잡아줍니다. +잡아줍니다. 실제 녹화 중에는 모든 caption frame의 위치, overflow, 줄 수, +outline, 시간 오차와 함께 폰트 적용 여부, 적용된 글자 크기, 줄 균형도 +측정합니다. 자동 channel target은 lint가 켜져 있어야 하며, `storyboardLint:false`이면 automation status가 `needs-fix`가 됩니다. diff --git a/README.md b/README.md index adb257a..73e3b71 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,41 @@ reviews the final candidate. Request changes sends the note back into the agent loop; Approve unlocks only the exact reviewed digest. Manual editor hints appear only with `automation: { manualFallback: true }`. +Localized campaign variants can opt into deterministic, measured typography. +Declare the authored locale and project-local font files; Shotkit embeds those +fonts into the recorded page, verifies glyph coverage before launch, waits for +the browser font load, and shrinks each rendered caption only as far as the +declared minimum size and line count allow: + +```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', + }], + }, +} +``` + +Font paths must remain inside the consumer project. OTF, TTF, WOFF, and WOFF2 +files up to 24 MB are accepted, with at most four fallback faces. Focus chunks +use locale-aware word segmentation and preserve the authored punctuation and +separators, so Japanese and Chinese are not rebuilt with injected spaces. +Runtime QA reports missing glyphs, failed font loads, minimum-size overflow, +and severely unbalanced two-line captions as structured agent fixes. Existing +configs without `typography` retain their legacy system-font behavior. + Project-specific application plans stay repo-internal and are not included in the npm package. @@ -376,7 +411,8 @@ 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, and timing drift. A mismatch becomes structured lint and prevents +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`. diff --git a/docs/handoff-conventions.md b/docs/handoff-conventions.md index 17bd34d..4243632 100644 --- a/docs/handoff-conventions.md +++ b/docs/handoff-conventions.md @@ -14,7 +14,7 @@ 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. @@ -90,6 +90,14 @@ 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. @@ -103,7 +111,8 @@ is considered verified only when its current hash has produced a real - 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, and schedule drift from the real recorded page; + 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. @@ -205,9 +214,12 @@ Fallback tool 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` @@ -216,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. diff --git a/package-lock.json b/package-lock.json index 479c324..dcc9b01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,10 @@ "dependencies": { "ajv": "^8.20.0", "ajv-formats": "^3.0.1", + "fontkit": "^2.0.4", "playwright": "^1.60.0", - "pngjs": "^7.0.0" + "pngjs": "^7.0.0", + "subset-font": "^2.5.0" }, "bin": { "shotkit": "bin/shotkit.js" @@ -1313,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", @@ -2018,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", @@ -2044,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", @@ -2264,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", @@ -2391,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", @@ -2804,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", @@ -2974,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", @@ -3954,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", @@ -4164,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" @@ -4209,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", @@ -4558,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", @@ -4839,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", @@ -4936,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", @@ -4947,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", @@ -4994,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", @@ -5098,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", @@ -5114,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", @@ -5335,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 90a4ed1..762a8fa 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,10 @@ "dependencies": { "ajv": "^8.20.0", "ajv-formats": "^3.0.1", + "fontkit": "^2.0.4", "playwright": "^1.60.0", - "pngjs": "^7.0.0" + "pngjs": "^7.0.0", + "subset-font": "^2.5.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -83,6 +85,8 @@ "src/index.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", diff --git a/schemas/captions.schema.json b/schemas/captions.schema.json index 33129ab..f7b220c 100644 --- a/schemas/captions.schema.json +++ b/schemas/captions.schema.json @@ -31,9 +31,13 @@ "bottomOffset": { "type": "integer", "minimum": 0 }, "wordsPerChunk": { "type": "integer", "minimum": 1, "maximum": 6 }, "wordMs": { "type": "integer", "minimum": 120, "maximum": 2000 }, - "activeColor": { "type": "string" } + "activeColor": { "type": "string" }, + "locale": { "type": "string", "minLength": 1 }, + "direction": { "enum": ["ltr", "rtl"] }, + "typography": { "$ref": "#/$defs/typography" } } }, + "qa": { "$ref": "#/$defs/captionQa" }, "captions": { "type": "array", "items": { @@ -42,7 +46,8 @@ "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"] } } } }, @@ -67,5 +72,92 @@ } } } + }, + "$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/storyboard.schema.json b/schemas/storyboard.schema.json index fc30e55..03e3056 100644 --- a/schemas/storyboard.schema.json +++ b/schemas/storyboard.schema.json @@ -99,7 +99,26 @@ "bottomOffset": { "type": "integer", "minimum": 0 }, "wordsPerChunk": { "type": "integer", "minimum": 1, "maximum": 6 }, "wordMs": { "type": "integer", "minimum": 120, "maximum": 2000 }, - "activeColor": { "type": "string" } + "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": { @@ -120,7 +139,8 @@ "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/skills/capture/SKILL.md b/skills/capture/SKILL.md index d4b2b83..db8075a 100644 --- a/skills/capture/SKILL.md +++ b/skills/capture/SKILL.md @@ -44,6 +44,11 @@ rendered from the shipped code. By default, it also writes a handoff pack: 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 @@ -127,6 +132,15 @@ rendered from the shipped code. By default, it also writes a handoff pack: The resolved style is recorded in both caption and storyboard handoff docs; `captions.json` also carries the trim-relative rendered `timeline[]`. Treat a `dense-focus-caption` lint as an agent-owned timing fix, never drop words. +- Locale typography is a harness contract, not a prompt-only suggestion. Set + `typography.locale`, `family`, `minFontSize`, `maxFontSize`, `maxLines`, and + one or more `fonts[].from` paths. Shotkit preserves authored separators, + verifies glyph coverage, embeds and waits for those fonts, then records the + resolved size and line balance. Fix `caption-locale-missing`, + `caption-font-not-embedded`, `caption-missing-glyph`, + `caption-font-load-failed`, `caption-typography-not-applied`, + `caption-type-fit-failed`, and `caption-unbalanced-lines` before presenting + the candidate. - Runtime caption QA measures the actual DOM frames for bounds, overflow, line count, outline stroke, missing frames, and timing drift. Treat every resulting storyboard warning as an agent-owned config fix and rerun the target. diff --git a/src/caption-language.js b/src/caption-language.js new file mode 100644 index 0000000..2cee3f8 --- /dev/null +++ b/src/caption-language.js @@ -0,0 +1,89 @@ +const RTL_SCRIPTS = new Set([ + 'Adlm', 'Arab', 'Hebr', 'Mand', 'Mend', 'Nkoo', 'Rohg', 'Samr', 'Syrc', 'Thaa', 'Yezi', +]); + +function canonicalLocale(value) { + if (value == null || value === '') return 'und'; + if (typeof value !== 'string') throw new Error('shotkit: caption typography.locale must be a string'); + try { + return Intl.getCanonicalLocales(value.trim())[0] || 'und'; + } catch (error) { + throw new Error(`shotkit: caption typography.locale "${value}" is invalid`, { cause: error }); + } +} + +function localeScript(locale) { + try { + return new Intl.Locale(locale).maximize().script || 'Latn'; + } catch (_error) { + return 'Latn'; + } +} + +function textDirection(locale, requested = 'auto') { + if (!['auto', 'ltr', 'rtl'].includes(requested)) { + throw new Error('shotkit: caption typography.direction must be "auto", "ltr", or "rtl"'); + } + if (requested !== 'auto') return requested; + return RTL_SCRIPTS.has(localeScript(locale)) ? 'rtl' : 'ltr'; +} + +function fallbackWords(value) { + return Array.from(value.matchAll(/\S+/gu)).map((match) => ({ + segment: match[0], + index: match.index, + isWordLike: true, + })); +} + +function segmentedWords(value, locale) { + if (typeof Intl === 'undefined' || typeof Intl.Segmenter !== 'function') return fallbackWords(value); + const requestedLocale = locale === 'und' ? undefined : locale; + return Array.from(new Intl.Segmenter(requestedLocale, { granularity: 'word' }).segment(value)) + .filter((part) => part.isWordLike); +} + +function segmentCaptionText(text, locale = 'und') { + const value = String(text).trim(); + if (!value) return []; + const words = segmentedWords(value, canonicalLocale(locale)); + if (!words.length) return [{ before: '', text: value, after: '' }]; + + return words.map((word, index) => { + const next = words[index + 1]; + const end = word.index + word.segment.length; + return { + before: index === 0 ? value.slice(0, word.index) : '', + text: word.segment, + after: value.slice(end, next ? next.index : value.length), + }; + }); +} + +function chunkCaptionSegments(segments, start, count) { + const chunk = segments.slice(start, start + count).map((segment) => ({ ...segment })); + if (!chunk.length) return chunk; + chunk[0].before = chunk[0].before.replace(/^\s+/u, ''); + chunk[chunk.length - 1].after = chunk[chunk.length - 1].after.replace(/\s+$/u, ''); + return chunk; +} + +function composeCaptionSegments(segments) { + return segments.map((segment) => `${segment.before}${segment.text}${segment.after}`).join(''); +} + +function captionWords(text, locale = 'und') { + return segmentCaptionText(text, locale).map((segment) => ( + `${segment.before}${segment.text}${segment.after.replace(/\s+/gu, '')}` + )); +} + +module.exports = { + canonicalLocale, + captionWords, + chunkCaptionSegments, + composeCaptionSegments, + localeScript, + segmentCaptionText, + textDirection, +}; diff --git a/src/caption-typography.js b/src/caption-typography.js new file mode 100644 index 0000000..343a213 --- /dev/null +++ b/src/caption-typography.js @@ -0,0 +1,296 @@ +const fs = require('fs'); +const path = require('path'); +const fontkit = require('fontkit'); +const subsetFont = require('subset-font'); + +const { canonicalLocale, textDirection } = require('./caption-language'); + +const SYSTEM_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; +const FONT_EXTENSIONS = new Set(['.otf', '.ttf', '.woff', '.woff2']); +const MAX_FONT_BYTES = 24 * 1024 * 1024; +const MAX_FONTS = 4; +const TYPOGRAPHY_KEYS = new Set([ + 'locale', 'direction', 'family', 'weight', 'minFontSize', 'maxFontSize', + 'maxLines', 'fit', 'minLineBalance', 'fonts', +]); +const FONT_KEYS = new Set(['family', 'from', 'weight', 'style', 'postscriptName']); + +function isObject(value) { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function nonEmptyString(value, name) { + if (typeof value !== 'string' || !value.trim()) throw new Error(`shotkit: ${name} must be a non-empty string`); + return value.trim(); +} + +function boundedNumber(value, fallback, name, min, max, integer = false) { + const resolved = value == null ? fallback : value; + if (!Number.isFinite(resolved) || resolved < min || resolved > max || (integer && !Number.isInteger(resolved))) { + throw new Error(`shotkit: caption typography.${name} must be ${integer ? 'an integer' : 'a number'} between ${min} and ${max}`); + } + return resolved; +} + +function fontWeight(value, name) { + if (value == null) return null; + if (Number.isInteger(value) && value >= 1 && value <= 1000) return String(value); + if (typeof value === 'string' && value.trim()) return value.trim(); + throw new Error(`shotkit: ${name} must be a font weight or variable weight range`); +} + +function normalizeFont(font, index) { + if (!isObject(font)) throw new Error(`shotkit: caption typography.fonts[${index}] must be an object`); + const unknown = Object.keys(font).filter((key) => !FONT_KEYS.has(key)); + if (unknown.length) { + throw new Error(`shotkit: caption typography.fonts[${index}] has unknown field(s): ${unknown.join(', ')}`); + } + const style = font.style == null ? 'normal' : font.style; + if (!['normal', 'italic', 'oblique'].includes(style)) { + throw new Error(`shotkit: caption typography.fonts[${index}].style must be normal, italic, or oblique`); + } + return { + family: nonEmptyString(font.family, `caption typography.fonts[${index}].family`), + from: nonEmptyString(font.from, `caption typography.fonts[${index}].from`), + weight: fontWeight(font.weight, `caption typography.fonts[${index}].weight`) || '400', + style, + ...(font.postscriptName == null ? {} : { + postscriptName: nonEmptyString(font.postscriptName, `caption typography.fonts[${index}].postscriptName`), + }), + }; +} + +function quotedFamily(family) { + return `"${family.replace(/["\\]/g, '\\$&')}"`; +} + +function normalizeTypographyOptions(captionOptions = {}) { + const raw = captionOptions && captionOptions.typography; + if (raw == null) { + return { + enabled: false, + locale: 'und', + direction: 'ltr', + family: SYSTEM_FONT_STACK, + weight: null, + minFontSize: 18, + maxFontSize: null, + maxLines: 2, + fit: 'none', + minLineBalance: 0, + fonts: [], + }; + } + if (!isObject(raw)) throw new Error('shotkit: caption typography must be an object'); + const unknown = Object.keys(raw).filter((key) => !TYPOGRAPHY_KEYS.has(key)); + if (unknown.length) throw new Error(`shotkit: caption typography has unknown field(s): ${unknown.join(', ')}`); + const locale = canonicalLocale(raw.locale); + const direction = textDirection(locale, raw.direction || 'auto'); + const fonts = raw.fonts == null ? [] : raw.fonts; + if (!Array.isArray(fonts) || fonts.length > MAX_FONTS) { + throw new Error(`shotkit: caption typography.fonts must be an array with at most ${MAX_FONTS} entries`); + } + const normalizedFonts = fonts.map(normalizeFont); + const family = raw.family == null + ? (normalizedFonts.length + ? `${normalizedFonts.map((font) => quotedFamily(font.family)).join(', ')}, ${SYSTEM_FONT_STACK}` + : SYSTEM_FONT_STACK) + : nonEmptyString(raw.family, 'caption typography.family'); + const unreferencedFonts = raw.family == null + ? [] + : normalizedFonts.filter((font) => !family.toLowerCase().includes(font.family.toLowerCase())); + if (unreferencedFonts.length) { + throw new Error(`shotkit: caption typography.family must reference configured font family: ${unreferencedFonts.map((font) => font.family).join(', ')}`); + } + const fit = raw.fit == null ? 'shrink' : raw.fit; + if (!['none', 'shrink'].includes(fit)) throw new Error('shotkit: caption typography.fit must be "none" or "shrink"'); + const minFontSize = boundedNumber(raw.minFontSize, 22, 'minFontSize', 12, 96, true); + const maxFontSize = raw.maxFontSize == null + ? null + : boundedNumber(raw.maxFontSize, null, 'maxFontSize', minFontSize, 120, true); + return { + enabled: true, + locale, + direction, + family, + weight: fontWeight(raw.weight, 'caption typography.weight'), + minFontSize, + maxFontSize, + maxLines: boundedNumber(raw.maxLines, 2, 'maxLines', 1, 3, true), + fit, + minLineBalance: boundedNumber(raw.minLineBalance, 0.38, 'minLineBalance', 0, 1), + fonts: normalizedFonts, + }; +} + +function typographyStyle(captionOptions = {}) { + const typography = normalizeTypographyOptions(captionOptions); + if (!typography.enabled) return null; + return { + locale: typography.locale, + direction: typography.direction, + family: typography.family, + ...(typography.weight == null ? {} : { weight: typography.weight }), + minFontSize: typography.minFontSize, + ...(typography.maxFontSize == null ? {} : { maxFontSize: typography.maxFontSize }), + maxLines: typography.maxLines, + fit: typography.fit, + minLineBalance: typography.minLineBalance, + fontFamilies: typography.fonts.map((font) => font.family), + }; +} + +function fontMime(filePath) { + switch (path.extname(filePath).toLowerCase()) { + case '.otf': return 'font/otf'; + case '.ttf': return 'font/ttf'; + case '.woff': return 'font/woff'; + case '.woff2': return 'font/woff2'; + default: return 'application/octet-stream'; + } +} + +function fontPath(cwd, from) { + const root = fs.realpathSync(cwd); + const requested = path.resolve(root, from); + let resolved; + try { + resolved = fs.realpathSync(requested); + } catch (error) { + throw new Error(`shotkit: caption font was not found: ${from}`, { cause: error }); + } + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + throw new Error('shotkit: caption typography font paths must stay inside the project directory'); + } + if (!FONT_EXTENSIONS.has(path.extname(resolved).toLowerCase())) { + throw new Error(`shotkit: unsupported caption font format: ${path.extname(resolved) || '(none)'}`); + } + const stats = fs.statSync(resolved); + if (!stats.isFile() || stats.size > MAX_FONT_BYTES) { + throw new Error(`shotkit: caption font must be a file no larger than ${MAX_FONT_BYTES} bytes`); + } + return resolved; +} + +function relevantCharacters(texts) { + const characters = new Map(); + for (const text of texts || []) { + for (const character of String(text)) { + const codePoint = character.codePointAt(0); + if (/\s/u.test(character) || codePoint === 0x200d + || (codePoint >= 0xfe00 && codePoint <= 0xfe0f) + || (codePoint >= 0xe0100 && codePoint <= 0xe01ef)) continue; + if (!characters.has(codePoint)) characters.set(codePoint, character); + } + } + return characters; +} + +function codePointLabel(codePoint) { + return `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`; +} + +async function prepareCaptionTypography(captionOptions = {}, cwd, texts = []) { + const typography = normalizeTypographyOptions(captionOptions); + if (!typography.enabled) { + return { + runtimeOptions: captionOptions, + report: { enabled: false, deterministic: false, locale: 'und', direction: 'ltr' }, + }; + } + + const characters = relevantCharacters(texts); + const prepared = await Promise.all(typography.fonts.map(async (font) => { + const resolved = fontPath(cwd, font.from); + const buffer = fs.readFileSync(resolved); + let parsed; + try { + parsed = fontkit.create(buffer, font.postscriptName); + } catch (error) { + throw new Error(`shotkit: could not parse caption font ${font.from}: ${error.message}`, { cause: error }); + } + if (!parsed || typeof parsed.hasGlyphForCodePoint !== 'function') { + throw new Error(`shotkit: caption font ${font.from} needs postscriptName for its font collection`); + } + const subsetText = ` ${Array.from(characters) + .filter(([codePoint]) => parsed.hasGlyphForCodePoint(codePoint)) + .map(([, character]) => character) + .join('')}`; + let embedded = buffer; + if (characters.size) { + const numericWeight = typography.weight && /^\d+$/u.test(typography.weight) + ? Number(typography.weight) + : null; + const variationAxes = numericWeight != null && parsed.variationAxes && parsed.variationAxes.wght + ? { wght: numericWeight } + : undefined; + try { + embedded = await subsetFont(buffer, subsetText, { + targetFormat: 'woff2', + ...(variationAxes ? { variationAxes } : {}), + }); + } catch (error) { + throw new Error(`shotkit: could not subset caption font ${font.from}: ${error.message}`, { cause: error }); + } + } + return { + ...font, + resolved, + parsed, + sourceBytes: buffer.length, + embeddedBytes: embedded.length, + source: `data:${characters.size ? 'font/woff2' : fontMime(resolved)};base64,${embedded.toString('base64')}`, + }; + })); + const missingGlyphs = []; + for (const [codePoint, character] of characters) { + if (!prepared.some((font) => font.parsed.hasGlyphForCodePoint(codePoint))) { + missingGlyphs.push({ character, codePoint: codePointLabel(codePoint) }); + } + } + const publicStyle = typographyStyle(captionOptions); + const fontFaces = prepared.map((font) => ({ + family: font.family, + source: font.source, + weight: font.weight, + style: font.style, + })); + return { + runtimeOptions: { + ...captionOptions, + typography: { ...publicStyle, enabled: true, fontFaces }, + }, + report: { + enabled: true, + deterministic: prepared.length > 0, + locale: typography.locale, + direction: typography.direction, + family: typography.family, + fit: typography.fit, + minFontSize: typography.minFontSize, + maxFontSize: typography.maxFontSize, + maxLines: typography.maxLines, + minLineBalance: typography.minLineBalance, + fontFamilies: prepared.map((font) => font.family), + fontFiles: prepared.map((font) => ( + path.relative(fs.realpathSync(cwd), font.resolved).split(path.sep).join('/') + )), + fontOptimization: prepared.map((font) => ({ + family: font.family, + sourceBytes: font.sourceBytes, + embeddedBytes: font.embeddedBytes, + })), + analyzedTextCount: texts.length, + missingGlyphs, + }, + }; +} + +module.exports = { + MAX_FONT_BYTES, + MAX_FONTS, + SYSTEM_FONT_STACK, + normalizeTypographyOptions, + prepareCaptionTypography, + typographyStyle, +}; diff --git a/src/capture.js b/src/capture.js index 445d613..46a95dd 100644 --- a/src/capture.js +++ b/src/capture.js @@ -39,6 +39,7 @@ const { resolveSize } = require('./presets'); const { postProcessDemo, probeVideo } = require('./video'); const { analyzeDemoStoryboard, createDemoController, installDemoCaptionOverlay, normalizeDemoConfigs } = require('./demo'); const { analyzeDemoCaptionMetrics } = require('./demo-caption-qa'); +const { prepareCaptionTypography } = require('./caption-typography'); const { applyCalibrationProfiles, loadCalibration } = require('./calibration'); const { deliveryStatus } = require('./approval'); const { assetRecord, writeHandoffDocs } = require('./handoff'); @@ -147,6 +148,7 @@ async function capture(config, opts = {}) { const capturedDemoConfigs = []; const demoViewports = {}; const demoWarnings = {}; + const demoCaptionReports = {}; const shouldRunVisualPass = targetOnly.size === 0 && wantsAny(only, visualOutputNames(config)); const shouldRunTextPass = targetOnly.size === 0 && wantsAny(only, textOutputNames(config, cwd)); const requestedDemoConfigs = demoConfigs.filter((demoConfig) => wantsDemo(demoConfig) && wantsTarget(demoConfig)); @@ -369,7 +371,12 @@ async function capture(config, opts = {}) { let setup2 = normalizeSetup(null); let page = null; try { - await installDemoCaptionOverlay(demoCtx.context, demoConfig.captionOptions || {}); + const preparedTypography = await prepareCaptionTypography( + demoConfig.captionOptions || {}, + cwd, + (demoConfig.captions || []).map((caption) => caption.text), + ); + await installDemoCaptionOverlay(demoCtx.context, preparedTypography.runtimeOptions); // Keep a small "unofficial" badge on screen across navigations. A // target-specific story may shorten or disable the global screenshot @@ -401,10 +408,19 @@ async function capture(config, opts = {}) { ); page = await demoCtx.context.newPage(); await page.setViewportSize(viewport); + if (preparedTypography.report.enabled) { + await page.evaluate(async () => { + if (window.__shotkitDemoCaption && typeof window.__shotkitDemoCaption.ready === 'function') { + await window.__shotkitDemoCaption.ready(); + } + }); + } const demo = createDemoController({ page, captions: demoConfig.captions, captionOptions: demoConfig.captionOptions, + runtimeCaptionOptions: preparedTypography.runtimeOptions, + typographyReport: preparedTypography.report, }); let captionMetricReport; try { @@ -423,6 +439,7 @@ async function capture(config, opts = {}) { captionMetricReport = demo.captionMetrics(); demo.stop(); } + demoCaptionReports[demoConfig.name] = captionMetricReport; const calibrationProfile = demoConfig.calibrationProfile || {}; const runtimeCaptionWarnings = analyzeDemoCaptionMetrics(captionMetricReport, { viewport, @@ -522,6 +539,7 @@ async function capture(config, opts = {}) { demoConfigs: capturedDemoConfigs, demoViewports, demoWarnings, + demoCaptionReports, flags: passFlags, // Scene-filtered or --no-video runs only re-capture a subset; merge into // the existing handoff contract rather than clobbering a prior full run. diff --git a/src/demo-caption-focus.js b/src/demo-caption-focus.js index 29e4355..4f54f59 100644 --- a/src/demo-caption-focus.js +++ b/src/demo-caption-focus.js @@ -3,6 +3,13 @@ const DEFAULT_FOCUS_WORD_MS = 360; const DEFAULT_FOCUS_ACTIVE_COLOR = '#facc15'; const MIN_FOCUS_FRAME_MS = 120; const CAPTION_POSITIONS = new Set(['bottom-left', 'bottom']); +const { + captionWords, + chunkCaptionSegments, + composeCaptionSegments, + segmentCaptionText, +} = require('./caption-language'); +const { normalizeTypographyOptions, typographyStyle } = require('./caption-typography'); function captionOptionObject(options) { if (options == null) return {}; @@ -18,6 +25,7 @@ function captionOptionObject(options) { if (options.appearance != null && options.appearance !== 'panel' && options.appearance !== 'outline') { throw new Error('shotkit: demo captionOptions.appearance must be "panel" or "outline"'); } + normalizeTypographyOptions(options); return options; } @@ -78,77 +86,80 @@ function captionStyle(options = {}) { style.wordMs = focus.wordMs; style.activeColor = focus.activeColor; } + const typography = typographyStyle(options); + if (typography) { + style.locale = typography.locale; + style.direction = typography.direction; + style.typography = typography; + } return style; } -function splitCaptionWords(text) { - const value = String(text).trim(); - if (!value) return []; - if (/\s/u.test(value) || typeof Intl === 'undefined' || typeof Intl.Segmenter !== 'function') { - return value.split(/\s+/u).filter(Boolean); - } - - const words = []; - let prefix = ''; - for (const part of new Intl.Segmenter(undefined, { granularity: 'word' }).segment(value)) { - if (part.isWordLike) { - words.push(`${prefix}${part.segment}`); - prefix = ''; - } else if (words.length) { - words[words.length - 1] += part.segment; - } else { - prefix += part.segment; - } - } - if (prefix) words.push(prefix); - return words.length ? words : [value]; +function splitCaptionWords(text, locale = 'und') { + return captionWords(text, locale); } -function focusFrame(caption, atMs, focusWords, activeWordIndex, condensed = false) { +function focusFrame(caption, atMs, focusSegments, activeWordIndex, typography, condensed = false) { + const focusWords = focusSegments.map((segment) => segment.text); return { atMs, - text: focusWords.join(' '), + text: composeCaptionSegments(focusSegments), sourceAtMs: caption.atMs, sourceText: caption.text, options: { focusWords, + focusSegments, activeWordIndex, fullText: caption.text, + locale: typography.locale, + direction: typography.direction, condensed, }, }; } function captionChunks(words, wordsPerChunk) { + const chunkCount = Math.ceil(words.length / wordsPerChunk); + if (!chunkCount) return []; + const baseSize = Math.floor(words.length / chunkCount); + const largerChunks = words.length % chunkCount; const chunks = []; - for (let index = 0; index < words.length; index += wordsPerChunk) { - chunks.push(words.slice(index, index + wordsPerChunk)); + let start = 0; + for (let index = 0; index < chunkCount; index++) { + const size = baseSize + (index < largerChunks ? 1 : 0); + chunks.push({ start, items: words.slice(start, start + size) }); + start += size; } return chunks; } -function buildFocusCaptionFrames(caption, words, nextAtMs, focus) { +function buildFocusCaptionFrames(caption, segments, nextAtMs, focus, typography) { const availableMs = nextAtMs - caption.atMs; const hasBoundary = Number.isFinite(availableMs); - const desiredMs = words.length * focus.wordMs; + const desiredMs = segments.length * focus.wordMs; let cadenceMs = focus.wordMs; if (hasBoundary && availableMs < desiredMs) { - cadenceMs = Math.floor(availableMs / words.length); + cadenceMs = Math.floor(availableMs / segments.length); } if (!hasBoundary || cadenceMs >= MIN_FOCUS_FRAME_MS) { - return words.map((_word, wordIndex) => { - const chunkStart = Math.floor(wordIndex / focus.wordsPerChunk) * focus.wordsPerChunk; + const chunks = captionChunks(segments, focus.wordsPerChunk); + return segments.map((_segment, wordIndex) => { + const chunk = chunks.find((candidate) => ( + wordIndex >= candidate.start && wordIndex < candidate.start + candidate.items.length + )); return focusFrame( caption, caption.atMs + (wordIndex * cadenceMs), - words.slice(chunkStart, chunkStart + focus.wordsPerChunk), - wordIndex - chunkStart, + chunkCaptionSegments(segments, chunk.start, chunk.items.length), + wordIndex - chunk.start, + typography, ); }); } - const chunks = captionChunks(words, focus.wordsPerChunk); + const chunks = captionChunks(segments, focus.wordsPerChunk) + .map((chunk) => chunkCaptionSegments(segments, chunk.start, chunk.items.length)); const chunkCadenceMs = Math.floor(availableMs / chunks.length); if (chunkCadenceMs >= MIN_FOCUS_FRAME_MS) { return chunks.map((chunk, index) => focusFrame( @@ -156,10 +167,11 @@ function buildFocusCaptionFrames(caption, words, nextAtMs, focus) { caption.atMs + (index * chunkCadenceMs), chunk, 0, + typography, )); } - return [focusFrame(caption, caption.atMs, words, 0, true)]; + return [focusFrame(caption, caption.atMs, chunkCaptionSegments(segments, 0, segments.length), 0, typography, true)]; } function buildCaptionFrames(schedule = [], options = {}) { @@ -173,11 +185,12 @@ function buildCaptionFrames(schedule = [], options = {}) { })); } + const typography = normalizeTypographyOptions(options); const frames = []; schedule.forEach((caption, captionIndex) => { - const words = splitCaptionWords(caption.text); + const segments = segmentCaptionText(caption.text, typography.locale); const nextAtMs = schedule[captionIndex + 1] ? schedule[captionIndex + 1].atMs : Number.POSITIVE_INFINITY; - if (!words.length) { + if (!segments.length) { frames.push({ ...caption, sourceAtMs: caption.atMs, @@ -186,7 +199,7 @@ function buildCaptionFrames(schedule = [], options = {}) { }); return; } - frames.push(...buildFocusCaptionFrames(caption, words, nextAtMs, focus)); + frames.push(...buildFocusCaptionFrames(caption, segments, nextAtMs, focus, typography)); }); return frames; } @@ -198,7 +211,8 @@ function analyzeFocusCaptionDensity(schedule = [], options = {}) { return schedule.flatMap((caption, index) => { const next = schedule[index + 1]; if (!next) return []; - const wordCount = splitCaptionWords(caption.text).length; + const typography = normalizeTypographyOptions(options); + const wordCount = segmentCaptionText(caption.text, typography.locale).length; const availableMs = next.atMs - caption.atMs; const recommendedMs = wordCount * focus.wordMs; if (!wordCount || availableMs >= recommendedMs) return []; diff --git a/src/demo-caption-qa.js b/src/demo-caption-qa.js index 8683af1..b9f33e8 100644 --- a/src/demo-caption-qa.js +++ b/src/demo-caption-qa.js @@ -53,6 +53,31 @@ function analyzeDemoCaptionMetrics(report = {}, { viewport, protectedRegions = [ warned.add(code); warnings.push(warning(code, message, fix, details)); }; + const typography = report.typography && report.typography.enabled ? report.typography : null; + if (typography) { + if (typography.locale === 'und') { + warnOnce( + 'caption-locale-missing', + 'caption typography does not declare a locale', + 'set captionOptions.typography.locale to the authored caption language', + ); + } + if (!typography.deterministic) { + warnOnce( + 'caption-font-not-embedded', + 'caption typography relies on environment-specific system fonts', + 'configure one or more project-local captionOptions.typography.fonts', + ); + } + if (Array.isArray(typography.missingGlyphs) && typography.missingGlyphs.length) { + warnOnce( + 'caption-missing-glyph', + `configured caption fonts miss ${typography.missingGlyphs.length} authored character(s)`, + 'add a locale-appropriate fallback font that covers every listed code point', + { missingGlyphs: typography.missingGlyphs }, + ); + } + } for (const sample of samples) { const sampleViewport = viewportFor(sample, viewport); @@ -78,6 +103,43 @@ function analyzeDemoCaptionMetrics(report = {}, { viewport, protectedRegions = [ { overflowX: !!sample.overflowX, overflowY: !!sample.overflowY }, ); } + if (typography && sample.fontLoaded === false) { + warnOnce( + 'caption-font-load-failed', + `caption font failed to load for "${sample.sourceText || sample.text}"`, + 'verify the configured font file and rerun the target', + { errors: sample.fontErrors || [] }, + ); + } + if (typography && (sample.fontConfigured === false || sample.fitStatus === 'not-requested')) { + warnOnce( + 'caption-typography-not-applied', + `configured caption typography was not applied to "${sample.sourceText || sample.text}"`, + 'preserve the prepared caption options across pointer, select, and navigation helpers, then rerun the target', + ); + } + if (typography && sample.fitStatus === 'overflow') { + warnOnce( + 'caption-type-fit-failed', + `caption "${sample.sourceText || sample.text}" does not fit at the configured minimum size`, + 'shorten the authored chunk, widen its template lane, or lower typography.minFontSize', + { + fontSize: sample.fontSize, + minFontSize: sample.minFontSize, + lineCount: sample.lineCount, + maxLines: sample.maxLines, + }, + ); + } + if (typography && sample.lineCount > 1 && Number.isFinite(sample.lineBalance) + && sample.lineBalance < typography.minLineBalance) { + warnOnce( + 'caption-unbalanced-lines', + `caption "${sample.sourceText || sample.text}" has an unbalanced final line`, + 'split the caption at a semantic boundary or adjust the template caption width', + { lineWidths: sample.lineWidths, lineBalance: sample.lineBalance, minimum: typography.minLineBalance }, + ); + } if (sample.lineCount > 2) { warnOnce( 'caption-too-many-lines', diff --git a/src/demo-storyboard.js b/src/demo-storyboard.js index 15c95bb..f39f7ec 100644 --- a/src/demo-storyboard.js +++ b/src/demo-storyboard.js @@ -90,7 +90,8 @@ function analyzeDemoStoryboard(demoConfig, { viewport, mp4Requested } = {}) { } const text = captions.map((caption) => caption.text).join(' ').toLowerCase(); - if (captions.length && !/(restore|original|safe|undo|revert|reset|복구|원문|되돌)/i.test(text)) { + const hasSafetyRole = captions.some((caption) => caption.role === 'safety' || caption.role === 'restore'); + if (captions.length && !hasSafetyRole && !/(restore|original|safe|undo|revert|reset|복구|원문|되돌)/i.test(text)) { warnings.push(storyboardWarning( 'missing-safety-restore', 'storyboard has no visible safety/restore beat', diff --git a/src/demo-time.js b/src/demo-time.js index e9564e5..69fee26 100644 --- a/src/demo-time.js +++ b/src/demo-time.js @@ -1,3 +1,5 @@ +const CAPTION_ROLES = new Set(['result', 'action', 'proof', 'safety', 'restore', 'cta']); + function normalizeDelayMs(value, label) { if (!Number.isFinite(value) || value < 0) { throw new Error(`shotkit: demo ${label} must be a non-negative number of milliseconds`); @@ -43,9 +45,13 @@ function normalizeDemoCaptions(captions = []) { if (caption.text == null) { throw new Error(`shotkit: demo.captions[${index}] needs text`); } + if (caption.role != null && !CAPTION_ROLES.has(caption.role)) { + throw new Error(`shotkit: demo.captions[${index}].role must be result, action, proof, safety, restore, or cta`); + } return { atMs: parseTimeToMs(caption.at, `at for captions[${index}]`), text: String(caption.text), + ...(caption.role == null ? {} : { role: caption.role }), }; }) .sort((a, b) => a.atMs - b.atMs); diff --git a/src/demo.js b/src/demo.js index 6ab9b14..489bbe9 100644 --- a/src/demo.js +++ b/src/demo.js @@ -51,7 +51,43 @@ function demoCaptionInitScript(options = {}) { const styleId = '__shotkit_demo_caption_style__'; const baseOptions = { position: options.position || 'bottom-left', + typography: options.typography && typeof options.typography === 'object' + ? options.typography + : { enabled: false }, }; + let fontLoadPromise = null; + + function loadCaptionFonts() { + const faces = Array.isArray(baseOptions.typography.fontFaces) + ? baseOptions.typography.fontFaces + : []; + if (!faces.length) return Promise.resolve({ configured: false, loaded: true, errors: [] }); + if (fontLoadPromise) return fontLoadPromise; + fontLoadPromise = (async () => { + const startedAt = performance.now(); + if (typeof FontFace !== 'function' || !document.fonts) { + return { configured: true, loaded: false, loadMs: 0, errors: ['FontFace API is unavailable'] }; + } + const results = await Promise.allSettled(faces.map(async (face) => { + const loaded = await new FontFace(face.family, `url(${face.source})`, { + weight: face.weight || '400', + style: face.style || 'normal', + }).load(); + document.fonts.add(loaded); + return face.family; + })); + const errors = results + .filter((result) => result.status === 'rejected') + .map((result) => String(result.reason && result.reason.message ? result.reason.message : result.reason)); + return { + configured: true, + loaded: errors.length === 0, + loadMs: Math.round(performance.now() - startedAt), + errors, + }; + })(); + return fontLoadPromise; + } function ensureStyle() { if (document.getElementById(styleId)) return; @@ -88,7 +124,7 @@ function demoCaptionInitScript(options = {}) { flex-wrap: wrap; align-items: center; justify-content: center; - column-gap: .22em; + column-gap: 0; row-gap: .08em; padding: 14px 22px 16px; background: rgba(8,11,16,.86); @@ -136,7 +172,10 @@ function demoCaptionInitScript(options = {}) { display: inline-block; color: rgba(255,255,255,.72); transform-origin: center bottom; + white-space: pre-wrap; } + #${rootId} .shotkit-caption-word::before { content: attr(data-before); } + #${rootId} .shotkit-caption-word::after { content: attr(data-after); } #${rootId} .shotkit-caption-word[data-active="true"] { color: var(--shotkit-caption-active-color, #facc15); text-shadow: 0 2px 14px rgba(0,0,0,.52); @@ -243,9 +282,9 @@ function demoCaptionInitScript(options = {}) { 100% { opacity: 0; transform: scale(1.9); } } @keyframes shotkit-caption-focus-pop { - 0% { opacity: .55; transform: translateY(5px) scale(.86); } - 70% { opacity: 1; transform: translateY(-1px) scale(1.1); } - 100% { opacity: 1; transform: translateY(0) scale(1.06); } + 0% { opacity: .55; transform: translateY(4px) scale(.94); } + 70% { opacity: 1; transform: translateY(-1px) scale(1.04); } + 100% { opacity: 1; transform: translateY(0) scale(1); } } `; document.head.appendChild(style); @@ -280,16 +319,135 @@ function demoCaptionInitScript(options = {}) { return pointer; } - function show(text, nextOptions = {}) { + function captionLines(root) { + const words = Array.from(root.querySelectorAll('.shotkit-caption-word')); + const rects = words.length + ? words.map((word) => ({ + top: word.offsetTop, + left: word.offsetLeft, + right: word.offsetLeft + word.offsetWidth, + width: word.offsetWidth, + height: word.offsetHeight, + })) + : (() => { + const range = document.createRange(); + range.selectNodeContents(root); + return Array.from(range.getClientRects()); + })(); + const lines = []; + for (const rect of rects.filter((item) => item.width > 0 && item.height > 0)) { + let line = lines.find((item) => Math.abs(item.top - rect.top) <= 2); + if (!line) { + line = { top: rect.top, left: rect.left, right: rect.right }; + lines.push(line); + } else { + line.left = Math.min(line.left, rect.left); + line.right = Math.max(line.right, rect.right); + } + } + return lines.sort((first, second) => first.top - second.top) + .map((line) => Math.max(0, line.right - line.left)); + } + + function captionMeasure(root) { + const lineWidths = captionLines(root); + const widest = lineWidths.length ? Math.max(...lineWidths) : 0; + const narrowest = lineWidths.length ? Math.min(...lineWidths) : 0; + return { + overflowX: root.scrollWidth > root.clientWidth + 1, + overflowY: root.scrollHeight > root.clientHeight + 1, + lineCount: lineWidths.length, + lineWidths: lineWidths.map((width) => Math.round(width * 100) / 100), + lineBalance: lineWidths.length > 1 && widest > 0 ? narrowest / widest : 1, + }; + } + + function fitCaption(root, typography) { + const computed = window.getComputedStyle(root); + const cssSize = Number.parseFloat(computed.fontSize) || 24; + const maximum = Number.isFinite(typography.maxFontSize) ? typography.maxFontSize : Math.round(cssSize); + const minimum = Math.min(maximum, Number.isFinite(typography.minFontSize) ? typography.minFontSize : maximum); + const maxLines = Number.isInteger(typography.maxLines) ? typography.maxLines : 2; + const setSize = (size) => { + root.style.fontSize = `${size}px`; + const measured = captionMeasure(root); + return { + ...measured, + fontSize: size, + fits: !measured.overflowX && !measured.overflowY && measured.lineCount <= maxLines, + }; + }; + + if (!typography.enabled) { + const measured = captionMeasure(root); + return { + ...measured, + requestedFontSize: cssSize, + fontSize: cssSize, + minFontSize: null, + maxLines, + fitStatus: 'not-requested', + }; + } + if (typography.fit !== 'shrink') { + const measured = setSize(maximum); + return { + ...measured, + requestedFontSize: maximum, + minFontSize: minimum, + maxLines, + fitStatus: measured.fits ? 'fit' : 'overflow', + }; + } + + let low = minimum; + let high = maximum; + let best = null; + while (low <= high) { + const size = Math.floor((low + high) / 2); + const measured = setSize(size); + if (measured.fits) { + best = measured; + low = size + 1; + } else { + high = size - 1; + } + } + const measured = best || setSize(minimum); + if (best) root.style.fontSize = `${best.fontSize}px`; + return { + ...measured, + requestedFontSize: maximum, + minFontSize: minimum, + maxLines, + fitStatus: measured.fits ? 'fit' : 'overflow', + }; + } + + async function show(text, nextOptions = {}) { const root = ensureRoot(); if (!root) return null; const position = nextOptions.position || baseOptions.position; + const typography = baseOptions.typography && baseOptions.typography.enabled + ? { ...baseOptions.typography, ...(nextOptions.typography || {}) } + : { enabled: false }; root.dataset.position = position; root.dataset.mode = nextOptions.mode === 'focus' ? 'focus' : 'static'; root.dataset.appearance = nextOptions.appearance === 'outline' ? 'outline' : 'panel'; - root.dataset.condensed = nextOptions.condensed || ( - root.dataset.mode === 'focus' && String(text).length > 42 - ) ? 'true' : 'false'; + root.dataset.condensed = nextOptions.condensed || (!typography.enabled + && root.dataset.mode === 'focus' && String(text).length > 42) ? 'true' : 'false'; + if (typography.enabled) { + root.lang = nextOptions.locale || typography.locale || 'und'; + root.dir = nextOptions.direction || typography.direction || 'ltr'; + root.style.fontFamily = typography.family; + if (typography.weight) root.style.fontWeight = typography.weight; + } else { + root.removeAttribute('lang'); + root.removeAttribute('dir'); + root.style.removeProperty('font-family'); + root.style.removeProperty('font-weight'); + root.style.removeProperty('font-size'); + } if (Number.isFinite(nextOptions.bottomOffset) && nextOptions.bottomOffset >= 0) { root.style.setProperty('--shotkit-caption-bottom-offset', `${Math.round(nextOptions.bottomOffset)}px`); } else { @@ -297,14 +455,24 @@ function demoCaptionInitScript(options = {}) { } root.style.setProperty('--shotkit-caption-active-color', nextOptions.activeColor || '#facc15'); root.textContent = ''; - if (root.dataset.mode === 'focus' && Array.isArray(nextOptions.focusWords)) { - nextOptions.focusWords.forEach((word, index) => { + if (root.dataset.mode === 'focus' && (Array.isArray(nextOptions.focusSegments) + || Array.isArray(nextOptions.focusWords))) { + const segments = Array.isArray(nextOptions.focusSegments) + ? nextOptions.focusSegments + : nextOptions.focusWords.map((word, index, words) => ({ + before: '', + text: word, + after: index === words.length - 1 ? '' : ' ', + })); + segments.forEach((segment, index) => { // Use an element most page translators do not scan. The root's // translate=no marker covers standards-aware localization tools too. const wordElement = document.createElement('b'); wordElement.className = 'shotkit-caption-word'; wordElement.dataset.active = index === nextOptions.activeWordIndex ? 'true' : 'false'; - wordElement.textContent = String(word); + wordElement.dataset.before = String(segment.before || ''); + wordElement.dataset.after = String(segment.after || ''); + wordElement.textContent = String(segment.text); root.appendChild(wordElement); }); root.setAttribute('aria-label', String(nextOptions.fullText || text)); @@ -315,26 +483,13 @@ function demoCaptionInitScript(options = {}) { root.setAttribute('aria-live', 'polite'); } root.dataset.visible = text ? 'true' : 'false'; + const fontState = await loadCaptionFonts(); + const fit = fitCaption(root, typography); const rect = root.getBoundingClientRect(); const rootStyle = window.getComputedStyle(root); const firstWord = root.querySelector('.shotkit-caption-word'); const textStyle = firstWord ? window.getComputedStyle(firstWord) : rootStyle; - const wordElements = Array.from(root.querySelectorAll('.shotkit-caption-word')); - let lineCount; - if (wordElements.length) { - // offsetTop ignores the active-word scale animation, so a pop cannot be - // mistaken for a second line. - lineCount = new Set(wordElements.map((wordElement) => wordElement.offsetTop)).size; - } else { - const range = document.createRange(); - range.selectNodeContents(root); - lineCount = new Set( - Array.from(range.getClientRects()) - .filter((lineRect) => lineRect.width > 0 && lineRect.height > 0) - .map((lineRect) => Math.round(lineRect.top)), - ).size; - } const stroke = textStyle.webkitTextStrokeWidth || textStyle.getPropertyValue('-webkit-text-stroke-width') || '0'; @@ -353,9 +508,23 @@ function demoCaptionInitScript(options = {}) { height: rect.height, }, viewport: { width: window.innerWidth, height: window.innerHeight }, - overflowX: root.scrollWidth > root.clientWidth + 1, - overflowY: root.scrollHeight > root.clientHeight + 1, - lineCount, + overflowX: fit.overflowX, + overflowY: fit.overflowY, + lineCount: fit.lineCount, + lineWidths: fit.lineWidths, + lineBalance: fit.lineBalance, + fitStatus: fit.fitStatus, + fontSize: fit.fontSize, + requestedFontSize: fit.requestedFontSize, + minFontSize: fit.minFontSize, + maxLines: fit.maxLines, + locale: typography.enabled ? root.lang : null, + direction: typography.enabled ? root.dir : null, + fontConfigured: fontState.configured, + fontLoaded: fontState.configured ? fontState.loaded : null, + fontLoadMs: Number.isFinite(fontState.loadMs) ? fontState.loadMs : null, + fontErrors: fontState.errors, + fontFamily: rootStyle.fontFamily, strokeWidth: Number.parseFloat(stroke) || 0, }; } @@ -390,8 +559,9 @@ function demoCaptionInitScript(options = {}) { if (pointer) pointer.dataset.visible = 'false'; } - window.__shotkitDemoCaption = { show, hide }; + window.__shotkitDemoCaption = { show, hide, ready: loadCaptionFonts }; window.__shotkitDemoPointer = { move: movePointer, pulse: pulsePointer, hide: hidePointer }; + void loadCaptionFonts(); const install = () => ensureRoot(); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install, { once: true }); else install(); @@ -429,8 +599,13 @@ async function hideDemoCaption(page) { }); } +function hasDemoPointerOverlay() { + return !!window.__shotkitDemoPointer; +} + async function moveDemoPointer(page, point, options = {}) { - await ensureDemoCaptionOverlay(page, options); + const installed = await page.evaluate(hasDemoPointerOverlay).catch(() => false); + if (!installed) await ensureDemoCaptionOverlay(page); await page.evaluate( ({ pointerPoint, pointerOptions }) => { window.__shotkitDemoPointer.move(pointerPoint, pointerOptions); @@ -484,7 +659,13 @@ async function clickTarget(page, target, clickOptions) { throw new Error('shotkit: demo.click target must be a selector string, Locator, or { x, y } point'); } -function createDemoController({ page, captions = [], captionOptions = {} }) { +function createDemoController({ + page, + captions = [], + captionOptions = {}, + runtimeCaptionOptions = captionOptions, + typographyReport = null, +}) { const schedule = normalizeDemoCaptions(captions); const captionFrames = buildCaptionFrames(schedule, captionOptions); const timers = []; @@ -501,8 +682,9 @@ function createDemoController({ page, captions = [], captionOptions = {} }) { activeText = String(text || ''); activeOptions = options || {}; activeExpectedAtMs = expectedAtMs; - const nextOptions = { ...captionOptions, ...activeOptions }; - captionStyle(nextOptions); + const authoredOptions = { ...captionOptions, ...activeOptions }; + const nextOptions = { ...runtimeCaptionOptions, ...activeOptions }; + captionStyle(authoredOptions); try { if (activeText) { if (!overlayReady) { @@ -608,6 +790,7 @@ function createDemoController({ page, captions = [], captionOptions = {} }) { return { expectedFrames: captionFrames.map((frame) => ({ atMs: frame.atMs, text: frame.text })), samples: captionSamples.map((sample) => ({ ...sample, rect: { ...sample.rect } })), + typography: typographyReport ? { ...typographyReport } : null, }; }, diff --git a/src/handoff.js b/src/handoff.js index 4ed27f6..23fc8f4 100644 --- a/src/handoff.js +++ b/src/handoff.js @@ -141,9 +141,13 @@ function trimEndMs(demoConfig, startMs) { // the beat/caption schema: at >= 0 (number), atMs >= 0 (integer). function deliverableBeats(captions, startMs) { return captions - .map((caption) => ({ atMs: caption.atMs - startMs, text: caption.text })) + .map((caption) => ({ + atMs: caption.atMs - startMs, + text: caption.text, + ...(caption.role == null ? {} : { role: caption.role }), + })) .filter((beat) => beat.atMs >= 0) - .map((beat) => ({ at: beat.atMs / 1000, atMs: beat.atMs, text: beat.text })); + .map((beat) => ({ at: beat.atMs / 1000, ...beat })); } // Coerce loosely-typed demo config into the storyboard schema's shape: preset @@ -200,7 +204,41 @@ function demoStoryboard(demoConfig, viewport) { }; } -function demoCaptions(demoConfig) { +function finiteSampleValues(samples, key) { + return samples.map((sample) => sample[key]).filter(Number.isFinite); +} + +function captionQaReport(report) { + if (!report) return undefined; + const expectedFrames = Array.isArray(report.expectedFrames) ? report.expectedFrames : []; + const samples = Array.isArray(report.samples) ? report.samples : []; + const fontSamples = samples.filter((sample) => sample.fontConfigured); + const fontLoadTimes = finiteSampleValues(fontSamples, 'fontLoadMs'); + const fontSizes = finiteSampleValues(samples, 'fontSize'); + const lineCounts = finiteSampleValues(samples, 'lineCount'); + const lineBalances = finiteSampleValues(samples, 'lineBalance'); + const typographyEnabled = !!(report.typography && report.typography.enabled); + const allFramesLoaded = samples.length + ? samples.every((sample) => sample.fontConfigured === true && sample.fontLoaded === true) + : null; + return { + scheduledFrameCount: expectedFrames.length, + measuredFrameCount: samples.length, + typography: report.typography || null, + rendering: { + fontLoaded: typographyEnabled + ? allFramesLoaded + : fontSamples.length ? fontSamples.every((sample) => sample.fontLoaded === true) : null, + maxFontLoadMs: fontLoadTimes.length ? Math.max(...fontLoadTimes) : null, + fitStatuses: [...new Set(samples.map((sample) => sample.fitStatus).filter(Boolean))], + resolvedFontSize: fontSizes.length ? { min: Math.min(...fontSizes), max: Math.max(...fontSizes) } : null, + maxLineCount: lineCounts.length ? Math.max(...lineCounts) : 0, + minLineBalance: lineBalances.length ? Math.min(...lineBalances) : null, + }, + }; +} + +function demoCaptions(demoConfig, captionReport) { const startMs = trimStartMs(demoConfig); const captions = normalizeDemoCaptions(demoConfig.captions || []); const frames = buildCaptionFrames(captions, demoConfig.captionOptions); @@ -209,6 +247,7 @@ function demoCaptions(demoConfig) { story: demoConfig.story, target: demoConfig.target, style: captionStyle(demoConfig.captionOptions || {}), + ...(captionReport ? { qa: captionQaReport(captionReport) } : {}), captions: deliverableBeats(captions, startMs), timeline: buildCaptionTimeline(frames, { startMs, endMs: trimEndMs(demoConfig, startMs) }), }; @@ -273,7 +312,18 @@ function refreshManifestHandoff(manifest, storyboard, config, approvalDocument = }; } -function buildHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewports, demoWarnings, flags, run = {} }) { +function buildHandoffDocs({ + cwd, + outDir, + config, + assets, + demoConfigs, + demoViewports, + demoWarnings, + demoCaptionReports = {}, + flags, + run = {}, +}) { const generatedAt = new Date().toISOString(); const project = readProjectInfo(cwd); const runInfo = { @@ -314,7 +364,7 @@ function buildHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewpo version: HANDOFF_VERSION, generatedAt, project, - demos: demoConfigs.map((demoConfig) => demoCaptions(demoConfig)), + demos: demoConfigs.map((demoConfig) => demoCaptions(demoConfig, demoCaptionReports[demoConfig.name])), }; const manifest = { $schema: HANDOFF_SCHEMA_IDS.manifest, @@ -378,7 +428,19 @@ function compatiblePreviousPack(previous, current) { return !previousProject || !currentProject || previousProject === currentProject; } -function writeHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewports, demoWarnings, flags, partial = false, run = {} }) { +function writeHandoffDocs({ + cwd, + outDir, + config, + assets, + demoConfigs, + demoViewports, + demoWarnings, + demoCaptionReports = {}, + flags, + partial = false, + run = {}, +}) { const storyboardPath = path.join(outDir, 'storyboard.json'); const captionsPath = path.join(outDir, 'captions.json'); const manifestPath = path.join(outDir, 'shotkit-manifest.json'); @@ -420,6 +482,7 @@ function writeHandoffDocs({ cwd, outDir, config, assets, demoConfigs, demoViewpo demoConfigs, demoViewports, demoWarnings, + demoCaptionReports, flags, run: { ...run, mode: partial ? 'partial' : 'full' }, }); diff --git a/test/caption-language.test.js b/test/caption-language.test.js new file mode 100644 index 0000000..68fdd79 --- /dev/null +++ b/test/caption-language.test.js @@ -0,0 +1,37 @@ +const { + canonicalLocale, + captionWords, + chunkCaptionSegments, + composeCaptionSegments, + segmentCaptionText, + textDirection, +} = require('../src/caption-language'); + +describe('caption language model', () => { + test('canonicalizes locales and infers script direction', () => { + expect(canonicalLocale('ko-kr')).toBe('ko-KR'); + expect(textDirection('en-US')).toBe('ltr'); + expect(textDirection('ar-SA')).toBe('rtl'); + expect(textDirection('ar-SA', 'ltr')).toBe('ltr'); + expect(() => canonicalLocale('not_a_locale')).toThrow(/invalid/); + expect(() => textDirection('en', 'sideways')).toThrow(/direction/); + }); + + test('preserves authored punctuation and separators across focus chunks', () => { + const segments = segmentCaptionText('Translate AI lessons, now!', 'en-US'); + expect(captionWords('Translate AI lessons, now!', 'en-US')).toEqual([ + 'Translate', 'AI', 'lessons,', 'now!', + ]); + expect(composeCaptionSegments(chunkCaptionSegments(segments, 0, 2))).toBe('Translate AI'); + expect(composeCaptionSegments(chunkCaptionSegments(segments, 2, 2))).toBe('lessons, now!'); + }); + + test('does not inject spaces into Japanese or Korean copy', () => { + const japanese = segmentCaptionText('AIレッスンを翻訳します。', 'ja-JP'); + const korean = segmentCaptionText('AI 강의를 바로 번역합니다.', 'ko-KR'); + expect(composeCaptionSegments(japanese)).toBe('AIレッスンを翻訳します。'); + expect(composeCaptionSegments(korean)).toBe('AI 강의를 바로 번역합니다.'); + expect(japanese.length).toBeGreaterThan(2); + expect(korean.length).toBeGreaterThan(2); + }); +}); diff --git a/test/caption-typography.test.js b/test/caption-typography.test.js new file mode 100644 index 0000000..c052455 --- /dev/null +++ b/test/caption-typography.test.js @@ -0,0 +1,115 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +jest.mock('fontkit', () => ({ create: jest.fn() })); +jest.mock('subset-font', () => jest.fn(async () => Buffer.from('subset-font'))); + +const fontkit = require('fontkit'); +const { + normalizeTypographyOptions, + prepareCaptionTypography, + typographyStyle, +} = require('../src/caption-typography'); + +describe('caption typography', () => { + afterEach(() => jest.clearAllMocks()); + + test('normalizes locale-aware fitting controls without changing legacy captions', () => { + expect(normalizeTypographyOptions({}).enabled).toBe(false); + expect(typographyStyle({})).toBeNull(); + expect(typographyStyle({ + typography: { + locale: 'ko-kr', + direction: 'auto', + family: 'Pretendard, sans-serif', + weight: 800, + minFontSize: 24, + maxFontSize: 44, + maxLines: 2, + fit: 'shrink', + }, + })).toMatchObject({ + locale: 'ko-KR', + direction: 'ltr', + family: 'Pretendard, sans-serif', + weight: '800', + minFontSize: 24, + maxFontSize: 44, + maxLines: 2, + fit: 'shrink', + }); + expect(() => normalizeTypographyOptions({ typography: { locale: 'ko', maxLines: 4 } })) + .toThrow(/maxLines/); + expect(() => normalizeTypographyOptions({ typography: { locale: 'ko', surprise: true } })) + .toThrow(/unknown field/); + expect(() => normalizeTypographyOptions({ + typography: { + locale: 'ko', + family: 'Primary Sans, sans-serif', + fonts: [{ family: 'Korean Fallback', from: 'fonts/fallback.woff2' }], + }, + })).toThrow(/family must reference configured font family/); + }); + + test('embeds project-local fonts and reports missing glyphs before capture', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-font-')); + fs.mkdirSync(path.join(cwd, 'fonts')); + fs.writeFileSync(path.join(cwd, 'fonts', 'caption.woff2'), Buffer.from('font')); + fontkit.create.mockReturnValue({ + hasGlyphForCodePoint: (codePoint) => codePoint < 128, + }); + + try { + const prepared = await prepareCaptionTypography({ + typography: { + locale: 'ko-KR', + fonts: [{ family: 'Campaign Sans', from: 'fonts/caption.woff2', weight: '100 900' }], + }, + }, cwd, ['Translate 한글']); + + expect(prepared.runtimeOptions.typography).toMatchObject({ + enabled: true, + locale: 'ko-KR', + fontFaces: [{ + family: 'Campaign Sans', + weight: '100 900', + source: expect.stringMatching(/^data:font\/woff2;base64,/), + }], + }); + expect(prepared.report).toMatchObject({ + enabled: true, + deterministic: true, + locale: 'ko-KR', + fontFiles: ['fonts/caption.woff2'], + missingGlyphs: expect.arrayContaining([ + expect.objectContaining({ character: '한', codePoint: 'U+D55C' }), + ]), + fontOptimization: [{ + family: 'Campaign Sans', + sourceBytes: 4, + embeddedBytes: 11, + }], + }); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('rejects font paths outside the project', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-font-root-')); + const outside = path.join(os.tmpdir(), `shotkit-outside-${Date.now()}.woff2`); + fs.writeFileSync(outside, 'font'); + try { + await expect(prepareCaptionTypography({ + typography: { + locale: 'en', + fonts: [{ family: 'Outside', from: outside }], + }, + }, cwd, ['Text'])).rejects.toThrow(/inside the project/); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + fs.rmSync(outside, { force: true }); + } + }); +}); diff --git a/test/demo-caption-focus.test.js b/test/demo-caption-focus.test.js index fb2ad9f..6dab31f 100644 --- a/test/demo-caption-focus.test.js +++ b/test/demo-caption-focus.test.js @@ -76,7 +76,7 @@ describe('focused demo captions', () => { const firstCaption = frames.filter((frame) => frame.sourceText === 'One two three four'); expect(firstCaption.map((frame) => frame.atMs)).toEqual([0, 125, 250, 375]); - expect(firstCaption.at(-1).text).toBe('four'); + expect(firstCaption.at(-1).text).toBe('three four'); expect(frames.find((frame) => frame.atMs === 500).text).toBe('Next beat'); expect(analyzeFocusCaptionDensity([ { atMs: 0, text: 'One two three four' }, @@ -176,4 +176,50 @@ describe('focused demo captions', () => { expect(() => normalizeFocusOptions({ mode: 'focus', appearance: 'bubble' })).toThrow(/appearance/); expect(() => normalizeFocusOptions('focus')).toThrow(/must be an object/); }); + + test('records locale typography and preserves Japanese separators in focus frames', () => { + const frames = buildCaptionFrames([ + { atMs: 0, text: 'AIレッスンを翻訳します。' }, + ], { + mode: 'focus', + wordsPerChunk: 3, + typography: { + locale: 'ja-JP', + family: 'Noto Sans JP, sans-serif', + minFontSize: 26, + maxFontSize: 42, + }, + }); + + expect(frames.map((frame) => frame.text).join('')).not.toContain('AI レッスン'); + expect(frames[0].options).toMatchObject({ locale: 'ja-JP', direction: 'ltr' }); + expect(captionStyle({ + mode: 'focus', + typography: { locale: 'ja-JP', family: 'Noto Sans JP, sans-serif' }, + })).toMatchObject({ + locale: 'ja-JP', + direction: 'ltr', + typography: { + locale: 'ja-JP', + family: 'Noto Sans JP, sans-serif', + fit: 'shrink', + maxLines: 2, + }, + }); + }); + + test('balances chunks instead of leaving a one-word final orphan', () => { + const frames = buildCaptionFrames([ + { atMs: 0, text: 'AI 강의를 바로 번역' }, + ], { + mode: 'focus', + wordsPerChunk: 3, + typography: { locale: 'ko-KR' }, + }); + + expect([...new Set(frames.map((frame) => frame.text))]).toEqual([ + 'AI 강의를', + '바로 번역', + ]); + }); }); diff --git a/test/demo-caption-qa.test.js b/test/demo-caption-qa.test.js index f359cda..24ab056 100644 --- a/test/demo-caption-qa.test.js +++ b/test/demo-caption-qa.test.js @@ -78,4 +78,39 @@ describe('runtime caption QA', () => { expect.objectContaining({ code: 'caption-protected-region-overlap' }), ])); }); + + test('turns deterministic typography failures into agent-owned fixes', () => { + const warnings = analyzeDemoCaptionMetrics({ + typography: { + enabled: true, + deterministic: true, + locale: 'ja-JP', + minLineBalance: 0.4, + missingGlyphs: [{ character: '訳', codePoint: 'U+8A33' }], + }, + expectedFrames: [{ atMs: 500, text: '翻訳します' }], + samples: [sample({ + text: '翻訳します', + sourceText: '翻訳します', + fontConfigured: false, + fontLoaded: false, + fontErrors: ['decode failed'], + fitStatus: 'overflow', + fontSize: 24, + minFontSize: 24, + maxLines: 2, + lineCount: 2, + lineWidths: [400, 100], + lineBalance: 0.25, + })], + }); + + expect(warnings).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'caption-missing-glyph' }), + expect.objectContaining({ code: 'caption-font-load-failed' }), + expect.objectContaining({ code: 'caption-typography-not-applied' }), + expect.objectContaining({ code: 'caption-type-fit-failed' }), + expect.objectContaining({ code: 'caption-unbalanced-lines' }), + ])); + }); }); diff --git a/test/demo.test.js b/test/demo.test.js index 00ee6e2..b63d283 100644 --- a/test/demo.test.js +++ b/test/demo.test.js @@ -26,6 +26,7 @@ class FakePage extends EventEmitter { this.inits = []; this.pointerMoves = []; this.pointerPulses = 0; + this.pointerInstalled = false; this.selectFocuses = []; this.selectReads = []; this.selects = []; @@ -40,7 +41,11 @@ class FakePage extends EventEmitter { } async evaluate(fn, arg) { - if (fn.name === 'demoCaptionInitScript') this.inits.push(arg); + if (fn.name === 'hasDemoPointerOverlay') return this.pointerInstalled; + if (fn.name === 'demoCaptionInitScript') { + this.inits.push(arg); + this.pointerInstalled = true; + } if (arg && Object.prototype.hasOwnProperty.call(arg, 'captionText')) { this.captions.push(arg.captionText); this.captionCalls.push({ text: arg.captionText, options: arg.captionOptions }); @@ -120,6 +125,17 @@ describe('demo time parsing', () => { { atMs: 4000, text: 'B' }, ]); }); + + test('preserves language-neutral storyboard roles', () => { + expect(normalizeDemoCaptions([ + { at: 1, text: '元の文章に戻せます', role: 'restore' }, + ])).toEqual([ + { atMs: 1000, text: '元の文章に戻せます', role: 'restore' }, + ]); + expect(() => normalizeDemoCaptions([ + { at: 1, text: 'Unknown', role: 'surprise' }, + ])).toThrow(/caption.*role/); + }); }); describe('demo delay validation', () => { @@ -246,6 +262,19 @@ describe('lintDemoStoryboard', () => { }, { viewport: { width: 1280, height: 720 }, mp4Requested: true })).toEqual([]); }); + test('accepts a non-English restore beat through its semantic role', () => { + const warnings = analyzeDemoStoryboard({ + name: 'demo-ja', + mp4: true, + trim: { duration: 25 }, + captions: [ + { at: 0.5, text: 'レッスンを翻訳します', role: 'result' }, + { at: 10, text: '元の文章に戻せます', role: 'restore' }, + ], + }, { viewport: { width: 720, height: 1280 }, mp4Requested: true }); + expect(warnings.map((warning) => warning.code)).not.toContain('missing-safety-restore'); + }); + test('asks agents to space focus-caption beats before publishing', () => { const warnings = analyzeDemoStoryboard({ name: 'demo', @@ -326,6 +355,31 @@ describe('createDemoController', () => { expect(page.captions).toEqual(['Open the course page']); }); + test('keeps authored typography separate from embedded browser font data', async () => { + const page = new FakePage(); + const captionOptions = { + mode: 'focus', + typography: { + locale: 'ko-KR', + fonts: [{ family: 'Campaign Sans', from: 'fonts/caption.woff2' }], + }, + }; + const runtimeCaptionOptions = { + ...captionOptions, + typography: { + enabled: true, + locale: 'ko-KR', + direction: 'ltr', + fontFaces: [{ family: 'Campaign Sans', source: 'data:font/woff2;base64,AA==' }], + }, + }; + const demo = createDemoController({ page, captionOptions, runtimeCaptionOptions }); + await demo.caption('한국어 자막'); + demo.stop(); + + expect(page.captionCalls[0].options.typography).toEqual(runtimeCaptionOptions.typography); + }); + test('records the browser render timestamp instead of host round-trip completion', async () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-07-10T00:00:00.000Z')); @@ -355,6 +409,7 @@ describe('createDemoController', () => { demo.stop(); expect(page.captions).toEqual(['Translate visible text']); + expect(page.inits).toHaveLength(1); expect(action).toHaveBeenCalledTimes(1); expect(page.clicks).toEqual([{ selector: '.slider', options: {} }]); expect(page.waits).toEqual([DEFAULT_STEP_HOLD_MS, DEFAULT_CLICK_HOLD_MS, 250]); diff --git a/test/handoff.test.js b/test/handoff.test.js index 6650389..e8dc6d5 100644 --- a/test/handoff.test.js +++ b/test/handoff.test.js @@ -92,6 +92,29 @@ describe('handoff contract', () => { fix: 'fix the demo', }], }, + demoCaptionReports: { + demo: { + expectedFrames: [{ atMs: 1000, text: 'Restore anytime' }], + samples: [{ + fontConfigured: true, + fontLoaded: true, + fontLoadMs: 7, + fitStatus: 'fit', + fontSize: 38, + lineCount: 1, + lineBalance: 1, + }], + typography: { + enabled: true, + deterministic: true, + locale: 'en-US', + direction: 'ltr', + fontFiles: ['fonts/NotoSans.ttf'], + fontOptimization: [{ family: 'Noto Sans', sourceBytes: 1000, embeddedBytes: 300 }], + missingGlyphs: [], + }, + }, + }, flags: { freeze: true, liveGt: false }, }); @@ -135,6 +158,19 @@ describe('handoff contract', () => { activeWordIndex: null, condensed: false, }]); + expect(docs.captions.demos[0].qa).toEqual({ + scheduledFrameCount: 1, + measuredFrameCount: 1, + typography: expect.objectContaining({ deterministic: true, locale: 'en-US' }), + rendering: { + fontLoaded: true, + maxFontLoadMs: 7, + fitStatuses: ['fit'], + resolvedFontSize: { min: 38, max: 38 }, + maxLineCount: 1, + minLineBalance: 1, + }, + }); expect(docs.storyboard.demos[0].beats[0].text).toBe('Restore anytime'); expect(docs.captions.demos[0].captions[0].atMs).toBe(1000); expect(docs.storyboard.storyboardLint).toEqual([{ @@ -195,6 +231,35 @@ describe('handoff contract', () => { expect(storyboard.integrity.digest).toMatch(/^[a-f0-9]{64}$/); }); + test('reports deterministic fonts loaded only when every measured frame kept the font setup', () => { + const { cwd, outDir } = tmpProject(); + const docs = buildHandoffDocs({ + cwd, + outDir, + config: {}, + assets: [], + demoConfigs: [{ name: 'demo', run: async () => {} }], + demoViewports: { demo: { width: 720, height: 1280 } }, + demoWarnings: { demo: [] }, + demoCaptionReports: { + demo: { + expectedFrames: [{ atMs: 0, text: 'A' }, { atMs: 200, text: 'B' }], + samples: [ + { fontConfigured: true, fontLoaded: true, fitStatus: 'fit' }, + { fontConfigured: false, fontLoaded: null, fitStatus: 'not-requested' }, + ], + typography: { enabled: true, deterministic: true, locale: 'en-US', direction: 'ltr' }, + }, + }, + flags: {}, + }); + + expect(docs.captions.demos[0].qa.rendering).toMatchObject({ + fontLoaded: false, + fitStatuses: ['fit', 'not-requested'], + }); + }); + test('partial runs prune missing files and recompute adapter readiness from preserved assets', () => { const { cwd, outDir } = tmpProject(); const mp4Path = path.join(outDir, 'demo.mp4'); From 4e4ab84885241cd6e4730d09c943ad8d9e18e802 Mon Sep 17 00:00:00 2001 From: heznpc Date: Mon, 13 Jul 2026 02:35:27 +0900 Subject: [PATCH 13/13] refactor: split capture and dashboard orchestration --- campaign/api.js | 53 +++ campaign/app.js | 651 +++++++------------------------ campaign/model.js | 102 +++++ campaign/package.json | 3 + campaign/render.js | 378 ++++++++++++++++++ eslint.config.js | 4 +- package.json | 14 +- src/calibrator-http.js | 174 +++++++++ src/calibrator-routes.js | 237 +++++++++++ src/calibrator-server.js | 642 ++---------------------------- src/calibrator-state.js | 217 +++++++++++ src/capture-demo.js | 170 ++++++++ src/capture-lifecycle.js | 9 + src/capture-plan.js | 92 +++++ src/capture-static.js | 96 +++++ src/capture.js | 329 +++------------- src/review-request.js | 91 +++++ test/calibrator-server.test.js | 8 +- test/campaign-ui.node.mjs | 148 +++++++ test/capture-description.test.js | 24 ++ test/capture-plan.test.js | 76 ++++ test/capture-static.test.js | 59 +++ test/capture-success.test.js | 102 +++++ test/review-request.test.js | 108 +++++ 24 files changed, 2374 insertions(+), 1413 deletions(-) create mode 100644 campaign/api.js create mode 100644 campaign/model.js create mode 100644 campaign/package.json create mode 100644 campaign/render.js create mode 100644 src/calibrator-http.js create mode 100644 src/calibrator-routes.js create mode 100644 src/calibrator-state.js create mode 100644 src/capture-demo.js create mode 100644 src/capture-lifecycle.js create mode 100644 src/capture-plan.js create mode 100644 src/capture-static.js create mode 100644 src/review-request.js create mode 100644 test/campaign-ui.node.mjs create mode 100644 test/capture-plan.test.js create mode 100644 test/capture-static.test.js create mode 100644 test/capture-success.test.js create mode 100644 test/review-request.test.js 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 index d62bb0a..68ba41d 100644 --- a/campaign/app.js +++ b/campaign/app.js @@ -1,523 +1,140 @@ -(() => { - 'use strict'; - - const $ = (id) => document.getElementById(id); - const elements = { - 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'), - }; - - const state = { - document: null, - recipeId: null, - targets: new Set(), - activeTargetId: null, - view: 'plan', - busy: false, - pollTimer: null, - }; - - function showNotice(title, message) { - elements.noticeTitle.textContent = title; - elements.noticeMessage.textContent = message; - elements.notice.hidden = false; - } - - function setConnection(label, tone = 'ready') { - elements.connectionState.className = `connection is-${tone}`; - elements.connectionState.querySelector('span').textContent = label; - } - - function setBusy(busy, label) { - state.busy = busy; - elements.startButton.disabled = busy || !state.recipeId || state.targets.size === 0; - elements.requestChangesButton.disabled = busy || !activeTarget() || !activeTarget().reviewable; - elements.approveButton.disabled = busy || !selectedTargets().length - || !selectedTargets().every((target) => target.reviewable); - if (label) setConnection(label, busy ? 'busy' : 'ready'); - } - - 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; - } - - function selectedRecipe() { - return state.document && state.document.recipes.find((recipe) => recipe.id === state.recipeId); - } - - function selectedTargets() { - const recipe = selectedRecipe(); - return recipe ? recipe.targets.filter((target) => state.targets.has(target.id)) : []; - } - - function activeTarget() { - const targets = selectedTargets(); - return targets.find((target) => target.id === state.activeTargetId) || targets[0] || null; - } - - function setView(view) { - if (!['plan', 'production', 'review'].includes(view)) return; - state.view = view; - for (const tab of elements.tabs) { - tab.setAttribute('aria-selected', tab.dataset.view === view ? 'true' : 'false'); +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); } - for (const panel of elements.views) panel.hidden = panel.dataset.view !== view; - if (view === 'review') renderReview(); + 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 formatViewport(viewport) { - return viewport ? `${viewport.width} x ${viewport.height}` : 'Configured output'; - } - - 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'; - } - - 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', () => selectRecipe(recipe.id)); - return button; - } - - function selectRecipe(recipeId) { - const recipe = state.document.recipes.find((item) => item.id === recipeId); - if (!recipe || state.busy) return; - state.recipeId = recipe.id; - state.targets = new Set(recipe.targets.map((target) => target.id)); - state.activeTargetId = recipe.targets[0] ? recipe.targets[0].id : null; - renderPlan(); - renderNavigation(); - } - - 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(); - 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 productionStatus(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; - } - - function progressRow(target) { - const status = productionStatus(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(); - if (!recipe) return; - const targets = selectedTargets(); - 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(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 button = document.createElement('button'); - button.type = 'button'; - button.className = 'target-tab'; - button.setAttribute('role', 'tab'); - button.setAttribute('aria-selected', target.id === activeTarget().id ? 'true' : 'false'); - button.dataset.status = target.review.status; - button.textContent = target.id; - button.addEventListener('click', () => { - state.activeTargetId = target.id; - renderReview(); - }); - 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(); - const targets = selectedTargets(); - if (!recipe || !targets.length) return; - if (!targets.some((target) => target.id === state.activeTargetId)) state.activeTargetId = targets[0].id; - const target = activeTarget(); - 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(); - const persisted = !!(state.document.selection && state.document.selection.persisted); - const targets = selectedTargets(); - 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 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 initializeSelection() { - const selected = state.document.selection; - const recipe = state.document.recipes.find((item) => item.id === (selected && selected.recipeId)) - || state.document.recipes[0]; - if (!recipe) return; - 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; - } - - async function loadState({ followPhase = true } = {}) { - try { - state.document = await api('/api/campaign'); - elements.projectName.textContent = state.document.project || 'Project'; - elements.advancedLink.hidden = !state.document.calibratorAvailable; - initializeSelection(); - if (followPhase) state.view = state.document.phase === 'complete' ? 'review' : state.document.phase; - render(); - setConnection(state.document.run.status === 'running' ? 'Producing' : 'Local', state.document.run.status === 'running' ? 'busy' : 'ready'); - } catch (error) { - setConnection('Offline', 'error'); - showNotice('Could not load campaign', error.message); - } finally { - elements.app.setAttribute('aria-busy', 'false'); - schedulePoll(); - } - } - - function schedulePoll() { - if (state.pollTimer) window.clearTimeout(state.pollTimer); - state.pollTimer = null; - if (!state.document) return; - const shouldPoll = state.document.run.status === 'running' - || (state.view === 'production' && state.document.phase === 'production'); - if (!shouldPoll) return; - state.pollTimer = window.setTimeout(() => loadState({ followPhase: true }), 1500); - } - - async function startProduction() { - const recipe = selectedRecipe(); - if (!recipe || state.busy) return; - setBusy(true, 'Starting'); - try { - await api('/api/campaign/run', { - method: 'POST', - body: JSON.stringify({ recipeId: recipe.id }), - }); - state.view = 'production'; - await loadState({ followPhase: true }); - } catch (error) { - showNotice('Production did not start', error.message); - } finally { - setBusy(false, 'Local'); - renderNavigation(); - } - } - - async function review(status, targets) { - const recipe = selectedRecipe(); - if (!recipe || state.busy) return; - const note = elements.reviewNote.value.trim(); - if (status === 'changes-requested' && !note) { - elements.reviewNote.focus(); - showNotice('Feedback required', 'Describe what the agent should change.'); - return; - } - setBusy(true, status === 'approved' ? 'Approving' : 'Submitting'); - try { - const candidates = targets.map((targetId) => selectedTargets().find((target) => target.id === targetId)); - const response = await api('/api/campaign/review', { - method: 'POST', - body: JSON.stringify({ - recipeId: recipe.id, - candidates: candidates.map((target) => ({ - target: target.id, - assetDigest: target.assetDigest, - ...(target.profileHash ? { profileHash: target.profileHash } : {}), - })), - status, - ...(status === 'changes-requested' ? { note } : {}), - }), - }); - state.document = response.campaign; - if (status === 'changes-requested') state.view = 'production'; - render(); - schedulePoll(); - } catch (error) { - showNotice('Review was not saved', error.message); - } finally { - setBusy(false, 'Local'); - if (state.view === 'review') renderReview(); - } +} + +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(); - if (target) review('changes-requested', [target.id]); +for (const tab of elements.tabs) { + tab.addEventListener('click', () => { + if (!tab.disabled) setView(tab.dataset.view); }); - elements.approveButton.addEventListener('click', () => review('approved', [...state.targets])); - elements.noticeClose.addEventListener('click', () => { elements.notice.hidden = true; }); - - loadState(); -})(); +} +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/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/eslint.config.js b/eslint.config.js index 07e3bbb..3748766 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,7 +17,7 @@ module.exports = [ }, }, { - files: ['test/**/*.js'], + files: ['test/**/*.{js,mjs}'], languageOptions: { globals: { ...globals.jest } }, }, { @@ -25,7 +25,7 @@ module.exports = [ languageOptions: { sourceType: 'module' }, }, { - files: ['calibrator/**/*.js', 'campaign/**/*.js'], + files: ['calibrator/**/*.js', 'campaign/**/*.js', 'test/**/*.mjs'], languageOptions: { sourceType: 'module' }, }, { diff --git a/package.json b/package.json index 762a8fa..191904b 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "scripts": { "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" }, @@ -73,6 +74,9 @@ "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", @@ -83,6 +87,11 @@ "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", @@ -97,7 +106,8 @@ "src/handoff-validator.js", "src/image-qa.js", "src/integrations.js", - "src/publish.js" + "src/publish.js", + "src/review-request.js" ], "coverageReporters": [ "text", diff --git a/src/calibrator-http.js b/src/calibrator-http.js new file mode 100644 index 0000000..6d2c0ee --- /dev/null +++ b/src/calibrator-http.js @@ -0,0 +1,174 @@ +const fs = require('fs'); +const path = require('path'); + +const STATIC_DIR = path.join(__dirname, '..', 'calibrator'); +const CAMPAIGN_STATIC_DIR = path.join(__dirname, '..', 'campaign'); +const MAX_BODY_BYTES = 256 * 1024; +const CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"; + +class HttpError extends Error { + constructor(status, message) { + super(message); + this.name = 'HttpError'; + this.status = status; + } +} + +function securityHeaders() { + return { + 'Content-Security-Policy': CONTENT_SECURITY_POLICY, + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'no-referrer', + }; +} + +function json(res, status, payload) { + const body = Buffer.from(`${JSON.stringify(payload)}\n`); + res.writeHead(status, { + ...securityHeaders(), + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': body.length, + 'Cache-Control': 'no-store', + }); + res.end(body); +} + +function contentType(filePath) { + switch (path.extname(filePath).toLowerCase()) { + case '.html': return 'text/html; charset=utf-8'; + case '.css': return 'text/css; charset=utf-8'; + case '.js': return 'text/javascript; charset=utf-8'; + case '.png': return 'image/png'; + case '.jpg': + case '.jpeg': return 'image/jpeg'; + case '.mp4': return 'video/mp4'; + case '.webm': return 'video/webm'; + default: return 'application/octet-stream'; + } +} + +function safeStaticPathIn(staticDir, urlPath) { + let decoded; + try { + decoded = decodeURIComponent(urlPath); + } catch (_error) { + return null; + } + if (decoded.includes('\0')) return null; + const relative = urlPath === '/' ? 'index.html' : decoded.replace(/^\/+/, ''); + const normalized = path.normalize(relative).replace(/^(\.\.(\/|\\|$))+/, ''); + const resolved = path.resolve(staticDir, normalized); + return resolved.startsWith(`${path.resolve(staticDir)}${path.sep}`) ? resolved : null; +} + +function safeStaticPath(urlPath) { + return safeStaticPathIn(STATIC_DIR, urlPath); +} + +function safeCampaignStaticPath(urlPath) { + const relative = urlPath === '/campaign/' + ? '/' + : urlPath.slice('/campaign'.length) || '/'; + return safeStaticPathIn(CAMPAIGN_STATIC_DIR, relative); +} + +function serveFile(req, res, filePath) { + if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + res.writeHead(404, securityHeaders()).end('Not found'); + return; + } + const size = fs.statSync(filePath).size; + const range = req.headers.range && /^bytes=(\d+)-(\d*)$/.exec(req.headers.range); + if (range) { + const start = Number(range[1]); + const end = range[2] ? Math.min(Number(range[2]), size - 1) : size - 1; + if (!Number.isInteger(start) || start < 0 || start > end || start >= size) { + res.writeHead(416, { ...securityHeaders(), 'Content-Range': `bytes */${size}` }).end(); + return; + } + res.writeHead(206, { + ...securityHeaders(), + 'Content-Type': contentType(filePath), + 'Content-Length': end - start + 1, + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-store', + }); + fs.createReadStream(filePath, { start, end }).pipe(res); + return; + } + res.writeHead(200, { + ...securityHeaders(), + 'Content-Type': contentType(filePath), + 'Content-Length': size, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-store', + }); + fs.createReadStream(filePath).pipe(res); +} + +async function requestBody(req) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX_BODY_BYTES) throw new HttpError(413, 'request body is too large'); + chunks.push(chunk); + } + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch (_error) { + throw new HttpError(400, 'request body must be valid JSON'); + } +} + +function isLoopbackHost(value) { + return typeof value === 'string' && /^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(value); +} + +function validateRequestHost(req) { + if (!isLoopbackHost(req.headers.host)) throw new HttpError(403, 'request host must be local'); +} + +function validateWriteRequest(req) { + const header = String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase(); + if (header !== 'application/json') { + throw new HttpError(415, 'write requests require application/json'); + } + if (!req.headers.origin) return; + let origin; + try { + origin = new URL(req.headers.origin); + } catch (_error) { + throw new HttpError(403, 'request origin must match the local dashboard'); + } + if (origin.protocol !== 'http:' || origin.host.toLowerCase() !== String(req.headers.host).toLowerCase()) { + throw new HttpError(403, 'request origin must match the local dashboard'); + } +} + +function safeMediaPath(outDir, name) { + const candidate = path.join(outDir, name); + try { + const root = fs.realpathSync(outDir); + const resolved = fs.realpathSync(candidate); + return resolved.startsWith(`${root}${path.sep}`) ? resolved : null; + } catch (_error) { + return null; + } +} + +module.exports = { + HttpError, + json, + requestBody, + safeCampaignStaticPath, + safeMediaPath, + safeStaticPath, + securityHeaders, + serveFile, + validateRequestHost, + validateWriteRequest, +}; diff --git a/src/calibrator-routes.js b/src/calibrator-routes.js new file mode 100644 index 0000000..14a6109 --- /dev/null +++ b/src/calibrator-routes.js @@ -0,0 +1,237 @@ +const path = require('path'); + +const { + loadApproval, + updateApprovalDecision, + updateApprovalDecisions, +} = require('./approval'); +const { calibrationProfileHash, updateCalibrationProfile } = require('./calibration'); +const { + captureProfileSnapshot, + updateProfileVerification, +} = require('./calibration-verification'); +const { + HttpError, + json, + requestBody, + safeCampaignStaticPath, + safeMediaPath, + safeStaticPath, + securityHeaders, + serveFile, + validateRequestHost, + validateWriteRequest, +} = require('./calibrator-http'); +const { saveCampaignSelection } = require('./campaign'); +const { nextAttempt } = require('./campaign-dashboard'); +const { normalizeDemoConfigs } = require('./demo'); +const { validateCampaignReview, validateSingleReview } = require('./review-request'); + +function configuredTarget(config, story, target) { + return normalizeDemoConfigs(config).find((demo) => ( + demo.target === target && (demo.story === story || demo.name === story) + )); +} + +async function recaptureTarget({ cwd, config, configPath, outDir, story, target, runner }) { + const snapshot = captureProfileSnapshot(config, cwd, story, target); + const result = await runner({ + cwd, + configPath, + story, + target, + noBuild: true, + attempt: nextAttempt(outDir), + }); + updateProfileVerification(config, cwd, story, target, result.machineStatus, snapshot); + return result; +} + +function createCalibratorRequestHandler({ + cwd, + config, + configPath, + outDir, + calibrationEnabled, + state, + campaignState, + campaignRuns, + recipes, + captureTarget, + isRecapturing, + setRecapturing, + calibrationDocument, + syncApprovalManifest, + profileFor, +}) { + return async function handleCalibratorRequest(req, res) { + try { + validateRequestHost(req); + if (req.method === 'POST') validateWriteRequest(req); + const url = new URL(req.url || '/', 'http://127.0.0.1'); + + if (req.method === 'GET' && url.pathname === '/api/state') { + json(res, 200, state({ + story: url.searchParams.get('story'), + target: url.searchParams.get('target'), + })); + return; + } + if (req.method === 'GET' && url.pathname === '/api/campaign') { + json(res, 200, campaignState()); + return; + } + if (req.method === 'POST' && url.pathname === '/api/campaign/select') { + if (isRecapturing()) { + json(res, 409, { ok: false, error: 'campaign selection cannot change while capture is running' }); + return; + } + const body = await requestBody(req); + try { + const selection = saveCampaignSelection(outDir, recipes, body); + campaignRuns.reset(); + json(res, 200, { ok: true, selection, campaign: campaignState() }); + } catch (error) { + json(res, 400, { ok: false, error: error.message }); + } + return; + } + if (req.method === 'POST' && url.pathname === '/api/campaign/run') { + if (isRecapturing()) { + json(res, 409, { ok: false, error: 'a capture is already running' }); + return; + } + const body = await requestBody(req); + try { + const selection = saveCampaignSelection(outDir, recipes, body); + const run = campaignRuns.start(selection); + json(res, 202, { ok: true, run }); + } catch (error) { + json(res, 400, { ok: false, error: error.message }); + } + return; + } + if (req.method === 'POST' && url.pathname === '/api/campaign/review') { + if (isRecapturing()) { + json(res, 409, { ok: false, error: 'review is unavailable while capture is running' }); + return; + } + const body = await requestBody(req); + const review = validateCampaignReview({ body, recipes, current: state() }); + const approval = updateApprovalDecisions(outDir, review.decisions); + syncApprovalManifest(outDir, approval.document, calibrationDocument()); + json(res, 200, { ok: true, campaign: campaignState() }); + return; + } + if (req.method === 'POST' && url.pathname === '/api/profile') { + if (!calibrationEnabled) { + json(res, 409, { ok: false, error: 'calibration is not configured for this project' }); + return; + } + if (isRecapturing()) { + json(res, 409, { ok: false, error: 'profile changes are unavailable while capture is running' }); + return; + } + const body = await requestBody(req); + if (!configuredTarget(config, body.story, body.target)) { + json(res, 404, { ok: false, error: 'configured story/target was not found' }); + return; + } + const updated = updateCalibrationProfile(config, cwd, body.story, body.target, body.profile); + syncApprovalManifest(outDir, loadApproval(outDir).document, updated.document); + json(res, 200, { + ok: true, + profile: profileFor(updated.document, body.story, body.target), + profileHash: calibrationProfileHash(body.profile), + }); + return; + } + if (req.method === 'POST' && url.pathname === '/api/recapture') { + if (isRecapturing()) { + json(res, 409, { ok: false, error: 'a recapture is already running' }); + return; + } + const body = await requestBody(req); + if (!configuredTarget(config, body.story, body.target)) { + json(res, 404, { ok: false, error: 'configured story/target was not found' }); + return; + } + setRecapturing(true); + try { + const result = await recaptureTarget({ + cwd, + config, + configPath, + outDir, + story: body.story, + target: body.target, + runner: captureTarget, + }); + json(res, 200, result); + } finally { + setRecapturing(false); + } + return; + } + if (req.method === 'POST' && url.pathname === '/api/review') { + if (isRecapturing()) { + json(res, 409, { ok: false, error: 'review is unavailable while capture is running' }); + return; + } + const body = await requestBody(req); + const current = state({ story: body.story, target: body.target }); + const { decision } = validateSingleReview({ body, current }); + const updated = updateApprovalDecision(outDir, body.story, body.target, decision); + syncApprovalManifest(outDir, updated.document, calibrationDocument()); + const refreshed = state({ story: body.story, target: body.target }); + json(res, 200, { + ok: true, + review: refreshed.targets.find((item) => ( + item.story === body.story && item.target === body.target + )).review, + approvalStatus: refreshed.approvalStatus, + }); + return; + } + if (req.method === 'GET' && url.pathname.startsWith('/media/')) { + let name; + try { + name = decodeURIComponent(url.pathname.slice('/media/'.length)); + } catch (_error) { + throw new HttpError(400, 'invalid media path encoding'); + } + if (!name || path.basename(name) !== name || !/\.(mp4|webm|png|jpe?g)$/i.test(name)) { + res.writeHead(400, securityHeaders()).end('Invalid media path'); + return; + } + const mediaPath = safeMediaPath(outDir, name); + if (!mediaPath) { + res.writeHead(404, securityHeaders()).end('Not found'); + return; + } + serveFile(req, res, mediaPath); + return; + } + if (req.method === 'GET' && url.pathname === '/campaign') { + res.writeHead(302, { ...securityHeaders(), Location: '/campaign/' }).end(); + return; + } + if (req.method === 'GET' && url.pathname.startsWith('/campaign/')) { + serveFile(req, res, safeCampaignStaticPath(url.pathname)); + return; + } + if (req.method === 'GET') { + serveFile(req, res, safeStaticPath(url.pathname)); + return; + } + res.writeHead(405, { ...securityHeaders(), Allow: 'GET, POST' }).end('Method not allowed'); + } catch (error) { + json(res, Number.isInteger(error.status) ? error.status : 500, { + ok: false, + error: error.message, + }); + } + }; +} + +module.exports = { createCalibratorRequestHandler }; diff --git a/src/calibrator-server.js b/src/calibrator-server.js index 82c6635..f1522f5 100644 --- a/src/calibrator-server.js +++ b/src/calibrator-server.js @@ -1,388 +1,26 @@ -const fs = require('fs'); const http = require('http'); const path = require('path'); const { execFile, spawn } = require('child_process'); -const { - applyCalibrationProfiles, - calibrationProfileHash, - loadCalibration, - updateCalibrationProfile, -} = require('./calibration'); -const { - loadApproval, - syncManifestApproval, - updateApprovalDecision, - updateApprovalDecisions, -} = require('./approval'); +const { loadCalibration } = require('./calibration'); +const { loadApproval } = require('./approval'); const { resolveCampaignRecipes, - saveCampaignSelection, } = require('./campaign'); const { createCampaignRunController, createCampaignStateReader, - nextAttempt, } = require('./campaign-dashboard'); const { - captureProfileSnapshot, hasCalibration, - updateProfileVerification, } = require('./calibration-verification'); -const { normalizeDemoConfigs } = require('./demo'); -const { writeJson } = require('./handoff-files'); - -const STATIC_DIR = path.join(__dirname, '..', 'calibrator'); -const CAMPAIGN_STATIC_DIR = path.join(__dirname, '..', 'campaign'); -const MAX_BODY_BYTES = 256 * 1024; -const CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"; - -class HttpError extends Error { - constructor(status, message) { - super(message); - this.status = status; - } -} - -function securityHeaders() { - return { - 'Content-Security-Policy': CONTENT_SECURITY_POLICY, - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - 'Referrer-Policy': 'no-referrer', - }; -} - -function readJson(filePath, fallback = null) { - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch (_error) { - return fallback; - } -} - -function json(res, status, payload) { - const body = Buffer.from(`${JSON.stringify(payload)}\n`); - res.writeHead(status, { - ...securityHeaders(), - 'Content-Type': 'application/json; charset=utf-8', - 'Content-Length': body.length, - 'Cache-Control': 'no-store', - }); - res.end(body); -} - -function contentType(filePath) { - switch (path.extname(filePath).toLowerCase()) { - case '.html': return 'text/html; charset=utf-8'; - case '.css': return 'text/css; charset=utf-8'; - case '.js': return 'text/javascript; charset=utf-8'; - case '.png': return 'image/png'; - case '.jpg': - case '.jpeg': return 'image/jpeg'; - case '.mp4': return 'video/mp4'; - case '.webm': return 'video/webm'; - default: return 'application/octet-stream'; - } -} - -function safeStaticPathIn(staticDir, urlPath) { - let decoded; - try { - decoded = decodeURIComponent(urlPath); - } catch (_error) { - return null; - } - if (decoded.includes('\0')) return null; - const relative = urlPath === '/' ? 'index.html' : decoded.replace(/^\/+/, ''); - const normalized = path.normalize(relative).replace(/^(\.\.(\/|\\|$))+/, ''); - const resolved = path.resolve(staticDir, normalized); - return resolved.startsWith(`${path.resolve(staticDir)}${path.sep}`) ? resolved : null; -} - -function safeStaticPath(urlPath) { - return safeStaticPathIn(STATIC_DIR, urlPath); -} - -function safeCampaignStaticPath(urlPath) { - const relative = urlPath === '/campaign/' - ? '/' - : urlPath.slice('/campaign'.length) || '/'; - return safeStaticPathIn(CAMPAIGN_STATIC_DIR, relative); -} - -function serveFile(req, res, filePath) { - if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { - res.writeHead(404, securityHeaders()).end('Not found'); - return; - } - const size = fs.statSync(filePath).size; - const range = req.headers.range && /^bytes=(\d+)-(\d*)$/.exec(req.headers.range); - if (range) { - const start = Number(range[1]); - const end = range[2] ? Math.min(Number(range[2]), size - 1) : size - 1; - if (!Number.isInteger(start) || start < 0 || start > end || start >= size) { - res.writeHead(416, { ...securityHeaders(), 'Content-Range': `bytes */${size}` }).end(); - return; - } - res.writeHead(206, { - ...securityHeaders(), - 'Content-Type': contentType(filePath), - 'Content-Length': end - start + 1, - 'Content-Range': `bytes ${start}-${end}/${size}`, - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'no-store', - }); - fs.createReadStream(filePath, { start, end }).pipe(res); - return; - } - res.writeHead(200, { - ...securityHeaders(), - 'Content-Type': contentType(filePath), - 'Content-Length': size, - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'no-store', - }); - fs.createReadStream(filePath).pipe(res); -} - -async function requestBody(req) { - const chunks = []; - let size = 0; - for await (const chunk of req) { - size += chunk.length; - if (size > MAX_BODY_BYTES) throw new HttpError(413, 'request body is too large'); - chunks.push(chunk); - } - if (!chunks.length) return {}; - try { - return JSON.parse(Buffer.concat(chunks).toString('utf8')); - } catch (_error) { - throw new HttpError(400, 'request body must be valid JSON'); - } -} - -function isLoopbackHost(value) { - return typeof value === 'string' && /^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(value); -} - -function validateRequestHost(req) { - if (!isLoopbackHost(req.headers.host)) throw new HttpError(403, 'request host must be local'); -} - -function validateWriteRequest(req) { - const contentTypeHeader = String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase(); - if (contentTypeHeader !== 'application/json') { - throw new HttpError(415, 'write requests require application/json'); - } - if (!req.headers.origin) return; - let origin; - try { - origin = new URL(req.headers.origin); - } catch (_error) { - throw new HttpError(403, 'request origin must match the local dashboard'); - } - if (origin.protocol !== 'http:' || origin.host.toLowerCase() !== String(req.headers.host).toLowerCase()) { - throw new HttpError(403, 'request origin must match the local dashboard'); - } -} - -function safeMediaPath(outDir, name) { - const candidate = path.join(outDir, name); - try { - const root = fs.realpathSync(outDir); - const resolved = fs.realpathSync(candidate); - return resolved.startsWith(`${root}${path.sep}`) ? resolved : null; - } catch (_error) { - return null; - } -} - -function safeArea(target, viewport) { - if (target === 'youtube-shorts') { - return { x: 40, y: 96, width: Math.max(0, viewport.width - 160), height: Math.max(0, viewport.height - 416) }; - } - return { x: 48, y: 40, width: Math.max(0, viewport.width - 96), height: Math.max(0, viewport.height - 88) }; -} - -function assetFor(assets, demoName, role) { - return assets.find((asset) => asset.role === role && asset.source && asset.source.name === demoName); -} - -function profileFor(document, story, target) { - return document.profiles && document.profiles[story] && document.profiles[story][target] - ? document.profiles[story][target] - : {}; -} - -function applyCalibrationHashes(manifest, calibrationDocument) { - const targets = manifest.handoff && manifest.handoff.automation - ? manifest.handoff.automation.targets || [] - : []; - for (const target of targets) { - const profile = profileFor(calibrationDocument, target.story, target.target); - if (!target.profileHash && profile.verification) { - const profileHash = calibrationProfileHash(profile); - if (profile.verification.status === 'publish-ready' && profile.verification.profileHash === profileHash) { - target.profileHash = profileHash; - } - } - } -} - -function calibrationApprovalOptions(calibrationDocument) { - return { - targetContext(target) { - const profile = profileFor(calibrationDocument, target.story, target.target); - const saved = hasProfile(calibrationDocument, target.story, target.target); - const profileHash = saved ? calibrationProfileHash(profile) : null; - const verified = !!(saved && profile.verification - && profile.verification.status === 'publish-ready' - && profile.verification.profileHash === profileHash - && (!target.profileHash || target.profileHash === profileHash)); - return { ready: verified, profileHash }; - }, - }; -} - -function syncApprovalManifest(outDir, approvalDocument, calibrationDocument) { - const manifestPath = path.join(outDir, 'shotkit-manifest.json'); - const manifest = readJson(manifestPath); - if (!manifest || !manifest.handoff || !manifest.handoff.automation) return null; - if (calibrationDocument) applyCalibrationHashes(manifest, calibrationDocument); - const gate = syncManifestApproval( - manifest, - approvalDocument, - calibrationDocument ? calibrationApprovalOptions(calibrationDocument) : {}, - ); - writeJson(manifestPath, manifest); - return gate; -} - -function hasProfile(document, story, target) { - return !!(document.profiles && document.profiles[story] - && Object.prototype.hasOwnProperty.call(document.profiles[story], target)); -} - -function createStateReader({ cwd, config }) { - const outDir = path.resolve(cwd, config.outDir || 'store-assets'); - const calibrationEnabled = hasCalibration(config); - return function state(selected = {}) { - const calibration = loadCalibration(config, cwd); - const approval = loadApproval(outDir); - const demos = applyCalibrationProfiles(normalizeDemoConfigs(config), calibration.document) - .filter((demo) => demo.target); - const manifest = readJson(path.join(outDir, 'shotkit-manifest.json'), {}); - if (calibrationEnabled) applyCalibrationHashes(manifest, calibration.document); - const storyboard = readJson(path.join(outDir, 'storyboard.json'), {}); - const captions = readJson(path.join(outDir, 'captions.json'), {}); - const assets = manifest.assets || []; - const automationTargets = manifest.handoff && manifest.handoff.automation - ? manifest.handoff.automation.targets || [] - : []; - const approvalGate = syncManifestApproval( - manifest, - approval.document, - calibrationEnabled ? calibrationApprovalOptions(calibration.document) : {}, - ); - const approvalByKey = new Map((approvalGate.targets || []).map((item) => [`${item.story}::${item.target}`, item])); - const storyboardByName = new Map((storyboard.demos || []).map((demo) => [demo.name, demo])); - const captionByName = new Map((captions.demos || []).map((demo) => [demo.name, demo])); - const lintByName = new Map((storyboard.storyboardLint || []).map((item) => [item.name, item.warnings || []])); - const layouts = config.calibration && Array.isArray(config.calibration.layouts) - ? config.calibration.layouts - : ['default']; - const targets = demos.map((demo) => { - const story = demo.story || demo.name; - const board = storyboardByName.get(demo.name) || {}; - const caption = captionByName.get(demo.name) || {}; - const viewport = board.viewport || (demo.targetProfile && demo.targetProfile.viewport) || { width: 1280, height: 720 }; - const mp4 = assetFor(assets, demo.name, 'sns-demo-mp4'); - const thumbnail = assetFor(assets, demo.name, 'thumbnail'); - const directVideo = path.join(outDir, `${demo.name}.mp4`); - const directThumbnail = path.join(outDir, `${demo.name}-thumbnail.png`); - const publish = automationTargets.find((item) => item.demo === demo.name && item.target === demo.target); - const approvalTarget = approvalByKey.get(`${story}::${demo.target}`) || { - status: 'not-ready', - assetDigest: null, - stale: false, - }; - const profile = profileFor(calibration.document, story, demo.target); - const savedProfile = hasProfile(calibration.document, story, demo.target); - const profileHash = calibrationEnabled - ? savedProfile ? calibrationProfileHash(profile) : null - : approvalTarget.profileHash || (publish && publish.profileHash) || null; - const verified = !calibrationEnabled || !!(savedProfile && profile.verification - && publish && publish.status === 'publish-ready' - && profile.verification.status === 'publish-ready' - && profile.verification.profileHash === profileHash); - const publishStatus = publish ? publish.status : 'not-requested'; - const reviewable = publishStatus === 'publish-ready' && verified && !!approvalTarget.assetDigest; - const reviewStatus = reviewable ? approvalTarget.status : 'not-ready'; - const status = publishStatus === 'publish-ready' - ? reviewable ? reviewStatus : 'needs-fix' - : publishStatus; - const videoName = mp4 && mp4.outPath ? path.basename(mp4.outPath) : path.basename(directVideo); - const thumbnailName = thumbnail && thumbnail.outPath ? path.basename(thumbnail.outPath) : path.basename(directThumbnail); - return { - story, - target: demo.target, - name: demo.name, - status, - machineStatus: publishStatus, - viewport, - safeArea: safeArea(demo.target, viewport), - videoUrl: fs.existsSync(path.join(outDir, videoName)) ? `/media/${encodeURIComponent(videoName)}` : null, - thumbnailUrl: fs.existsSync(path.join(outDir, thumbnailName)) ? `/media/${encodeURIComponent(thumbnailName)}` : null, - beats: board.beats || caption.captions || [], - captionStyle: caption.style || board.captionStyle || {}, - warnings: lintByName.get(demo.name) || [], - layouts, - profile, - hasProfile: savedProfile, - profileHash, - verified, - reviewable, - publishable: reviewable && reviewStatus === 'approved', - review: { - status: reviewStatus, - stale: !!approvalTarget.stale, - ...(approvalTarget.decision ? { decision: approvalTarget.decision } : {}), - }, - assetDigest: approvalTarget.assetDigest, - }; - }); - const active = targets.find((item) => item.story === selected.story && item.target === selected.target) || targets[0] || null; - return { - project: path.basename(cwd), - calibratorAvailable: calibrationEnabled, - calibrationPath: calibration.path ? path.relative(cwd, calibration.path) : null, - targets, - selected: active ? { story: active.story, target: active.target } : null, - approvalStatus: approvalGate.status, - }; - }; -} - -function configuredTarget(config, story, target) { - return normalizeDemoConfigs(config).find((demo) => ( - demo.target === target && (demo.story === story || demo.name === story) - )); -} - -async function recaptureTarget({ cwd, config, configPath, outDir, story, target, runner }) { - const snapshot = captureProfileSnapshot(config, cwd, story, target); - const result = await runner({ - cwd, - configPath, - story, - target, - noBuild: true, - attempt: nextAttempt(outDir), - }); - updateProfileVerification(config, cwd, story, target, result.machineStatus, snapshot); - return result; -} +const { safeCampaignStaticPath, safeStaticPath } = require('./calibrator-http'); +const { createCalibratorRequestHandler } = require('./calibrator-routes'); +const { + createStateReader, + profileFor, + syncApprovalManifest, +} = require('./calibrator-state'); function recaptureCliArgs({ cwd, configPath, story, target, targets, attempt, noBuild = false }) { const cliPath = path.join(__dirname, '..', 'bin', 'shotkit.js'); @@ -470,252 +108,24 @@ async function startCalibrator({ }); syncApprovalManifest(outDir, loadApproval(outDir).document, calibrationDocument()); - const server = http.createServer(async (req, res) => { - try { - validateRequestHost(req); - if (req.method === 'POST') validateWriteRequest(req); - const url = new URL(req.url || '/', 'http://127.0.0.1'); - if (req.method === 'GET' && url.pathname === '/api/state') { - json(res, 200, state({ story: url.searchParams.get('story'), target: url.searchParams.get('target') })); - return; - } - if (req.method === 'GET' && url.pathname === '/api/campaign') { - json(res, 200, campaignState()); - return; - } - if (req.method === 'POST' && url.pathname === '/api/campaign/select') { - if (recapturing) { - json(res, 409, { ok: false, error: 'campaign selection cannot change while capture is running' }); - return; - } - const body = await requestBody(req); - try { - const selection = saveCampaignSelection(outDir, recipes, body); - campaignRuns.reset(); - json(res, 200, { ok: true, selection, campaign: campaignState() }); - } catch (error) { - json(res, 400, { ok: false, error: error.message }); - } - return; - } - if (req.method === 'POST' && url.pathname === '/api/campaign/run') { - if (recapturing) { - json(res, 409, { ok: false, error: 'a capture is already running' }); - return; - } - const body = await requestBody(req); - try { - const selection = saveCampaignSelection(outDir, recipes, body); - const run = campaignRuns.start(selection); - json(res, 202, { ok: true, run }); - } catch (error) { - json(res, 400, { ok: false, error: error.message }); - } - return; - } - if (req.method === 'POST' && url.pathname === '/api/campaign/review') { - if (recapturing) { - json(res, 409, { ok: false, error: 'review is unavailable while capture is running' }); - return; - } - const body = await requestBody(req); - if (!['approved', 'changes-requested'].includes(body.status)) { - json(res, 400, { ok: false, error: 'review status must be approved or changes-requested' }); - return; - } - if (body.status === 'changes-requested' && (typeof body.note !== 'string' || !body.note.trim())) { - json(res, 400, { ok: false, error: 'review feedback is required when changes are requested' }); - return; - } - if (typeof body.note === 'string' && body.note.trim().length > 2000) { - json(res, 400, { ok: false, error: 'review feedback must be at most 2000 characters' }); - return; - } - const recipe = recipes.find((item) => item.id === body.recipeId); - if (!recipe) { - json(res, 400, { ok: false, error: 'shotkit: campaign recipe was not found' }); - return; - } - if (!Array.isArray(body.candidates) || !body.candidates.length) { - json(res, 400, { ok: false, error: 'review candidates must be a non-empty array' }); - return; - } - const availableTargets = new Set(recipe.targets.map((target) => target.id)); - const candidateTargets = body.candidates.map((candidate) => candidate && candidate.target); - if (candidateTargets.some((target) => typeof target !== 'string' || !availableTargets.has(target)) - || new Set(candidateTargets).size !== candidateTargets.length) { - json(res, 400, { ok: false, error: 'review candidates contain an unavailable or duplicate target' }); - return; - } - const current = state(); - const selected = candidateTargets.map((target) => current.targets.find((item) => ( - item.story === recipe.story && item.target === target - ))); - if (selected.some((target) => !target)) { - json(res, 404, { ok: false, error: 'configured story/target was not found' }); - return; - } - if (selected.some((target) => !target.reviewable)) { - json(res, 409, { ok: false, error: 'every selected target must be ready for user review' }); - return; - } - const stale = selected.some((target, index) => { - const candidate = body.candidates[index]; - return candidate.assetDigest !== target.assetDigest - || (candidate.profileHash || null) !== (target.profileHash || null); - }); - if (stale) { - json(res, 409, { ok: false, error: 'review candidate is stale; reload the final media before deciding' }); - return; - } - const approval = updateApprovalDecisions(outDir, selected.map((target) => ({ - story: recipe.story, - target: target.target, - decision: { - status: body.status, - assetDigest: target.assetDigest, - ...(target.profileHash ? { profileHash: target.profileHash } : {}), - note: body.note, - }, - }))); - syncApprovalManifest(outDir, approval.document, calibrationDocument()); - json(res, 200, { ok: true, campaign: campaignState() }); - return; - } - if (req.method === 'POST' && url.pathname === '/api/profile') { - if (!calibrationEnabled) { - json(res, 409, { ok: false, error: 'calibration is not configured for this project' }); - return; - } - if (recapturing) { - json(res, 409, { ok: false, error: 'profile changes are unavailable while capture is running' }); - return; - } - const body = await requestBody(req); - if (!configuredTarget(config, body.story, body.target)) { - json(res, 404, { ok: false, error: 'configured story/target was not found' }); - return; - } - const updated = updateCalibrationProfile(config, cwd, body.story, body.target, body.profile); - syncApprovalManifest(outDir, loadApproval(outDir).document, updated.document); - json(res, 200, { - ok: true, - profile: profileFor(updated.document, body.story, body.target), - profileHash: calibrationProfileHash(body.profile), - }); - return; - } - if (req.method === 'POST' && url.pathname === '/api/recapture') { - if (recapturing) { - json(res, 409, { ok: false, error: 'a recapture is already running' }); - return; - } - const body = await requestBody(req); - if (!configuredTarget(config, body.story, body.target)) { - json(res, 404, { ok: false, error: 'configured story/target was not found' }); - return; - } - recapturing = true; - try { - const result = await recaptureTarget({ - cwd, - config, - configPath, - outDir, - story: body.story, - target: body.target, - runner: captureTarget, - }); - json(res, 200, result); - } finally { - recapturing = false; - } - return; - } - if (req.method === 'POST' && url.pathname === '/api/review') { - if (recapturing) { - json(res, 409, { ok: false, error: 'review is unavailable while capture is running' }); - return; - } - const body = await requestBody(req); - if (!['approved', 'changes-requested'].includes(body.status)) { - json(res, 400, { ok: false, error: 'review status must be approved or changes-requested' }); - return; - } - if (body.status === 'changes-requested' && (typeof body.note !== 'string' || !body.note.trim())) { - json(res, 400, { ok: false, error: 'review feedback is required when changes are requested' }); - return; - } - if (typeof body.note === 'string' && body.note.trim().length > 2000) { - json(res, 400, { ok: false, error: 'review feedback must be at most 2000 characters' }); - return; - } - const current = state({ story: body.story, target: body.target }); - const selected = current.targets.find((item) => item.story === body.story && item.target === body.target); - if (!selected) { - json(res, 404, { ok: false, error: 'configured story/target was not found' }); - return; - } - if (!selected.reviewable) { - json(res, 409, { ok: false, error: 'target must pass machine QA and recapture verification before user review' }); - return; - } - if (body.assetDigest !== selected.assetDigest - || (body.profileHash || null) !== (selected.profileHash || null)) { - json(res, 409, { ok: false, error: 'review candidate is stale; reload the final media before deciding' }); - return; - } - const updated = updateApprovalDecision(outDir, body.story, body.target, { - status: body.status, - assetDigest: selected.assetDigest, - ...(selected.profileHash ? { profileHash: selected.profileHash } : {}), - note: body.note, - }); - syncApprovalManifest(outDir, updated.document, calibrationDocument()); - const refreshed = state({ story: body.story, target: body.target }); - json(res, 200, { - ok: true, - review: refreshed.targets.find((item) => item.story === body.story && item.target === body.target).review, - approvalStatus: refreshed.approvalStatus, - }); - return; - } - if (req.method === 'GET' && url.pathname.startsWith('/media/')) { - let name; - try { - name = decodeURIComponent(url.pathname.slice('/media/'.length)); - } catch (_error) { - throw new HttpError(400, 'invalid media path encoding'); - } - if (!name || path.basename(name) !== name || !/\.(mp4|webm|png|jpe?g)$/i.test(name)) { - res.writeHead(400, securityHeaders()).end('Invalid media path'); - return; - } - const mediaPath = safeMediaPath(outDir, name); - if (!mediaPath) { - res.writeHead(404, securityHeaders()).end('Not found'); - return; - } - serveFile(req, res, mediaPath); - return; - } - if (req.method === 'GET' && url.pathname === '/campaign') { - res.writeHead(302, { ...securityHeaders(), Location: '/campaign/' }).end(); - return; - } - if (req.method === 'GET' && url.pathname.startsWith('/campaign/')) { - serveFile(req, res, safeCampaignStaticPath(url.pathname)); - return; - } - if (req.method === 'GET') { - serveFile(req, res, safeStaticPath(url.pathname)); - return; - } - res.writeHead(405, { ...securityHeaders(), Allow: 'GET, POST' }).end('Method not allowed'); - } catch (error) { - json(res, Number.isInteger(error.status) ? error.status : 500, { ok: false, error: error.message }); - } + const handler = createCalibratorRequestHandler({ + cwd, + config, + configPath, + outDir, + calibrationEnabled, + state, + campaignState, + campaignRuns, + recipes, + captureTarget, + isRecapturing: () => recapturing, + setRecapturing(value) { recapturing = value; }, + calibrationDocument, + syncApprovalManifest, + profileFor, }); + const server = http.createServer(handler); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); diff --git a/src/calibrator-state.js b/src/calibrator-state.js new file mode 100644 index 0000000..94089ee --- /dev/null +++ b/src/calibrator-state.js @@ -0,0 +1,217 @@ +const fs = require('fs'); +const path = require('path'); + +const { loadApproval, syncManifestApproval } = require('./approval'); +const { + applyCalibrationProfiles, + calibrationProfileHash, + loadCalibration, +} = require('./calibration'); +const { hasCalibration } = require('./calibration-verification'); +const { normalizeDemoConfigs } = require('./demo'); +const { writeJson } = require('./handoff-files'); + +function readJson(filePath, fallback = null) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (_error) { + return fallback; + } +} + +function safeArea(target, viewport) { + if (target === 'youtube-shorts') { + return { + x: 40, + y: 96, + width: Math.max(0, viewport.width - 160), + height: Math.max(0, viewport.height - 416), + }; + } + return { + x: 48, + y: 40, + width: Math.max(0, viewport.width - 96), + height: Math.max(0, viewport.height - 88), + }; +} + +function assetFor(assets, demoName, role) { + return assets.find((asset) => asset.role === role && asset.source && asset.source.name === demoName); +} + +function profileFor(document, story, target) { + return document.profiles && document.profiles[story] && document.profiles[story][target] + ? document.profiles[story][target] + : {}; +} + +function hasProfile(document, story, target) { + return !!(document.profiles && document.profiles[story] + && Object.prototype.hasOwnProperty.call(document.profiles[story], target)); +} + +function applyCalibrationHashes(manifest, calibrationDocument) { + const targets = manifest.handoff && manifest.handoff.automation + ? manifest.handoff.automation.targets || [] + : []; + for (const target of targets) { + const profile = profileFor(calibrationDocument, target.story, target.target); + if (!target.profileHash && profile.verification) { + const profileHash = calibrationProfileHash(profile); + if (profile.verification.status === 'publish-ready' + && profile.verification.profileHash === profileHash) { + target.profileHash = profileHash; + } + } + } +} + +function calibrationApprovalOptions(calibrationDocument) { + return { + targetContext(target) { + const profile = profileFor(calibrationDocument, target.story, target.target); + const saved = hasProfile(calibrationDocument, target.story, target.target); + const profileHash = saved ? calibrationProfileHash(profile) : null; + const verified = !!(saved && profile.verification + && profile.verification.status === 'publish-ready' + && profile.verification.profileHash === profileHash + && (!target.profileHash || target.profileHash === profileHash)); + return { ready: verified, profileHash }; + }, + }; +} + +function syncApprovalManifest(outDir, approvalDocument, calibrationDocument) { + const manifestPath = path.join(outDir, 'shotkit-manifest.json'); + const manifest = readJson(manifestPath); + if (!manifest || !manifest.handoff || !manifest.handoff.automation) return null; + if (calibrationDocument) applyCalibrationHashes(manifest, calibrationDocument); + const gate = syncManifestApproval( + manifest, + approvalDocument, + calibrationDocument ? calibrationApprovalOptions(calibrationDocument) : {}, + ); + writeJson(manifestPath, manifest); + return gate; +} + +function createStateReader({ cwd, config }) { + const outDir = path.resolve(cwd, config.outDir || 'store-assets'); + const calibrationEnabled = hasCalibration(config); + return function state(selected = {}) { + const calibration = loadCalibration(config, cwd); + const approval = loadApproval(outDir); + const demos = applyCalibrationProfiles(normalizeDemoConfigs(config), calibration.document) + .filter((demo) => demo.target); + const manifest = readJson(path.join(outDir, 'shotkit-manifest.json'), {}); + if (calibrationEnabled) applyCalibrationHashes(manifest, calibration.document); + const storyboard = readJson(path.join(outDir, 'storyboard.json'), {}); + const captions = readJson(path.join(outDir, 'captions.json'), {}); + const assets = manifest.assets || []; + const automationTargets = manifest.handoff && manifest.handoff.automation + ? manifest.handoff.automation.targets || [] + : []; + const approvalGate = syncManifestApproval( + manifest, + approval.document, + calibrationEnabled ? calibrationApprovalOptions(calibration.document) : {}, + ); + const approvalByKey = new Map((approvalGate.targets || []).map((item) => ( + [`${item.story}::${item.target}`, item] + ))); + const storyboardByName = new Map((storyboard.demos || []).map((demo) => [demo.name, demo])); + const captionByName = new Map((captions.demos || []).map((demo) => [demo.name, demo])); + const lintByName = new Map((storyboard.storyboardLint || []).map((item) => ( + [item.name, item.warnings || []] + ))); + const layouts = config.calibration && Array.isArray(config.calibration.layouts) + ? config.calibration.layouts + : ['default']; + const targets = demos.map((demo) => { + const story = demo.story || demo.name; + const board = storyboardByName.get(demo.name) || {}; + const caption = captionByName.get(demo.name) || {}; + const viewport = board.viewport + || (demo.targetProfile && demo.targetProfile.viewport) + || { width: 1280, height: 720 }; + const mp4 = assetFor(assets, demo.name, 'sns-demo-mp4'); + const thumbnail = assetFor(assets, demo.name, 'thumbnail'); + const directVideo = path.join(outDir, `${demo.name}.mp4`); + const directThumbnail = path.join(outDir, `${demo.name}-thumbnail.png`); + const publish = automationTargets.find((item) => ( + item.demo === demo.name && item.target === demo.target + )); + const approvalTarget = approvalByKey.get(`${story}::${demo.target}`) || { + status: 'not-ready', + assetDigest: null, + stale: false, + }; + const profile = profileFor(calibration.document, story, demo.target); + const savedProfile = hasProfile(calibration.document, story, demo.target); + const profileHash = calibrationEnabled + ? savedProfile ? calibrationProfileHash(profile) : null + : approvalTarget.profileHash || (publish && publish.profileHash) || null; + const verified = !calibrationEnabled || !!(savedProfile && profile.verification + && publish && publish.status === 'publish-ready' + && profile.verification.status === 'publish-ready' + && profile.verification.profileHash === profileHash); + const publishStatus = publish ? publish.status : 'not-requested'; + const reviewable = publishStatus === 'publish-ready' && verified && !!approvalTarget.assetDigest; + const reviewStatus = reviewable ? approvalTarget.status : 'not-ready'; + const status = publishStatus === 'publish-ready' + ? reviewable ? reviewStatus : 'needs-fix' + : publishStatus; + const videoName = mp4 && mp4.outPath ? path.basename(mp4.outPath) : path.basename(directVideo); + const thumbnailName = thumbnail && thumbnail.outPath + ? path.basename(thumbnail.outPath) + : path.basename(directThumbnail); + return { + story, + target: demo.target, + name: demo.name, + status, + machineStatus: publishStatus, + viewport, + safeArea: safeArea(demo.target, viewport), + videoUrl: fs.existsSync(path.join(outDir, videoName)) + ? `/media/${encodeURIComponent(videoName)}` : null, + thumbnailUrl: fs.existsSync(path.join(outDir, thumbnailName)) + ? `/media/${encodeURIComponent(thumbnailName)}` : null, + beats: board.beats || caption.captions || [], + captionStyle: caption.style || board.captionStyle || {}, + warnings: lintByName.get(demo.name) || [], + layouts, + profile, + hasProfile: savedProfile, + profileHash, + verified, + reviewable, + publishable: reviewable && reviewStatus === 'approved', + review: { + status: reviewStatus, + stale: !!approvalTarget.stale, + ...(approvalTarget.decision ? { decision: approvalTarget.decision } : {}), + }, + assetDigest: approvalTarget.assetDigest, + }; + }); + const active = targets.find((item) => ( + item.story === selected.story && item.target === selected.target + )) || targets[0] || null; + return { + project: path.basename(cwd), + calibratorAvailable: calibrationEnabled, + calibrationPath: calibration.path ? path.relative(cwd, calibration.path) : null, + targets, + selected: active ? { story: active.story, target: active.target } : null, + approvalStatus: approvalGate.status, + }; + }; +} + +module.exports = { + createStateReader, + profileFor, + syncApprovalManifest, +}; diff --git a/src/capture-demo.js b/src/capture-demo.js new file mode 100644 index 0000000..109dfbf --- /dev/null +++ b/src/capture-demo.js @@ -0,0 +1,170 @@ +const path = require('path'); + +const { prepareCaptionTypography } = require('./caption-typography'); +const { analyzeDemoCaptionMetrics } = require('./demo-caption-qa'); +const { createDemoController, installDemoCaptionOverlay } = require('./demo'); +const { analyzePng } = require('./image-qa'); +const { normalizeSetup } = require('./capture-lifecycle'); +const { postProcessDemo, probeVideo } = require('./video'); + +class DemoPostProcessError extends Error { + constructor(demoName, error) { + const message = error && error.message ? error.message : String(error); + super(`demo "${demoName}" post-processing failed: ${message}`, { cause: error }); + this.name = 'DemoPostProcessError'; + } +} + +function demoDisclaimer(config, demoConfig) { + const value = Object.prototype.hasOwnProperty.call(demoConfig, 'disclaimer') + ? demoConfig.disclaimer + : config.disclaimer; + if (value != null && value !== false && (typeof value !== 'string' || !value.trim())) { + throw new Error(`demo "${demoConfig.name}".disclaimer must be a non-empty string or false`); + } + return value; +} + +function installDisclaimer(text) { + const add = () => { + if (document.getElementById('__shotkit_badge__') || !document.body) return; + const badge = document.createElement('div'); + badge.id = '__shotkit_badge__'; + badge.textContent = text; + badge.style.cssText = 'position:fixed;top:10px;left:10px;z-index:2147483647;background:rgba(20,21,26,.86);color:#fff;font:600 11px -apple-system,Segoe UI,Roboto,sans-serif;padding:5px 9px;border-radius:6px;pointer-events:none'; + document.body.appendChild(badge); + }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add, { once: true }); + else add(); +} + +async function captureDemo({ + config, + demoConfig, + opts, + cwd, + outDir, + viewport, + passFlags, + demoCtx, + resources, + registerAsset, + log, +}) { + const preparedTypography = await prepareCaptionTypography( + demoConfig.captionOptions || {}, + cwd, + (demoConfig.captions || []).map((caption) => caption.text), + ); + await installDemoCaptionOverlay(demoCtx.context, preparedTypography.runtimeOptions); + + const disclaimer = demoDisclaimer(config, demoConfig); + if (disclaimer) await demoCtx.context.addInitScript(installDisclaimer, disclaimer); + + resources.setup = normalizeSetup( + config.setup ? await config.setup({ + context: demoCtx.context, + extensionId: demoCtx.extensionId, + flags: passFlags, + }) : null, + ); + resources.page = await demoCtx.context.newPage(); + await resources.page.setViewportSize(viewport); + if (preparedTypography.report.enabled) { + await resources.page.evaluate(async () => { + if (window.__shotkitDemoCaption && typeof window.__shotkitDemoCaption.ready === 'function') { + await window.__shotkitDemoCaption.ready(); + } + }); + } + + const demo = createDemoController({ + page: resources.page, + captions: demoConfig.captions, + captionOptions: demoConfig.captionOptions, + runtimeCaptionOptions: preparedTypography.runtimeOptions, + typographyReport: preparedTypography.report, + }); + let captionMetricReport; + try { + await demoConfig.run({ + page: resources.page, + context: demoCtx.context, + extensionId: demoCtx.extensionId, + env: resources.setup.env, + baseUrl: resources.setup.env.baseUrl, + flags: passFlags, + demo, + target: demoConfig.targetProfile || null, + calibration: demoConfig.calibrationProfile || null, + }); + } finally { + captionMetricReport = demo.captionMetrics(); + demo.stop(); + } + + const calibrationProfile = demoConfig.calibrationProfile || {}; + const runtimeCaptionWarnings = analyzeDemoCaptionMetrics(captionMetricReport, { + viewport, + protectedRegions: calibrationProfile.protectedRegions || [], + framing: calibrationProfile.framing || null, + }); + for (const warning of runtimeCaptionWarnings) { + log(`⚠️ ${demoConfig.name}: ${warning.message}${warning.fix ? `; ${warning.fix}` : ''}`); + } + + const video = resources.page.video(); + await resources.page.close(); + if (!video) throw new Error(`demo "${demoConfig.name}" did not produce a video recording`); + const out = path.join(outDir, `${demoConfig.name}.webm`); + await video.saveAs(out); + registerAsset(out, { + name: demoConfig.name, + type: 'video', + role: 'source-demo-webm', + width: viewport.width, + height: viewport.height, + target: demoConfig.target, + channel: demoConfig.channel, + source: { kind: 'demo', name: demoConfig.name, story: demoConfig.story, target: demoConfig.target }, + }, `✓ ${demoConfig.name}.webm (${viewport.width}×${viewport.height})`); + + let extra; + try { + extra = postProcessDemo({ + webmPath: out, + mp4: demoConfig.mp4 || opts.mp4, + trim: demoConfig.trim, + crop: demoConfig.crop, + zoom: demoConfig.zoom, + thumbnail: demoConfig.thumbnail, + log, + }); + } catch (error) { + throw new DemoPostProcessError(demoConfig.name, error); + } + for (const extraPath of extra) { + const format = path.extname(extraPath).toLowerCase(); + const media = format === '.mp4' && demoConfig.target ? probeVideo(extraPath) : undefined; + const visual = format === '.png' && demoConfig.target ? analyzePng(extraPath) : undefined; + registerAsset(extraPath, { + name: path.basename(extraPath, path.extname(extraPath)), + type: format === '.png' ? 'image' : 'video', + role: format === '.png' ? 'thumbnail' : 'sns-demo-mp4', + width: media && media.ok ? media.width : undefined, + height: media && media.ok ? media.height : undefined, + target: demoConfig.target, + channel: demoConfig.channel, + media, + visual, + source: { kind: 'demo', name: demoConfig.name, story: demoConfig.story, target: demoConfig.target }, + }); + } + + return { captionMetricReport, runtimeCaptionWarnings }; +} + +module.exports = { + captureDemo, + DemoPostProcessError, +}; diff --git a/src/capture-lifecycle.js b/src/capture-lifecycle.js new file mode 100644 index 0000000..7e1091e --- /dev/null +++ b/src/capture-lifecycle.js @@ -0,0 +1,9 @@ +function normalizeSetup(result) { + if (!result) return { env: {}, teardown: async () => {} }; + if (typeof result.teardown === 'function') { + return { env: result.env || {}, teardown: result.teardown }; + } + return { env: result.env || result, teardown: async () => {} }; +} + +module.exports = { normalizeSetup }; diff --git a/src/capture-plan.js b/src/capture-plan.js new file mode 100644 index 0000000..5091650 --- /dev/null +++ b/src/capture-plan.js @@ -0,0 +1,92 @@ +const path = require('path'); + +const { DEFAULT_BAND_HEIGHT } = require('./caption'); +const { hasJsonExtension } = require('./describe'); +const { resolveSize } = require('./presets'); + +const DEFAULT_VIEWPORT = { width: 1280, height: 800 }; + +function usageError(message) { + const error = new Error(message); + error.exitCode = 2; + return error; +} + +function wantsAny(only, names) { + if (only.size === 0) return names.length > 0; + return names.some((name) => only.has(name)); +} + +function visualOutputNames(config) { + return [ + ...(config.scenes || []).map((scene) => scene.name), + ...(config.promoTiles || []).map((tile) => tile.name), + ].filter(Boolean); +} + +function textOutputNames(config, cwd) { + if (!config.description || !config.description.from) return []; + const sourcePath = path.resolve(cwd, config.description.from); + return hasJsonExtension(sourcePath) ? ['description', 'privacy'] : ['description']; +} + +function outputNames(config, cwd, demoConfigs) { + return [ + ...visualOutputNames(config), + ...textOutputNames(config, cwd), + ...demoConfigs.flatMap((demo) => [demo.name, demo.story]).filter(Boolean), + ]; +} + +function createCapturePlan({ config, opts = {}, cwd, demoConfigs }) { + const only = new Set(opts.scenes || []); + const targetOnly = new Set(opts.targets || []); + const wants = (name) => only.size === 0 || only.has(name); + const wantsDemo = (demo) => wants(demo.name) || (demo.story && only.has(demo.story)); + const wantsTarget = (demo) => targetOnly.size === 0 || targetOnly.has(demo.target); + const requestedDemoConfigs = demoConfigs.filter((demo) => wantsDemo(demo) && wantsTarget(demo)); + + if (only.size > 0) { + const knownNames = new Set(outputNames(config, cwd, demoConfigs)); + const unknownNames = [...only].filter((name) => !knownNames.has(name)); + if (unknownNames.length) { + throw usageError(`unknown scene: ${unknownNames.join(', ')}. Known: ${[...knownNames].join(', ') || '(none)'}`); + } + } + if (targetOnly.size > 0) { + const configuredTargets = new Set(demoConfigs.map((demo) => demo.target).filter(Boolean)); + const unknownTargets = [...targetOnly].filter((target) => !configuredTargets.has(target)); + if (unknownTargets.length) { + throw usageError(`target not configured: ${unknownTargets.join(', ')}. Configured: ${[...configuredTargets].join(', ') || '(none)'}`); + } + if (!requestedDemoConfigs.length) { + throw usageError('no configured demo matches the requested scene and target filters'); + } + } + + const shouldRunVisualPass = targetOnly.size === 0 && wantsAny(only, visualOutputNames(config)); + const shouldRunTextPass = targetOnly.size === 0 && wantsAny(only, textOutputNames(config, cwd)); + const selectedDemoConfigs = opts.noVideo ? [] : requestedDemoConfigs; + return { + cwd, + only, + targetOnly, + wants, + outDir: path.resolve(cwd, config.outDir || 'store-assets'), + defaultViewport: resolveSize(config.viewport, DEFAULT_VIEWPORT), + bandHeight: config.bandHeight || DEFAULT_BAND_HEIGHT, + passFlags: { liveGt: !!opts.liveGt, freeze: !!opts.freeze }, + demoConfigs, + requestedDemoConfigs, + selectedDemoConfigs, + shouldRunVisualPass, + shouldRunTextPass, + needsBrowser: shouldRunVisualPass || selectedDemoConfigs.length > 0, + partial: only.size > 0 || targetOnly.size > 0 || !!opts.noVideo, + }; +} + +module.exports = { + DEFAULT_VIEWPORT, + createCapturePlan, +}; diff --git a/src/capture-static.js b/src/capture-static.js new file mode 100644 index 0000000..ffddf65 --- /dev/null +++ b/src/capture-static.js @@ -0,0 +1,96 @@ +const path = require('path'); + +const { compositeCaption } = require('./caption'); +const { renderPromoTile } = require('./promo'); +const { resolveSize } = require('./presets'); + +async function captureStaticAssets({ + config, + context, + extensionId, + setup, + passFlags, + wants, + defaultViewport, + bandHeight, + outDir, + writeAsset, +}) { + for (const scene of config.scenes || []) { + if (!wants(scene.name)) continue; + const viewport = resolveSize(scene.preset || scene.viewport, defaultViewport); + const captioned = !!(config.disclaimer || scene.caption); + const captureHeight = captioned ? viewport.height - bandHeight : viewport.height; + const page = await context.newPage(); + try { + await page.setViewportSize({ width: viewport.width, height: captureHeight }); + await scene.run({ + page, + context, + extensionId, + env: setup.env, + baseUrl: setup.env.baseUrl, + flags: passFlags, + }); + let buffer = await page.screenshot({ + clip: { x: 0, y: 0, width: viewport.width, height: captureHeight }, + }); + if (captioned) { + buffer = await compositeCaption({ + context, + imageBuffer: buffer, + width: viewport.width, + height: viewport.height, + bandHeight, + caption: scene.caption, + disclaimer: config.disclaimer, + }); + } + writeAsset( + path.join(outDir, `${scene.name}.png`), + buffer, + { + name: scene.name, + type: 'image', + role: 'store-screenshot', + width: viewport.width, + height: viewport.height, + source: { kind: 'scene', name: scene.name }, + }, + `✓ ${scene.name}.png (${viewport.width}×${viewport.height})`, + ); + } finally { + await page.close(); + } + } + + for (const tile of config.promoTiles || []) { + if (!wants(tile.name)) continue; + const { width, height } = resolveSize( + tile.preset || { width: tile.width, height: tile.height }, + defaultViewport, + ); + const buffer = await renderPromoTile({ + context, + template: tile.template, + width, + height, + replacements: tile.replacements, + }); + writeAsset( + path.join(outDir, `${tile.name}.png`), + buffer, + { + name: tile.name, + type: 'image', + role: 'promo-tile', + width, + height, + source: { kind: 'promoTile', name: tile.name }, + }, + `✓ ${tile.name}.png (${width}×${height})`, + ); + } +} + +module.exports = { captureStaticAssets }; diff --git a/src/capture.js b/src/capture.js index 46a95dd..1d5ad38 100644 --- a/src/capture.js +++ b/src/capture.js @@ -26,8 +26,6 @@ const path = require('path'); const { execSync } = require('child_process'); const { launchWithExtension, closeContext } = require('./launch'); -const { compositeCaption, DEFAULT_BAND_HEIGHT } = require('./caption'); -const { renderPromoTile } = require('./promo'); const { extractListing, extractProductManifest, @@ -36,23 +34,14 @@ const { renderPrivacyDisclosureDoc, } = require('./describe'); const { resolveSize } = require('./presets'); -const { postProcessDemo, probeVideo } = require('./video'); -const { analyzeDemoStoryboard, createDemoController, installDemoCaptionOverlay, normalizeDemoConfigs } = require('./demo'); -const { analyzeDemoCaptionMetrics } = require('./demo-caption-qa'); -const { prepareCaptionTypography } = require('./caption-typography'); +const { analyzeDemoStoryboard, normalizeDemoConfigs } = require('./demo'); +const { captureDemo, DemoPostProcessError } = require('./capture-demo'); +const { normalizeSetup } = require('./capture-lifecycle'); +const { createCapturePlan, DEFAULT_VIEWPORT } = require('./capture-plan'); +const { captureStaticAssets } = require('./capture-static'); const { applyCalibrationProfiles, loadCalibration } = require('./calibration'); const { deliveryStatus } = require('./approval'); const { assetRecord, writeHandoffDocs } = require('./handoff'); -const { analyzePng } = require('./image-qa'); - -const DEFAULT_VIEWPORT = { width: 1280, height: 800 }; - -/** Normalize whatever setup() returns into { env, teardown }. */ -function normalizeSetup(result) { - if (!result) return { env: {}, teardown: async () => {} }; - if (typeof result.teardown === 'function') return { env: result.env || {}, teardown: result.teardown }; - return { env: result.env || result, teardown: async () => {} }; -} function isManagedTempDir(dir, prefix) { const resolved = path.resolve(dir); @@ -75,42 +64,6 @@ function normalizePreparedExtension(result) { throw new Error('prepareExtension() must return an extension directory string or { dir, cleanup }'); } -function wantsAny(only, names) { - if (only.size === 0) return names.length > 0; - return names.some((name) => only.has(name)); -} - -function visualOutputNames(config) { - return [ - ...(config.scenes || []).map((scene) => scene.name), - ...(config.promoTiles || []).map((tile) => tile.name), - ].filter(Boolean); -} - -function textOutputNames(config, cwd) { - const names = []; - if (config.description && config.description.from) { - const sourcePath = path.resolve(cwd, config.description.from); - names.push('description'); - if (hasJsonExtension(sourcePath)) names.push('privacy'); - } - return names.filter(Boolean); -} - -function outputNames(config, cwd, demoConfigs) { - return [ - ...visualOutputNames(config), - ...textOutputNames(config, cwd), - ...demoConfigs.flatMap((demo) => [demo.name, demo.story]).filter(Boolean), - ]; -} - -function usageError(message) { - const err = new Error(message); - err.exitCode = 2; - return err; -} - /** * @param {object} config the project's shotkit config object (scenes, etc.) * @param {object} [opts] @@ -127,50 +80,34 @@ function usageError(message) { */ async function capture(config, opts = {}) { const cwd = opts.cwd || process.cwd(); - const only = new Set(opts.scenes || []); - const targetOnly = new Set(opts.targets || []); - const wants = (name) => only.size === 0 || only.has(name); - const wantsDemo = (demo) => wants(demo.name) || (demo.story && only.has(demo.story)); - const wantsTarget = (demo) => targetOnly.size === 0 || targetOnly.has(demo.target); const log = opts.log || ((msg) => console.log(`[shotkit] ${msg}`)); - const passFlags = { liveGt: !!opts.liveGt, freeze: !!opts.freeze }; - - const outDir = path.resolve(cwd, config.outDir || 'store-assets'); - const defaultViewport = resolveSize(config.viewport, DEFAULT_VIEWPORT); - const bandHeight = config.bandHeight || DEFAULT_BAND_HEIGHT; + const calibration = loadCalibration(config, cwd); + const demoConfigs = applyCalibrationProfiles(normalizeDemoConfigs(config), calibration.document); + const plan = createCapturePlan({ config, opts, cwd, demoConfigs }); + const { + bandHeight, + defaultViewport, + needsBrowser, + only, + outDir, + partial, + passFlags, + requestedDemoConfigs, + selectedDemoConfigs, + shouldRunTextPass, + shouldRunVisualPass, + targetOnly, + wants, + } = plan; const produced = []; let manifest = null; let status = 'not-requested'; let machineStatus = 'not-requested'; const assets = []; - const calibration = loadCalibration(config, cwd); - const demoConfigs = applyCalibrationProfiles(normalizeDemoConfigs(config), calibration.document); const capturedDemoConfigs = []; const demoViewports = {}; const demoWarnings = {}; const demoCaptionReports = {}; - const shouldRunVisualPass = targetOnly.size === 0 && wantsAny(only, visualOutputNames(config)); - const shouldRunTextPass = targetOnly.size === 0 && wantsAny(only, textOutputNames(config, cwd)); - const requestedDemoConfigs = demoConfigs.filter((demoConfig) => wantsDemo(demoConfig) && wantsTarget(demoConfig)); - const selectedDemoConfigs = requestedDemoConfigs.filter(() => !opts.noVideo); - const needsBrowser = shouldRunVisualPass || selectedDemoConfigs.length > 0; - if (only.size > 0) { - const knownNames = new Set(outputNames(config, cwd, demoConfigs)); - const unknownNames = [...only].filter((name) => !knownNames.has(name)); - if (unknownNames.length) { - throw usageError(`unknown scene: ${unknownNames.join(', ')}. Known: ${[...knownNames].join(', ') || '(none)'}`); - } - } - if (targetOnly.size > 0) { - const configuredTargets = new Set(demoConfigs.map((demo) => demo.target).filter(Boolean)); - const unknownTargets = [...targetOnly].filter((target) => !configuredTargets.has(target)); - if (unknownTargets.length) { - throw usageError(`target not configured: ${unknownTargets.join(', ')}. Configured: ${[...configuredTargets].join(', ') || '(none)'}`); - } - if (!requestedDemoConfigs.length) { - throw usageError('no configured demo matches the requested scene and target filters'); - } - } fs.mkdirSync(outDir, { recursive: true }); const tempDirs = []; let fatalDemoError = null; @@ -285,61 +222,18 @@ async function capture(config, opts = {}) { setup = normalizeSetup( config.setup ? await config.setup({ context: ctx.context, extensionId: ctx.extensionId, flags: passFlags }) : null, ); - for (const scene of config.scenes || []) { - if (!wants(scene.name)) continue; - const viewport = resolveSize(scene.preset || scene.viewport, defaultViewport); - const captioned = !!(config.disclaimer || scene.caption); - const captureHeight = captioned ? viewport.height - bandHeight : viewport.height; - - const page = await ctx.context.newPage(); - try { - await page.setViewportSize({ width: viewport.width, height: captureHeight }); - await scene.run({ page, context: ctx.context, extensionId: ctx.extensionId, env: setup.env, baseUrl: setup.env.baseUrl, flags: passFlags }); - let buf = await page.screenshot({ clip: { x: 0, y: 0, width: viewport.width, height: captureHeight } }); - if (captioned) { - buf = await compositeCaption({ - context: ctx.context, imageBuffer: buf, - width: viewport.width, height: viewport.height, bandHeight, - caption: scene.caption, disclaimer: config.disclaimer, - }); - } - writeAsset( - path.join(outDir, `${scene.name}.png`), - buf, - { - name: scene.name, - type: 'image', - role: 'store-screenshot', - width: viewport.width, - height: viewport.height, - source: { kind: 'scene', name: scene.name }, - }, - `✓ ${scene.name}.png (${viewport.width}×${viewport.height})`, - ); - } finally { - await page.close(); - } - } - - for (const tile of config.promoTiles || []) { - if (!wants(tile.name)) continue; - const { width, height } = resolveSize(tile.preset || { width: tile.width, height: tile.height }, defaultViewport); - const buf = await renderPromoTile({ context: ctx.context, template: tile.template, width, height, replacements: tile.replacements }); - writeAsset( - path.join(outDir, `${tile.name}.png`), - buf, - { - name: tile.name, - type: 'image', - role: 'promo-tile', - width, - height, - source: { kind: 'promoTile', name: tile.name }, - }, - `✓ ${tile.name}.png (${width}×${height})`, - ); - } - + await captureStaticAssets({ + config, + context: ctx.context, + extensionId: ctx.extensionId, + setup, + passFlags, + wants, + defaultViewport, + bandHeight, + outDir, + writeAsset, + }); } finally { // Close the context (drops the browser's sockets) BEFORE the fixture server: // server.close() waits for open connections to drain, and a still-open page @@ -368,156 +262,41 @@ async function capture(config, opts = {}) { const videoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-video-')); tempDirs.push(videoDir); const demoCtx = await launchWithExtension({ extensionDir, viewport, recordVideoDir: videoDir, recordVideoSize: viewport }); - let setup2 = normalizeSetup(null); - let page = null; + const resources = { setup: normalizeSetup(null), page: null }; try { - const preparedTypography = await prepareCaptionTypography( - demoConfig.captionOptions || {}, + const result = await captureDemo({ + config, + demoConfig, + opts, cwd, - (demoConfig.captions || []).map((caption) => caption.text), - ); - await installDemoCaptionOverlay(demoCtx.context, preparedTypography.runtimeOptions); - - // Keep a small "unofficial" badge on screen across navigations. A - // target-specific story may shorten or disable the global screenshot - // disclaimer when the video header has less room. - const demoDisclaimer = Object.prototype.hasOwnProperty.call(demoConfig, 'disclaimer') - ? demoConfig.disclaimer - : config.disclaimer; - if (demoDisclaimer != null && demoDisclaimer !== false - && (typeof demoDisclaimer !== 'string' || !demoDisclaimer.trim())) { - throw new Error(`demo "${demoConfig.name}".disclaimer must be a non-empty string or false`); - } - if (demoDisclaimer) { - await demoCtx.context.addInitScript((text) => { - const add = () => { - if (document.getElementById('__shotkit_badge__') || !document.body) return; - const b = document.createElement('div'); - b.id = '__shotkit_badge__'; - b.textContent = text; - b.style.cssText = 'position:fixed;top:10px;left:10px;z-index:2147483647;background:rgba(20,21,26,.86);color:#fff;font:600 11px -apple-system,Segoe UI,Roboto,sans-serif;padding:5px 9px;border-radius:6px;pointer-events:none'; - document.body.appendChild(b); - }; - if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add, { once: true }); - else add(); - }, demoDisclaimer); - } - - setup2 = normalizeSetup( - config.setup ? await config.setup({ context: demoCtx.context, extensionId: demoCtx.extensionId, flags: passFlags }) : null, - ); - page = await demoCtx.context.newPage(); - await page.setViewportSize(viewport); - if (preparedTypography.report.enabled) { - await page.evaluate(async () => { - if (window.__shotkitDemoCaption && typeof window.__shotkitDemoCaption.ready === 'function') { - await window.__shotkitDemoCaption.ready(); - } - }); - } - const demo = createDemoController({ - page, - captions: demoConfig.captions, - captionOptions: demoConfig.captionOptions, - runtimeCaptionOptions: preparedTypography.runtimeOptions, - typographyReport: preparedTypography.report, - }); - let captionMetricReport; - try { - await demoConfig.run({ - page, - context: demoCtx.context, - extensionId: demoCtx.extensionId, - env: setup2.env, - baseUrl: setup2.env.baseUrl, - flags: passFlags, - demo, - target: demoConfig.targetProfile || null, - calibration: demoConfig.calibrationProfile || null, - }); - } finally { - captionMetricReport = demo.captionMetrics(); - demo.stop(); - } - demoCaptionReports[demoConfig.name] = captionMetricReport; - const calibrationProfile = demoConfig.calibrationProfile || {}; - const runtimeCaptionWarnings = analyzeDemoCaptionMetrics(captionMetricReport, { + outDir, viewport, - protectedRegions: calibrationProfile.protectedRegions || [], - framing: calibrationProfile.framing || null, + passFlags, + demoCtx, + resources, + registerAsset, + log, }); - demoWarnings[demoConfig.name].push(...runtimeCaptionWarnings); - for (const warning of runtimeCaptionWarnings) { - log(`⚠️ ${demoConfig.name}: ${warning.message}${warning.fix ? `; ${warning.fix}` : ''}`); - } - // Ordering: grab the video handle, page.close() (finalizes recording + - // drops the page socket), video.saveAs() while the browser is still up, - // THEN closeContext, THEN server teardown (no page holds a socket → no - // deadlock). See the screenshots finally above. - const video = page.video(); - await page.close(); - if (!video) throw new Error(`demo "${demoConfig.name}" did not produce a video recording`); - const out = path.join(outDir, `${demoConfig.name}.webm`); - await video.saveAs(out); capturedDemoConfigs.push(demoConfig); - registerAsset(out, { - name: demoConfig.name, - type: 'video', - role: 'source-demo-webm', - width: viewport.width, - height: viewport.height, - target: demoConfig.target, - channel: demoConfig.channel, - source: { kind: 'demo', name: demoConfig.name, story: demoConfig.story, target: demoConfig.target }, - }, `✓ ${demoConfig.name}.webm (${viewport.width}×${viewport.height})`); - // SNS post-processing: mp4 (H.264) and/or trim — needs a real ffmpeg, - // fails loudly if one was requested but none is installed. - let extra; - try { - extra = postProcessDemo({ - webmPath: out, - mp4: demoConfig.mp4 || opts.mp4, - trim: demoConfig.trim, - crop: demoConfig.crop, - zoom: demoConfig.zoom, - thumbnail: demoConfig.thumbnail, - log, - }); - } catch (err) { - const msg = err && err.message ? err.message : String(err); - fatalDemoError = new Error(`demo "${demoConfig.name}" post-processing failed: ${msg}`, { cause: err }); + demoCaptionReports[demoConfig.name] = result.captionMetricReport; + demoWarnings[demoConfig.name].push(...result.runtimeCaptionWarnings); + } catch (err) { + if (err instanceof DemoPostProcessError) { + fatalDemoError = err; log(`❌ ${fatalDemoError.message}`); break; } - for (const extraPath of extra) { - const format = path.extname(extraPath).toLowerCase(); - const media = format === '.mp4' && demoConfig.target ? probeVideo(extraPath) : undefined; - const visual = format === '.png' && demoConfig.target ? analyzePng(extraPath) : undefined; - registerAsset(extraPath, { - name: path.basename(extraPath, path.extname(extraPath)), - type: format === '.png' ? 'image' : 'video', - role: format === '.png' ? 'thumbnail' : 'sns-demo-mp4', - width: media && media.ok ? media.width : undefined, - height: media && media.ok ? media.height : undefined, - target: demoConfig.target, - channel: demoConfig.channel, - media, - visual, - source: { kind: 'demo', name: demoConfig.name, story: demoConfig.story, target: demoConfig.target }, - }); - } - } catch (err) { // One demo run/recording failure must not abort the remaining demos, the // later teardown, but it must still make the run fail. A requested demo // clip missing from produced[] is a false-positive success signal. demoErrors.push({ name: demoConfig.name, error: err }); log(`❌ demo "${demoConfig.name}" failed: ${err.message} — continuing with the remaining demos`); } finally { - if (page && !page.isClosed()) await page.close().catch(() => {}); + if (resources.page && !resources.page.isClosed()) await resources.page.close().catch(() => {}); try { await closeContext(demoCtx); } finally { - await setup2.teardown(); + await resources.setup.teardown(); } } } @@ -543,7 +322,7 @@ async function capture(config, opts = {}) { flags: passFlags, // Scene-filtered or --no-video runs only re-capture a subset; merge into // the existing handoff contract rather than clobbering a prior full run. - partial: only.size > 0 || targetOnly.size > 0 || !!opts.noVideo, + partial, run: { requestedScenes: [...only], requestedTargets: [...targetOnly], diff --git a/src/review-request.js b/src/review-request.js new file mode 100644 index 0000000..0b72343 --- /dev/null +++ b/src/review-request.js @@ -0,0 +1,91 @@ +class ReviewRequestError extends Error { + constructor(status, message) { + super(message); + this.name = 'ReviewRequestError'; + this.status = status; + } +} + +function validateReviewDecision(body) { + if (!['approved', 'changes-requested'].includes(body.status)) { + throw new ReviewRequestError(400, 'review status must be approved or changes-requested'); + } + if (body.status === 'changes-requested' && (typeof body.note !== 'string' || !body.note.trim())) { + throw new ReviewRequestError(400, 'review feedback is required when changes are requested'); + } + if (typeof body.note === 'string' && body.note.trim().length > 2000) { + throw new ReviewRequestError(400, 'review feedback must be at most 2000 characters'); + } +} + +function isStale(candidate, target) { + return candidate.assetDigest !== target.assetDigest + || (candidate.profileHash || null) !== (target.profileHash || null); +} + +function decisionFor(body, target) { + return { + status: body.status, + assetDigest: target.assetDigest, + ...(target.profileHash ? { profileHash: target.profileHash } : {}), + note: body.note, + }; +} + +function validateCampaignReview({ body, recipes, current }) { + validateReviewDecision(body); + const recipe = recipes.find((item) => item.id === body.recipeId); + if (!recipe) throw new ReviewRequestError(400, 'shotkit: campaign recipe was not found'); + if (!Array.isArray(body.candidates) || !body.candidates.length) { + throw new ReviewRequestError(400, 'review candidates must be a non-empty array'); + } + const availableTargets = new Set(recipe.targets.map((target) => target.id)); + const candidateTargets = body.candidates.map((candidate) => candidate && candidate.target); + if (candidateTargets.some((target) => typeof target !== 'string' || !availableTargets.has(target)) + || new Set(candidateTargets).size !== candidateTargets.length) { + throw new ReviewRequestError(400, 'review candidates contain an unavailable or duplicate target'); + } + const selected = candidateTargets.map((target) => current.targets.find((item) => ( + item.story === recipe.story && item.target === target + ))); + if (selected.some((target) => !target)) { + throw new ReviewRequestError(404, 'configured story/target was not found'); + } + if (selected.some((target) => !target.reviewable)) { + throw new ReviewRequestError(409, 'every selected target must be ready for user review'); + } + if (selected.some((target, index) => isStale(body.candidates[index], target))) { + throw new ReviewRequestError(409, 'review candidate is stale; reload the final media before deciding'); + } + return { + recipe, + selected, + decisions: selected.map((target) => ({ + story: recipe.story, + target: target.target, + decision: decisionFor(body, target), + })), + }; +} + +function validateSingleReview({ body, current }) { + validateReviewDecision(body); + const selected = current.targets.find((item) => ( + item.story === body.story && item.target === body.target + )); + if (!selected) throw new ReviewRequestError(404, 'configured story/target was not found'); + if (!selected.reviewable) { + throw new ReviewRequestError(409, 'target must pass machine QA and recapture verification before user review'); + } + if (isStale(body, selected)) { + throw new ReviewRequestError(409, 'review candidate is stale; reload the final media before deciding'); + } + return { selected, decision: decisionFor(body, selected) }; +} + +module.exports = { + ReviewRequestError, + validateCampaignReview, + validateReviewDecision, + validateSingleReview, +}; diff --git a/test/calibrator-server.test.js b/test/calibrator-server.test.js index 74a0523..522625a 100644 --- a/test/calibrator-server.test.js +++ b/test/calibrator-server.test.js @@ -133,7 +133,10 @@ describe('calibrator server', () => { test.each(['calibrator', 'campaign'])('%s static DOM keeps every JavaScript id binding valid', (surface) => { const root = path.join(__dirname, '..', surface); const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8'); - const script = fs.readFileSync(path.join(root, 'app.js'), 'utf8'); + const script = fs.readdirSync(root) + .filter((name) => name.endsWith('.js')) + .map((name) => fs.readFileSync(path.join(root, name), 'utf8')) + .join('\n'); const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); expect(new Set(ids).size).toBe(ids.length); const bindings = [...script.matchAll(/\$\('([^']+)'\)/g)].map((match) => match[1]); @@ -177,6 +180,9 @@ describe('calibrator server', () => { ['/regions.js', 'text/javascript'], ['/campaign/styles.css', 'text/css'], ['/campaign/app.js', 'text/javascript'], + ['/campaign/api.js', 'text/javascript'], + ['/campaign/model.js', 'text/javascript'], + ['/campaign/render.js', 'text/javascript'], ]) { const response = await fetch(`${calibrator.url}${assetPath}`); expect(response.status).toBe(200); diff --git a/test/campaign-ui.node.mjs b/test/campaign-ui.node.mjs new file mode 100644 index 0000000..4a4880d --- /dev/null +++ b/test/campaign-ui.node.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + activeTarget, + createCampaignState, + initializeSelection, + productionStatus, + selectRecipeInState, + selectedTargets, + setCampaignView, + shouldPoll, + statusLabel, +} from '../campaign/model.js'; +import { + buildReviewPayload, + buildRunPayload, + requestJson, +} from '../campaign/api.js'; + +function target(id, overrides = {}) { + return { + id, + machineStatus: 'not-ready', + publishable: false, + reviewable: false, + review: { status: 'not-requested', decision: null }, + ...overrides, + }; +} + +function campaignDocument() { + return { + phase: 'plan', + selection: { recipeId: 'launch', persisted: true }, + recipes: [ + { id: 'other', targets: [target('cws')] }, + { id: 'launch', targets: [target('x'), target('youtube-shorts')] }, + ], + run: { status: 'idle', recipeId: null, targets: [] }, + }; +} + +test('campaign selection preserves the active target until the recipe changes', () => { + const state = createCampaignState(); + state.document = campaignDocument(); + + assert.equal(initializeSelection(state).id, 'launch'); + assert.deepEqual(selectedTargets(state).map((item) => item.id), ['x', 'youtube-shorts']); + assert.equal(activeTarget(state).id, 'x'); + + state.activeTargetId = 'youtube-shorts'; + initializeSelection(state); + assert.equal(activeTarget(state).id, 'youtube-shorts'); + + assert.equal(selectRecipeInState(state, 'other'), true); + assert.equal(activeTarget(state).id, 'cws'); + state.busy = true; + assert.equal(selectRecipeInState(state, 'launch'), false); + assert.equal(state.recipeId, 'other'); +}); + +test('production status honors approval, feedback, current run, and machine fallback order', () => { + const state = createCampaignState(); + state.document = campaignDocument(); + initializeSelection(state); + const output = selectedTargets(state)[0]; + + assert.equal(productionStatus(state, output), 'not-ready'); + output.reviewable = true; + assert.equal(productionStatus(state, output), 'publish-ready'); + state.document.run = { + status: 'running', + recipeId: 'launch', + targets: [{ target: 'x', status: 'running' }], + }; + assert.equal(productionStatus(state, output), 'running'); + output.review.status = 'changes-requested'; + assert.equal(productionStatus(state, output), 'changes-requested'); + output.publishable = true; + assert.equal(productionStatus(state, output), 'approved'); +}); + +test('campaign view and polling rules stay constrained to known workflow phases', () => { + const state = createCampaignState(); + assert.equal(setCampaignView(state, 'timeline'), false); + assert.equal(state.view, 'plan'); + state.document = campaignDocument(); + assert.equal(shouldPoll(state), false); + state.document.run.status = 'running'; + assert.equal(shouldPoll(state), true); + state.document.run.status = 'idle'; + state.document.phase = 'production'; + setCampaignView(state, 'production'); + assert.equal(shouldPoll(state), true); + assert.equal(statusLabel('changes-requested'), 'Agent working'); + assert.equal(statusLabel('unknown'), 'Preparing'); +}); + +test('review payload binds each decision to the current asset and profile digests', () => { + const targets = [ + { id: 'x', assetDigest: 'asset-x', profileHash: 'profile-x' }, + { id: 'youtube-shorts', assetDigest: 'asset-shorts' }, + ]; + assert.deepEqual(buildRunPayload('launch'), { recipeId: 'launch' }); + assert.deepEqual(buildReviewPayload({ + recipeId: 'launch', + targets, + status: 'changes-requested', + note: 'Move the caption up.', + }), { + recipeId: 'launch', + candidates: [ + { target: 'x', assetDigest: 'asset-x', profileHash: 'profile-x' }, + { target: 'youtube-shorts', assetDigest: 'asset-shorts' }, + ], + status: 'changes-requested', + note: 'Move the caption up.', + }); + assert.equal('note' in buildReviewPayload({ + recipeId: 'launch', + targets, + status: 'approved', + note: 'ignored', + }), false); +}); + +test('API requests serialize JSON and surface server errors', async () => { + let request; + const payload = await requestJson('/api/example', { + method: 'POST', + body: { ready: true }, + }, async (url, options) => { + request = { url, options }; + return new Response(JSON.stringify({ ok: true, value: 3 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + assert.deepEqual(payload, { ok: true, value: 3 }); + assert.equal(request.url, '/api/example'); + assert.equal(request.options.body, JSON.stringify({ ready: true })); + assert.equal(request.options.headers['Content-Type'], 'application/json'); + + await assert.rejects(() => requestJson('/api/example', {}, async () => ( + new Response(JSON.stringify({ ok: false, error: 'stale candidate' }), { status: 409 }) + )), /stale candidate/); +}); diff --git a/test/capture-description.test.js b/test/capture-description.test.js index b723198..e163856 100644 --- a/test/capture-description.test.js +++ b/test/capture-description.test.js @@ -134,3 +134,27 @@ test('capture does not delete caller-owned extension directories', async () => { expect(fs.existsSync(extensionDir)).toBe(true); }); + +test('a filtered text recapture retains untouched handoff assets from the full run', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-capture-partial-text-')); + writeProductManifest(cwd); + const config = { + outDir: 'store-assets', + description: { from: 'product.manifest.json', channel: 'chromeWebStore' }, + }; + + await capture(config, { cwd, noBuild: true, log: () => {} }); + const result = await capture(config, { + cwd, + scenes: ['description'], + noBuild: true, + log: () => {}, + }); + + const manifest = JSON.parse(fs.readFileSync(result.manifest, 'utf8')); + expect(manifest.run).toMatchObject({ mode: 'partial', requestedScenes: ['description'] }); + expect(manifest.assets.find((asset) => asset.role === 'store-listing-copy')) + .toMatchObject({ state: 'produced' }); + expect(manifest.assets.find((asset) => asset.role === 'privacy-disclosure')) + .toMatchObject({ state: 'retained' }); +}); diff --git a/test/capture-plan.test.js b/test/capture-plan.test.js new file mode 100644 index 0000000..3ee170a --- /dev/null +++ b/test/capture-plan.test.js @@ -0,0 +1,76 @@ +const path = require('path'); + +const { createCapturePlan, DEFAULT_VIEWPORT } = require('../src/capture-plan'); + +const cwd = path.join(path.sep, 'workspace', 'project'); +const run = async () => {}; + +test('plans filtered story targets and browser work without side effects', () => { + const x = { name: 'demo-x', story: 'demo', target: 'x', run }; + const shorts = { name: 'demo-youtube-shorts', story: 'demo', target: 'youtube-shorts', run }; + const plan = createCapturePlan({ + cwd, + config: { + outDir: 'artifacts', + viewport: { width: 900, height: 700 }, + scenes: [{ name: 'store' }], + promoTiles: [{ name: 'promo' }], + description: { from: 'product.manifest.json' }, + }, + opts: { scenes: ['demo'], targets: ['x'], liveGt: true }, + demoConfigs: [x, shorts], + }); + + expect(plan).toMatchObject({ + cwd, + outDir: path.join(cwd, 'artifacts'), + defaultViewport: { width: 900, height: 700 }, + passFlags: { liveGt: true, freeze: false }, + shouldRunVisualPass: false, + shouldRunTextPass: false, + needsBrowser: true, + partial: true, + requestedDemoConfigs: [x], + selectedDemoConfigs: [x], + }); + expect(plan.wants('demo')).toBe(true); + expect(plan.wants('store')).toBe(false); +}); + +test('plans text-only and no-video runs without Chromium', () => { + const demo = { name: 'demo', run }; + const plan = createCapturePlan({ + cwd, + config: { description: { from: 'STORE_LISTING.md' } }, + opts: { noVideo: true }, + demoConfigs: [demo], + }); + + expect(plan.defaultViewport).toEqual(DEFAULT_VIEWPORT); + expect(plan.shouldRunTextPass).toBe(true); + expect(plan.requestedDemoConfigs).toEqual([demo]); + expect(plan.selectedDemoConfigs).toEqual([]); + expect(plan.needsBrowser).toBe(false); + expect(plan.partial).toBe(true); +}); + +test('preserves usage errors for unknown scenes, targets, and empty intersections', () => { + const demos = [ + { name: 'translate', target: 'x', run }, + { name: 'restore', target: 'youtube-shorts', run }, + ]; + const plan = (opts) => createCapturePlan({ cwd, config: {}, opts, demoConfigs: demos }); + + for (const [opts, message] of [ + [{ scenes: ['missing'] }, 'unknown scene: missing. Known: translate, restore'], + [{ targets: ['missing'] }, 'target not configured: missing. Configured: x, youtube-shorts'], + [{ scenes: ['translate'], targets: ['youtube-shorts'] }, 'no configured demo matches the requested scene and target filters'], + ]) { + try { + plan(opts); + throw new Error('expected createCapturePlan to reject invalid filters'); + } catch (error) { + expect(error).toMatchObject({ message, exitCode: 2 }); + } + } +}); diff --git a/test/capture-static.test.js b/test/capture-static.test.js new file mode 100644 index 0000000..d474f7a --- /dev/null +++ b/test/capture-static.test.js @@ -0,0 +1,59 @@ +jest.mock('../src/caption', () => ({ + compositeCaption: jest.fn(async () => Buffer.from('captioned')), +})); +jest.mock('../src/promo', () => ({ + renderPromoTile: jest.fn(async () => Buffer.from('promo')), +})); + +const { compositeCaption } = require('../src/caption'); +const { captureStaticAssets } = require('../src/capture-static'); +const { renderPromoTile } = require('../src/promo'); + +test('captures selected screenshots and promo tiles while closing every scene page', async () => { + let closed = false; + const page = { + setViewportSize: jest.fn(async () => {}), + screenshot: jest.fn(async () => Buffer.from('shot')), + close: jest.fn(async () => { closed = true; }), + }; + const context = { newPage: jest.fn(async () => page) }; + const sceneRun = jest.fn(async () => {}); + const writeAsset = jest.fn(); + const config = { + disclaimer: 'Unofficial', + scenes: [ + { name: 'selected', viewport: { width: 640, height: 480 }, caption: 'Proof', run: sceneRun }, + { name: 'skipped', run: async () => {} }, + ], + promoTiles: [{ name: 'promo', width: 320, height: 180, template: '
' }], + }; + + await captureStaticAssets({ + config, + context, + extensionId: 'extension-id', + setup: { env: { baseUrl: 'http://127.0.0.1:4321' } }, + passFlags: { freeze: true, liveGt: false }, + wants: (name) => name !== 'skipped', + defaultViewport: { width: 1280, height: 800 }, + bandHeight: 56, + outDir: '/tmp/store-assets', + writeAsset, + }); + + expect(sceneRun).toHaveBeenCalledWith(expect.objectContaining({ + context, + extensionId: 'extension-id', + baseUrl: 'http://127.0.0.1:4321', + flags: { freeze: true, liveGt: false }, + })); + expect(page.setViewportSize).toHaveBeenCalledWith({ width: 640, height: 424 }); + expect(compositeCaption).toHaveBeenCalledWith(expect.objectContaining({ + width: 640, + height: 480, + bandHeight: 56, + })); + expect(renderPromoTile).toHaveBeenCalledWith(expect.objectContaining({ width: 320, height: 180 })); + expect(writeAsset).toHaveBeenCalledTimes(2); + expect(closed).toBe(true); +}); diff --git a/test/capture-success.test.js b/test/capture-success.test.js new file mode 100644 index 0000000..e2f2d66 --- /dev/null +++ b/test/capture-success.test.js @@ -0,0 +1,102 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +jest.mock('../src/launch', () => { + const mockFs = require('fs'); + const events = []; + let closed = false; + const page = { + setViewportSize: jest.fn(async () => {}), + on: jest.fn(), + off: jest.fn(), + evaluate: jest.fn(async () => {}), + waitForTimeout: jest.fn(async () => {}), + video: jest.fn(() => { + events.push('video-handle'); + return { + saveAs: jest.fn(async (out) => { + events.push('video-save'); + mockFs.writeFileSync(out, 'webm'); + }), + }; + }), + close: jest.fn(async () => { + events.push('page-close'); + closed = true; + }), + isClosed: jest.fn(() => closed), + }; + return { + __events: events, + __reset: () => { + events.length = 0; + closed = false; + }, + launchWithExtension: jest.fn(async () => ({ + extensionId: 'test-extension', + context: { + addInitScript: jest.fn(async () => {}), + newPage: jest.fn(async () => page), + }, + })), + closeContext: jest.fn(async () => events.push('context-close')), + }; +}); + +jest.mock('../src/video', () => ({ + ...jest.requireActual('../src/video'), + postProcessDemo: jest.fn(() => []), +})); + +const launch = require('../src/launch'); +const { capture } = require('../src/capture'); + +beforeEach(() => { + jest.clearAllMocks(); + launch.__reset(); +}); + +test('successful demo capture preserves the recording and cleanup order', async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-capture-success-')); + const extensionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shotkit-extension-')); + const demoRun = jest.fn(async () => {}); + + const result = await capture({ + outDir: 'store-assets', + prepareExtension: async () => ({ + dir: extensionDir, + cleanup: async () => launch.__events.push('extension-cleanup'), + }), + setup: async () => ({ + env: { baseUrl: 'http://127.0.0.1:4321' }, + teardown: async () => launch.__events.push('setup-teardown'), + }), + demos: [{ name: 'demo', run: demoRun }], + }, { + cwd, + noBuild: true, + log: () => {}, + }); + + expect(demoRun).toHaveBeenCalledTimes(1); + expect(launch.__events).toEqual([ + 'video-handle', + 'page-close', + 'video-save', + 'context-close', + 'setup-teardown', + 'extension-cleanup', + ]); + expect(result.produced).toContain(path.join(cwd, 'store-assets', 'demo.webm')); + const manifest = JSON.parse(fs.readFileSync(result.manifest, 'utf8')); + expect(manifest.run).toMatchObject({ + mode: 'full', + selectedDemos: ['demo'], + capturedDemos: ['demo'], + skippedDemos: [], + }); + expect(manifest.assets).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: 'source-demo-webm', state: 'produced' }), + ])); +}); diff --git a/test/review-request.test.js b/test/review-request.test.js new file mode 100644 index 0000000..6f4fef8 --- /dev/null +++ b/test/review-request.test.js @@ -0,0 +1,108 @@ +const { + ReviewRequestError, + validateCampaignReview, + validateReviewDecision, + validateSingleReview, +} = require('../src/review-request'); + +const digest = 'a'.repeat(64); +const profileHash = 'b'.repeat(64); + +function expectReviewError(run, status, message) { + expect(run).toThrow(expect.objectContaining({ + name: 'ReviewRequestError', + status, + message, + })); +} + +test('validates shared review status and feedback rules', () => { + expect(() => validateReviewDecision({ status: 'approved' })).not.toThrow(); + expectReviewError( + () => validateReviewDecision({ status: 'changes-requested', note: ' ' }), + 400, + 'review feedback is required when changes are requested', + ); + expectReviewError( + () => validateReviewDecision({ status: 'approved', note: 'x'.repeat(2001) }), + 400, + 'review feedback must be at most 2000 characters', + ); + expect(new ReviewRequestError(409, 'stale')).toMatchObject({ status: 409, message: 'stale' }); +}); + +test('builds an atomic campaign decision only for current reviewable candidates', () => { + const recipe = { + id: 'launch', + story: 'demo', + targets: [{ id: 'x' }, { id: 'youtube-shorts' }], + }; + const current = { + targets: [ + { story: 'demo', target: 'x', reviewable: true, assetDigest: digest, profileHash: null }, + { story: 'demo', target: 'youtube-shorts', reviewable: true, assetDigest: digest, profileHash }, + ], + }; + const body = { + recipeId: 'launch', + status: 'approved', + candidates: [ + { target: 'x', assetDigest: digest }, + { target: 'youtube-shorts', assetDigest: digest, profileHash }, + ], + }; + + expect(validateCampaignReview({ body, recipes: [recipe], current }).decisions).toEqual([ + { story: 'demo', target: 'x', decision: { status: 'approved', assetDigest: digest, note: undefined } }, + { + story: 'demo', + target: 'youtube-shorts', + decision: { status: 'approved', assetDigest: digest, profileHash, note: undefined }, + }, + ]); + expectReviewError( + () => validateCampaignReview({ + body: { ...body, candidates: [body.candidates[0], { ...body.candidates[0] }] }, + recipes: [recipe], + current, + }), + 400, + 'review candidates contain an unavailable or duplicate target', + ); + expectReviewError( + () => validateCampaignReview({ + body: { ...body, candidates: [{ ...body.candidates[0], assetDigest: profileHash }] }, + recipes: [recipe], + current, + }), + 409, + 'review candidate is stale; reload the final media before deciding', + ); +}); + +test('validates one review candidate against the selected final media', () => { + const selected = { + story: 'demo', + target: 'youtube-shorts', + reviewable: true, + assetDigest: digest, + profileHash, + }; + const body = { + story: selected.story, + target: selected.target, + status: 'changes-requested', + note: 'Move the result higher.', + assetDigest: digest, + profileHash, + }; + expect(validateSingleReview({ body, current: { targets: [selected] } })).toEqual({ + selected, + decision: { + status: 'changes-requested', + assetDigest: digest, + profileHash, + note: 'Move the result higher.', + }, + }); +});