mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
/**
|
|
* C196 — the reel shot list: the single source of truth of one tutorial reel.
|
|
* The skill edits this file (natural-language adjustment is translated here)
|
|
* and re-runs the build; the rendered MP4 is never edited.
|
|
*
|
|
* A shot list has a `hook` (graphic), N `capture` scenes (the real site driven
|
|
* in a mobile viewport) and a `cta` (graphic). Capture scenes declare invisible
|
|
* `setup` steps (scroll/prepare, outside the recorded clip) and visible `steps`
|
|
* (paced clicks/typing that the viewer sees), plus the burned caption and the
|
|
* step badge of the design artifact.
|
|
*
|
|
* C197 adds the spoken script: an optional `narration` per scene (capture
|
|
* scenes fall back to the burned caption, graphic scenes are silent without
|
|
* one) and the top-level `coverAlt` of the cover. Both enter the normalized
|
|
* object, so the shot list hash identifies them too.
|
|
*
|
|
* Pure module: no browser, no ffmpeg, no I/O beyond reading the JSON file.
|
|
*/
|
|
|
|
import { readFile } from 'node:fs/promises'
|
|
import { resolve } from 'node:path'
|
|
|
|
import { sha256Hex } from './cli.mjs'
|
|
|
|
const SHOT_LIST_DIR = 'scripts/reels/shot-lists'
|
|
const NARRATION_MAX_LENGTH = 600
|
|
|
|
/**
|
|
* @typedef {{
|
|
* slug: string, title: string, feature: string, route: string,
|
|
* coverAlt: string,
|
|
* fixture: Record<string, unknown>,
|
|
* scenes: Array<{
|
|
* id: string, kind: string,
|
|
* template?: string, durationMs?: number,
|
|
* badge?: { number: number, total: number, label: string },
|
|
* caption?: { parts: Array<{ text: string, accent?: boolean }> } | null,
|
|
* narration?: string | null,
|
|
* setup: Array<{ action: string, selector: string }>,
|
|
* steps: Array<{ action: string, selector: string, value?: string, pauseMs?: number, zoom?: boolean }>,
|
|
* leadInMs: number, trailOutMs: number, captionDelayMs: number, captionDurationMs: number | null,
|
|
* }>,
|
|
* }} ShotList
|
|
*/
|
|
|
|
const SCENE_KINDS = new Set(['capture', 'graphic'])
|
|
const GRAPHIC_TEMPLATES = new Set(['hook', 'cta'])
|
|
const SETUP_ACTIONS = new Set(['scrollIntoView'])
|
|
const STEP_ACTIONS = new Set(['click', 'fill', 'waitFor', 'download'])
|
|
|
|
const fail = (message) => {
|
|
throw new Error(`Shot list: ${message}`)
|
|
}
|
|
|
|
const assertObject = (value, label) => {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
fail(`${label} deve ser um objeto.`)
|
|
}
|
|
}
|
|
|
|
const assertString = (value, label, { trim = true } = {}) => {
|
|
if (typeof value !== 'string' || value.trim() === '') fail(`${label} deve ser string não vazia.`)
|
|
return trim ? value.trim() : value
|
|
}
|
|
|
|
const assertPositiveInt = (value, label, { max = 120000 } = {}) => {
|
|
if (!Number.isInteger(value) || value <= 0 || value > max) {
|
|
fail(`${label} deve ser inteiro entre 1 e ${max}.`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
const assertSelector = (value, label) => {
|
|
const selector = assertString(value, label)
|
|
if (selector.length > 240) fail(`${label} longo demais.`)
|
|
return selector
|
|
}
|
|
|
|
const normalizeCaption = (raw, label) => {
|
|
if (raw === undefined || raw === null) return null
|
|
assertObject(raw, label)
|
|
if (!Array.isArray(raw.parts) || raw.parts.length === 0) fail(`${label}.parts deve ter itens.`)
|
|
const parts = raw.parts.map((part, index) => {
|
|
assertObject(part, `${label}.parts[${index}]`)
|
|
// The authored spacing between parts is part of the caption AND of the
|
|
// narration fallback — validated, never trimmed away.
|
|
const text = assertString(part.text, `${label}.parts[${index}].text`, { trim: false })
|
|
return part.accent === true ? { text, accent: true } : { text }
|
|
})
|
|
return { parts }
|
|
}
|
|
|
|
const normalizeNarration = (raw, label) => {
|
|
if (raw === undefined || raw === null) return null
|
|
const text = assertString(raw, label)
|
|
if (text.length > NARRATION_MAX_LENGTH) {
|
|
fail(`${label} excede ${NARRATION_MAX_LENGTH} caracteres.`)
|
|
}
|
|
return text
|
|
}
|
|
|
|
const normalizeBadge = (raw, label) => {
|
|
assertObject(raw, label)
|
|
const number = assertPositiveInt(raw.number, `${label}.number`, { max: 99 })
|
|
const total = assertPositiveInt(raw.total, `${label}.total`, { max: 99 })
|
|
if (number > total) fail(`${label}.number não pode ser maior que total.`)
|
|
const labelText = assertString(raw.label, `${label}.label`)
|
|
return { number, total, label: labelText }
|
|
}
|
|
|
|
const normalizeSetup = (raw, label) => {
|
|
if (raw === undefined) return []
|
|
if (!Array.isArray(raw)) fail(`${label} deve ser uma lista.`)
|
|
return raw.map((step, index) => {
|
|
assertObject(step, `${label}[${index}]`)
|
|
const action = assertString(step.action, `${label}[${index}].action`)
|
|
if (!SETUP_ACTIONS.has(action)) {
|
|
fail(`${label}[${index}].action "${action}" não é um setup válido (${[...SETUP_ACTIONS]}).`)
|
|
}
|
|
return { action, selector: assertSelector(step.selector, `${label}[${index}].selector`) }
|
|
})
|
|
}
|
|
|
|
const normalizeSteps = (raw, label) => {
|
|
if (!Array.isArray(raw) || raw.length === 0) fail(`${label} deve ter ao menos um passo.`)
|
|
const allowedKeys = new Set(['action', 'selector', 'value', 'pauseMs', 'zoom'])
|
|
return raw.map((step, index) => {
|
|
assertObject(step, `${label}[${index}]`)
|
|
for (const key of Object.keys(step)) {
|
|
if (!allowedKeys.has(key)) fail(`${label}[${index}].${key} não é uma chave válida.`)
|
|
}
|
|
const action = assertString(step.action, `${label}[${index}].action`)
|
|
if (!STEP_ACTIONS.has(action)) {
|
|
fail(`${label}[${index}].action "${action}" inválida (${[...STEP_ACTIONS]}).`)
|
|
}
|
|
const normalized = {
|
|
action,
|
|
selector: assertSelector(step.selector, `${label}[${index}].selector`),
|
|
}
|
|
if (action === 'fill' && step.value !== undefined) {
|
|
normalized.value = assertString(step.value, `${label}[${index}].value`)
|
|
}
|
|
if (step.zoom !== undefined) {
|
|
if (typeof step.zoom !== 'boolean') fail(`${label}[${index}].zoom deve ser boolean.`)
|
|
normalized.zoom = step.zoom
|
|
}
|
|
if (step.pauseMs !== undefined) {
|
|
normalized.pauseMs = assertPositiveInt(step.pauseMs, `${label}[${index}].pauseMs`, {
|
|
max: 10000,
|
|
})
|
|
}
|
|
return normalized
|
|
})
|
|
}
|
|
|
|
const normalizeCaptureScene = (scene, index) => {
|
|
const label = `scenes[${index}]`
|
|
const id = assertString(scene.id, `${label}.id`)
|
|
const normalized = {
|
|
id,
|
|
kind: 'capture',
|
|
badge: normalizeBadge(scene.badge, `${label}.badge`),
|
|
caption: normalizeCaption(scene.caption, `${label}.caption`),
|
|
narration: normalizeNarration(scene.narration, `${label}.narration`),
|
|
setup: normalizeSetup(scene.setup, `${label}.setup`),
|
|
steps: normalizeSteps(scene.steps, `${label}.steps`),
|
|
leadInMs:
|
|
scene.leadInMs === undefined
|
|
? 700
|
|
: assertPositiveInt(scene.leadInMs, `${label}.leadInMs`, { max: 10000 }),
|
|
trailOutMs:
|
|
scene.trailOutMs === undefined
|
|
? 800
|
|
: assertPositiveInt(scene.trailOutMs, `${label}.trailOutMs`, { max: 10000 }),
|
|
captionDelayMs:
|
|
scene.captionDelayMs === undefined
|
|
? 250
|
|
: assertPositiveInt(scene.captionDelayMs, `${label}.captionDelayMs`, { max: 10000 }),
|
|
captionDurationMs:
|
|
scene.captionDurationMs === undefined
|
|
? null
|
|
: assertPositiveInt(scene.captionDurationMs, `${label}.captionDurationMs`, { max: 30000 }),
|
|
}
|
|
if (normalized.caption === null) fail(`${label}.caption é obrigatória em cena de captura.`)
|
|
return normalized
|
|
}
|
|
|
|
const normalizeGraphicScene = (scene, index) => {
|
|
const label = `scenes[${index}]`
|
|
const template = assertString(scene.template, `${label}.template`)
|
|
if (!GRAPHIC_TEMPLATES.has(template)) {
|
|
fail(`${label}.template "${template}" inválido (${[...GRAPHIC_TEMPLATES]}).`)
|
|
}
|
|
return {
|
|
id: assertString(scene.id, `${label}.id`),
|
|
kind: 'graphic',
|
|
template,
|
|
durationMs: assertPositiveInt(scene.durationMs, `${label}.durationMs`, { max: 15000 }),
|
|
narration: normalizeNarration(scene.narration, `${label}.narration`),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates and normalizes the raw JSON of a shot list (pure).
|
|
*
|
|
* @param {Record<string, unknown>} raw
|
|
* @returns {ShotList}
|
|
*/
|
|
export const normalizeShotList = (raw) => {
|
|
assertObject(raw, 'raiz')
|
|
const slug = assertString(raw.slug, 'slug')
|
|
if (!/^[a-z0-9-]+$/.test(slug)) fail('slug deve ser kebab-case (a-z, 0-9, -).')
|
|
const route = assertString(raw.route ?? '/', 'route')
|
|
if (!route.startsWith('/')) fail('route deve começar com "/".')
|
|
assertObject(raw.fixture ?? {}, 'fixture')
|
|
const fixture = raw.fixture ?? {}
|
|
if (!Array.isArray(raw.scenes) || raw.scenes.length === 0)
|
|
fail('scenes deve ter ao menos uma cena.')
|
|
const scenes = raw.scenes.map((scene, index) => {
|
|
assertObject(scene, `scenes[${index}]`)
|
|
const kind = assertString(scene.kind, `scenes[${index}].kind`)
|
|
if (!SCENE_KINDS.has(kind)) fail(`scenes[${index}].kind "${kind}" inválido.`)
|
|
return kind === 'capture'
|
|
? normalizeCaptureScene(scene, index)
|
|
: normalizeGraphicScene(scene, index)
|
|
})
|
|
const ids = new Set(scenes.map((scene) => scene.id))
|
|
if (ids.size !== scenes.length) fail('ids de cena precisam ser únicos.')
|
|
const usesFixtureName = scenes.some(
|
|
(scene) =>
|
|
scene.kind === 'capture' &&
|
|
scene.steps.some((step) => step.action === 'fill' && step.value === undefined),
|
|
)
|
|
if (usesFixtureName && typeof fixture.cardName !== 'string') {
|
|
fail('fixture.cardName é obrigatório quando um passo fill não declara value.')
|
|
}
|
|
if (scenes[0].kind !== 'graphic' || scenes[0].template !== 'hook')
|
|
fail('a primeira cena deve ser o hook.')
|
|
const last = scenes[scenes.length - 1]
|
|
if (last.kind !== 'graphic' || last.template !== 'cta') fail('a última cena deve ser o CTA.')
|
|
const title = assertString(raw.title ?? slug, 'title')
|
|
return {
|
|
slug,
|
|
title,
|
|
feature: assertString(raw.feature ?? slug, 'feature'),
|
|
route,
|
|
coverAlt:
|
|
raw.coverAlt === undefined || raw.coverAlt === null
|
|
? title
|
|
: assertString(raw.coverAlt, 'coverAlt'),
|
|
fixture,
|
|
scenes,
|
|
}
|
|
}
|
|
|
|
/** Deterministic sha256 of the normalized shot list — the reel version. */
|
|
export const shotListHash = (shotList) => sha256Hex(Buffer.from(JSON.stringify(shotList), 'utf8'))
|
|
|
|
const shotListPath = ({ slug, root = process.cwd() }) =>
|
|
resolve(root, SHOT_LIST_DIR, `${slug}.json`)
|
|
|
|
/**
|
|
* Reads + normalizes a shot list; returns it with its hash.
|
|
*
|
|
* @param {{ slug: string, root?: string }} options
|
|
* @returns {Promise<{ shotList: ShotList, hash: string }>}
|
|
*/
|
|
export const loadShotList = async ({ slug, root = process.cwd() }) => {
|
|
const path = shotListPath({ slug, root })
|
|
let raw
|
|
try {
|
|
raw = JSON.parse(await readFile(path, 'utf8'))
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT')
|
|
fail(`não encontrei ${path}. Rode a skill a partir da raiz do repo.`)
|
|
throw new Error(`Shot list inválido em ${path}: ${error.message}`)
|
|
}
|
|
const shotList = normalizeShotList(raw)
|
|
if (shotList.slug !== slug) fail(`slug do arquivo ("${shotList.slug}") difere de "${slug}".`)
|
|
return { shotList, hash: shotListHash(shotList) }
|
|
}
|
|
|
|
export const captureScenes = (shotList) =>
|
|
shotList.scenes.filter((scene) => scene.kind === 'capture')
|
|
export const graphicScenes = (shotList) =>
|
|
shotList.scenes.filter((scene) => scene.kind === 'graphic')
|