From b5dd93e1ba914b40084ddfffee04480210fc10ff Mon Sep 17 00:00:00 2001 From: johnf Date: Sat, 24 Oct 2026 01:04:54 -0400 Subject: [PATCH] A reels studio: agents run a faceless TikTok, Instagram and YouTube Shorts page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A crew of four workers (scout, writer, editor, analyst) runs each series through the office-reels command and MCP server; the studio voices the scripts, makes an image a scene, burns in captions timed to the voice, renders with ffmpeg and posts approved reels on each series' schedule, then reads the numbers back for the analyst. Free by default (Kokoro reads the narration locally, Pollinations draws the images), with ElevenLabs, OpenAI and fal taking over once their keys are set. The /reels page (☰ → Reels studio) has the series, a three-step "create a series" flow, a video grid per series, one-at-a-time review, the crew, analytics and accounts. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + bin/office-reels.js | 441 +++++++++++++++++++++++++++++++ bin/reels-kokoro.py | 22 ++ docs/reels.md | 136 ++++++++++ src/client/features/hud/index.ts | 2 + src/client/reels.html | 19 ++ src/client/reels/api.ts | 140 ++++++++++ src/client/reels/create.ts | 190 +++++++++++++ src/client/reels/home.ts | 84 ++++++ src/client/reels/main.ts | 162 ++++++++++++ src/client/reels/modal.ts | 105 ++++++++ src/client/reels/presets.ts | 67 +++++ src/client/reels/reels.css | 350 ++++++++++++++++++++++++ src/client/reels/review.ts | 102 +++++++ src/client/reels/series.ts | 136 ++++++++++ src/client/reels/views.ts | 119 +++++++++ src/server/hooks/server.ts | 5 +- src/server/http/routes/index.ts | 3 + src/server/http/routes/reels.ts | 106 ++++++++ src/server/office/timers.ts | 6 +- src/server/reels/accounts.ts | 74 ++++++ src/server/reels/captions.ts | 120 +++++++++ src/server/reels/crew.ts | 64 +++++ src/server/reels/endpoint.ts | 85 ++++++ src/server/reels/hire.ts | 58 ++++ src/server/reels/index.ts | 38 +++ src/server/reels/media.ts | 222 ++++++++++++++++ src/server/reels/pipeline.ts | 82 ++++++ src/server/reels/publish.ts | 236 +++++++++++++++++ src/server/reels/render.ts | 109 ++++++++ src/server/reels/schedule.ts | 70 +++++ src/server/reels/store.ts | 193 ++++++++++++++ src/server/reels/studio.ts | 346 ++++++++++++++++++++++++ src/server/reels/types.ts | 125 +++++++++ src/server/workers/process.ts | 4 +- tests/reels.test.ts | 305 +++++++++++++++++++++ vite.config.ts | 1 + 37 files changed, 4326 insertions(+), 4 deletions(-) create mode 100644 bin/office-reels.js create mode 100644 bin/reels-kokoro.py create mode 100644 docs/reels.md create mode 100644 src/client/reels.html create mode 100644 src/client/reels/api.ts create mode 100644 src/client/reels/create.ts create mode 100644 src/client/reels/home.ts create mode 100644 src/client/reels/main.ts create mode 100644 src/client/reels/modal.ts create mode 100644 src/client/reels/presets.ts create mode 100644 src/client/reels/reels.css create mode 100644 src/client/reels/review.ts create mode 100644 src/client/reels/series.ts create mode 100644 src/client/reels/views.ts create mode 100644 src/server/http/routes/reels.ts create mode 100644 src/server/reels/accounts.ts create mode 100644 src/server/reels/captions.ts create mode 100644 src/server/reels/crew.ts create mode 100644 src/server/reels/endpoint.ts create mode 100644 src/server/reels/hire.ts create mode 100644 src/server/reels/index.ts create mode 100644 src/server/reels/media.ts create mode 100644 src/server/reels/pipeline.ts create mode 100644 src/server/reels/publish.ts create mode 100644 src/server/reels/render.ts create mode 100644 src/server/reels/schedule.ts create mode 100644 src/server/reels/store.ts create mode 100644 src/server/reels/studio.ts create mode 100644 src/server/reels/types.ts create mode 100644 tests/reels.test.ts diff --git a/README.md b/README.md index fac9441a5..99792346f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **Other maps.** Turn the whole building into a castle: sit on a throne of iron blades while your workers line up before you when they're done, send new ones off through the Hand of the King, and watch their beards grow long and grey as they toil. Send one home and the Kingsguard runs up from the dungeon, marches it down the stairs and throws it in a cell, where it starves, dies and rots down to a skeleton. Or into a space station in orbit, the Earth turning outside its windows: you run it from the captain's chair on the bridge, and a worker sent home is marched to the airlock and blown out into space, to drift off past the observation windows with everyone who went before it. Or make a map of your own, with its own way of seeing workers off in JSON ([docs/maps.md](docs/maps.md)). +- **A reels studio.** Hire a crew of agents (a scout, a writer, an editor and an analyst) to run a faceless short-video page: they come up with the stories and write the scripts, the office voices them, makes the images, renders the videos with captions and posts the approved ones to TikTok, Instagram and YouTube on a schedule, and the analyst reads the numbers back to the crew ([docs/reels.md](docs/reels.md)). + There's a lot more (a rooftop bar, an office dog, an arcade, supercars in the garage to drive round a scenic loop past a farm, pines, mountains and a beach): see [docs/features.md](docs/features.md). ## Requirements @@ -404,6 +406,7 @@ Every change to the app that lands on `main` is published as a GitHub release by - [Features](docs/features.md): everything in the office, room by room - [Agents](docs/agents.md): Claude Code, Codex and OpenCode, models and effort, and the office's prompts +- [Reels studio](docs/reels.md): a crew of agents running a faceless TikTok, Instagram and YouTube Shorts page, from ideas to posts and numbers - [Configuration](docs/configuration.md): every command-line option, and where the office keeps its data - [Maps](docs/maps.md): the castle, the space station, and making a map of your own - [Workers' servers on your own computer](docs/tunnel.md): `agent-office tunnel`, which opens every worker's web server on your computer by itself diff --git a/bin/office-reels.js b/bin/office-reels.js new file mode 100644 index 000000000..c2d3d14d1 --- /dev/null +++ b/bin/office-reels.js @@ -0,0 +1,441 @@ +#!/usr/bin/env node +// office-reels: the office's reels studio, from inside Agent Office. The crew running a faceless +// short-video page (a scout, a writer, an editor and an analyst, each an ordinary worker) adds +// ideas, writes scripts, approves the videos the studio makes and reads the numbers with it; the +// studio makes the videos and posts them (src/server/reels/). The office puts it on every worker's +// PATH with AGENT_OFFICE_HOOK_URL, AGENT_OFFICE_WORKER_ID and AGENT_OFFICE_HOOK_TOKEN, and this talks +// to /office/reels with them. `office-reels mcp` is the same as an MCP server on stdio. Plain Node, +// no build step, no dependencies. + +import { realpathSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +const USAGE = `Usage: + office-reels status [--series ] [--json] the series, the reels on the go, the crew, what's set up + office-reels show [--json] one reel in full: its script, caption, video, posts + office-reels series add [fields] start a series (or its fields as JSON on stdin) + office-reels series set [fields] change one (--pause / --resume to stop and start it) + fields: --name --niche --style --voice --tone --music --platforms tiktok,instagram,youtube + --postsPerDay --backlog --cta --hashtags a,b --tiktokPrivacy --youtubePrivacy + office-reels idea --title "…" [--hook "…"] [--idea "…"] + office-reels script <<'EOF' a script for an idea (or a rejected reel), as JSON: + {"title": "…", "hook": "…", "scenes": [{"narration": "…", "visual": "…"}], "caption": "…", "hashtags": []} + EOF (script --series makes a new reel from it) + office-reels review --approve approve a rendered reel for posting + office-reels review --reject --note "what to fix" + office-reels remake make its video again + office-reels post post an approved reel now, outside its schedule + office-reels stats [--json] fetch every post's views, likes, comments, shares + office-reels note "…" write down what the numbers taught (or on stdin) + office-reels crew hire [--series ] [--roles scout,writer,editor,analyst] + [--provider

] [--model ] [--effort ] + hire the crew: a worker for each role nobody has + office-reels crew join [--series ] join the crew yourself, in a role + office-reels brief [--series ] what a crew member in a role is told + office-reels mcp serve these as MCP tools on stdio`; + +export class UsageError extends Error {} + +const ENV = ['AGENT_OFFICE_HOOK_URL', 'AGENT_OFFICE_WORKER_ID', 'AGENT_OFFICE_HOOK_TOKEN']; +export const ROLES = ['scout', 'writer', 'editor', 'analyst']; +const RETRY_MS = 6000; +/** Posting waits for each platform to process the video; the numbers ask every platform. */ +const TIMEOUT_MS = { get: 15_000, post: 20 * 60_000, stats: 120_000, crew: 90_000, other: 30_000 }; +const SERIES_FIELDS = ['--name', '--niche', '--style', '--voice', '--tone', '--music', '--platforms', '--postsPerDay', '--backlog', '--cta', '--hashtags', '--tiktokPrivacy', '--youtubePrivacy']; + +function options(args, valued, bare) { + /** @type {Record} */ + const opts = {}; + const words = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg.startsWith('--')) { + if (arg.startsWith('-') && arg.length > 1) throw new UsageError(`Unknown option: ${arg}`); + words.push(arg); + continue; + } + const eq = arg.indexOf('='); + const flag = eq > 0 ? arg.slice(0, eq) : arg; + if (bare.includes(flag)) { + if (eq > 0) throw new UsageError(`${flag} takes no value`); + opts[flag] = true; + } else if (valued.includes(flag)) { + if (eq > 0) opts[flag] = arg.slice(eq + 1); + else if (i + 1 < args.length) opts[flag] = args[++i]; + else throw new UsageError(`${flag} needs a value`); + } else throw new UsageError(`Unknown option: ${flag}`); + } + return { opts, words }; +} + +/** The flags a series takes, as the fields the studio reads. */ +function seriesFields(opts) { + const out = {}; + for (const f of SERIES_FIELDS) if (opts[f] !== undefined) out[f.slice(2)] = opts[f]; + for (const k of ['platforms', 'hashtags']) if (typeof out[k] === 'string') out[k] = out[k].split(/[\s,]+/).filter(Boolean); + for (const k of ['postsPerDay', 'backlog']) if (out[k] !== undefined) out[k] = Number(out[k]); + if (opts['--pause']) out.paused = true; + if (opts['--resume']) out.paused = false; + return out; +} + +const one = (words, what) => { + if (words.length !== 1) throw new UsageError(`Give one ${what}`); + return words[0]; +}; + +/** + * What the command line asks for: the call to make (`method`, `path`, `body`), and how to show it. + * `stdin` says the body (or a field of it) comes on stdin. + * @param {string[]} argv + */ +export function parseArgs(argv) { + const [cmd, ...rest] = argv; + const help = (a) => a === '-h' || a === '--help'; + if (cmd === undefined || cmd === 'help' || help(cmd) || rest.some(help)) return { cmd: 'help' }; + if (cmd === 'mcp') return { cmd: 'mcp' }; + if (cmd === 'status' || cmd === 'ls') { + const { opts, words } = options(rest, ['--series'], ['--json']); + if (words.length) throw new UsageError('status takes no arguments'); + return { cmd, method: 'GET', path: '', json: !!opts['--json'], ...(opts['--series'] ? { series: opts['--series'] } : {}) }; + } + if (cmd === 'show') { + const { opts, words } = options(rest, [], ['--json']); + return { cmd, method: 'GET', path: '/reel', query: { id: one(words, 'reel id') }, json: !!opts['--json'] }; + } + if (cmd === 'brief') { + const { opts, words } = options(rest, ['--series'], []); + const role = one(words, `role (${ROLES.join(', ')})`); + return { cmd, method: 'GET', path: '/brief', query: { role, ...(opts['--series'] ? { series: opts['--series'] } : {}) } }; + } + if (cmd === 'series') { + const [sub, ...more] = rest; + const { opts, words } = options(more, SERIES_FIELDS, ['--pause', '--resume']); + if (sub === 'add') { + if (words.length) throw new UsageError('series add takes its fields as flags or JSON on stdin'); + const body = seriesFields(opts); + return { cmd, method: 'POST', path: '/series', body, stdin: Object.keys(body).length ? undefined : 'json' }; + } + if (sub === 'set') return { cmd, method: 'POST', path: '/series/set', body: { series: one(words, 'series'), ...seriesFields(opts) } }; + throw new UsageError('series add, or series set '); + } + if (cmd === 'idea') { + const { opts, words } = options(rest, ['--title', '--hook', '--idea'], []); + if (!opts['--title']) throw new UsageError('An idea needs --title'); + return { cmd, method: 'POST', path: '/idea', body: { series: one(words, 'series'), title: opts['--title'], hook: opts['--hook'], idea: opts['--idea'] } }; + } + if (cmd === 'script') { + const { opts, words } = options(rest, ['--series'], []); + if (opts['--series'] && words.length) throw new UsageError('Give a reel, or --series for a new one, not both'); + const target = opts['--series'] ? { series: opts['--series'] } : { reel: one(words, 'reel id (or --series for a new reel)') }; + return { cmd, method: 'POST', path: '/script', body: target, stdin: 'json' }; + } + if (cmd === 'review') { + const { opts, words } = options(rest, ['--note'], ['--approve', '--reject']); + if (!!opts['--approve'] === !!opts['--reject']) throw new UsageError('review takes --approve or --reject'); + return { cmd, method: 'POST', path: '/review', body: { reel: one(words, 'reel id'), approve: !!opts['--approve'], note: opts['--note'] ?? '' } }; + } + if (cmd === 'remake' || cmd === 'post') { + const { words } = options(rest, [], []); + return { cmd, method: 'POST', path: `/${cmd}`, body: { reel: one(words, 'reel id') } }; + } + if (cmd === 'stats') { + const { opts, words } = options(rest, [], ['--json']); + if (words.length) throw new UsageError('stats takes no arguments'); + return { cmd, method: 'POST', path: '/stats', body: {}, json: !!opts['--json'] }; + } + if (cmd === 'note') { + const { words } = options(rest, [], []); + if (!words.length || words.length > 2) throw new UsageError('note "…" (or the note on stdin)'); + return { cmd, method: 'POST', path: '/note', body: { series: words[0], ...(words[1] ? { text: words[1] } : {}) }, stdin: words[1] ? undefined : 'text' }; + } + if (cmd === 'crew') { + const [sub, ...more] = rest; + const { opts, words } = options(more, ['--series', '--roles', '--provider', '--model', '--effort'], []); + const series = opts['--series'] ? { series: opts['--series'] } : {}; + if (sub === 'join') return { cmd, method: 'POST', path: '/crew/join', body: { role: one(words, `role (${ROLES.join(', ')})`), ...series } }; + if (sub === 'hire') { + if (words.length) throw new UsageError('crew hire takes only options'); + const roles = opts['--roles'] ? String(opts['--roles']).split(/[\s,]+/).filter(Boolean) : undefined; + const bad = roles?.find((r) => !ROLES.includes(r)); + if (bad) throw new UsageError(`roles are ${ROLES.join(', ')} (got ${bad})`); + const pick = Object.fromEntries(['provider', 'model', 'effort'].filter((k) => opts[`--${k}`]).map((k) => [k, opts[`--${k}`]])); + return { cmd, method: 'POST', path: '/crew', body: { ...series, ...(roles ? { roles } : {}), ...pick } }; + } + throw new UsageError('crew hire, or crew join '); + } + throw new UsageError(`Unknown command: ${cmd}`); +} + +export function officeEnv(env) { + const missing = ENV.filter((k) => !env[k]); + if (missing.length) throw new Error(`${missing.join(', ')} ${missing.length === 1 ? "isn't" : "aren't"} set. office-reels only works inside Agent Office, from a worker's terminal.`); + return { url: env.AGENT_OFFICE_HOOK_URL.replace(/\/+$/, ''), worker: env.AGENT_OFFICE_WORKER_ID, token: env.AGENT_OFFICE_HOOK_TOKEN }; +} + +/** The HTTP request for a call to the studio. */ +export function buildRequest(call, office) { + const url = new URL(`${office.url}/office/reels${call.path}`); + url.searchParams.set('worker', office.worker); + for (const [k, v] of Object.entries(call.query ?? {})) url.searchParams.set(k, String(v)); + const headers = { authorization: `Bearer ${office.token}` }; + const timeout = call.method === 'GET' ? TIMEOUT_MS.get : TIMEOUT_MS[call.path.slice(1)] ?? TIMEOUT_MS.other; + if (call.method === 'GET') return { method: 'GET', url: url.href, headers, timeout }; + return { method: 'POST', url: url.href, headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(call.body ?? {}), timeout }; +} + +async function send(req, fetchImpl) { + const until = Date.now() + RETRY_MS; + for (;;) { + try { + const res = await fetchImpl(req.url, { method: req.method, headers: req.headers, body: req.body, signal: AbortSignal.timeout(req.timeout) }); + const text = await res.text(); + let body; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { error: text.slice(0, 300) }; + } + return { status: res.status, body }; + } catch (err) { + const code = err?.cause?.code ?? err?.code; + if (code === 'ECONNREFUSED' && Date.now() < until) { + await new Promise((r) => setTimeout(r, 1000)); + continue; + } + throw new Error(`Couldn't reach the office at ${new URL(req.url).origin} (${code ?? err?.message ?? err}). Is it running?`); + } + } +} + +export async function call(c, io) { + const res = await send(buildRequest(c, officeEnv(io.env)), io.fetch); + if (res.status === 404 && !res.body?.error) throw new Error("The office doesn't know office-reels (404): it's running an older Agent Office. Restart or upgrade it."); + if (res.status < 200 || res.status >= 300) throw new Error(res.body?.error || `The office said no (${res.status}).`); + return res.body; +} + +const ago = (t) => { + if (!t) return 'never'; + const m = Math.round((Date.now() - t) / 60000); + return m < 60 ? `${m}m ago` : m < 2880 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`; +}; + +/** How everything stands, for people. */ +export function formatStatus(v, only) { + const lines = []; + const media = v.media ?? {}; + lines.push(`Voice: ${media.voice ?? '?'} · images: ${media.images ?? '?'} · connected: ${v.connected?.length ? v.connected.join(', ') : 'nothing yet (see docs/reels.md)'}`); + if (v.you?.crew?.length) lines.push(`You (${v.you.name}) are the ${v.you.crew.join(' and ')}.`); + if (!v.series?.length) lines.push('No series yet: office-reels series add --name "…" --niche "…" --platforms tiktok,youtube'); + for (const s of v.series ?? []) { + if (only && s.id !== only && s.name.toLowerCase() !== String(only).toLowerCase()) continue; + const p = s.pipeline ?? {}; + lines.push('', `▶ ${s.name} (${s.id})${s.paused ? ' · PAUSED' : ''} · ${s.platforms.join(', ') || 'no platforms'} · ${s.postsPerDay}/day · last post ${ago(s.lastPost)}`); + lines.push(` ${s.niche}`); + lines.push(` ideas ${p.ideas} · in the pipeline ${p.inPipe} (backlog ${s.backlog}) · to review ${p.toReview} · approved ${p.approved} · posted ${p.posted}${p.failed ? ` · failed ${p.failed}` : ''}`); + for (const n of s.notes ?? []) lines.push(` 📝 ${n.text} — ${n.by}`); + } + const reels = (v.reels ?? []).filter((r) => !only || r.series === only || v.series?.find((s) => s.id === r.series)?.name.toLowerCase() === String(only).toLowerCase()); + if (reels.length) lines.push('', 'Reels:'); + for (const r of reels) { + const posts = (r.posts ?? []).map((p) => `${p.platform}${p.error ? ' ✗' : p.views !== undefined ? ` ${p.views} views` : ' ✓'}`).join(', '); + lines.push(` ${r.id} [${r.status}] ${r.title}${r.seconds ? ` · ${r.seconds}s` : ''} · ${r.by}${posts ? ` · ${posts}` : ''}${r.review ? ` · editor: ${r.review}` : ''}${r.error ? ` · ${r.error}` : ''}`); + } + if (v.crew?.length) lines.push('', `Crew: ${v.crew.map((c) => `${c.name} (${c.role}${c.seriesId ? ` on ${c.seriesId}` : ''}, ${c.status})`).join(', ')}`); + else lines.push('', 'No crew yet: office-reels crew hire'); + return lines.join('\n'); +} + +/** What a POST did, in a line or two. */ +export function formatResult(cmd, r) { + if (cmd === 'crew' && r.hired) return [...r.hired.map((h) => `✓ hired ${h.name} as ${h.role} (${h.id})`), ...(r.skipped ?? []).map((s) => `– ${s}`)].join('\n') || 'Nobody hired.'; + if (cmd === 'crew' && r.brief) return `✓ You're on the crew. Your brief:\n\n${r.brief}`; + if (cmd === 'series') return `✓ ${r.name} (${r.id}): ${r.platforms?.join(', ') || 'no platforms'}, ${r.postsPerDay}/day${r.paused ? ', paused' : ''}`; + if (cmd === 'note') return `✓ Noted on ${r.name}.`; + if (cmd === 'stats' && Array.isArray(r)) return r.map((x) => `${x.id} ${x.title}: ${Object.entries(x.posts).map(([p, s]) => `${p} ${s.stats?.views ?? '?'} views, ${s.stats?.likes ?? '?'} likes${s.error ? ` (${s.error})` : ''}`).join(' · ')}`).join('\n'); + if (r?.id && r?.status) return `✓ ${r.id} [${r.status}] ${r.title}${r.review ? ` · ${r.review}` : ''}${Object.keys(r.posts ?? {}).length ? ` · ${Object.entries(r.posts).map(([p, s]) => `${p}: ${s.url ?? s.error ?? 'posted'}`).join(' · ')}` : ''}`; + return JSON.stringify(r, null, 2); +} + +// --- MCP ------------------------------------------------------------------------------------------ + +const MCP_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']; +const obj = (properties, required = []) => ({ type: 'object', properties, required, additionalProperties: false }); +const S = (description) => ({ type: 'string', description }); + +export const TOOLS = [ + { name: 'reels_status', description: 'How the reels studio stands: each series (niche, platforms, schedule, its pipeline counts and the analyst\'s notes), every reel on the go with its id and status, the crew, and which voice/image services and platforms are set up.', inputSchema: obj({}), annotations: { readOnlyHint: true } }, + { name: 'reels_show', description: 'One reel in full: its script (scenes), caption, video file, review and posts.', inputSchema: obj({ reel: S('The reel id.') }, ['reel']), annotations: { readOnlyHint: true } }, + { name: 'reels_add_idea', description: 'Adds an idea for a reel to a series (the scout\'s job).', inputSchema: obj({ series: S('Series id or name.'), title: S('One specific story.'), hook: S('The first line that stops the scroll.'), idea: S('What it is about, for the writer.') }, ['series', 'title']) }, + { + name: 'reels_script', + description: 'Sends the script for an idea or a rejected reel (reel), or makes a new reel from it (series). 6-12 scenes, 110-170 words of narration in all; each visual is an image prompt without text or the art style.', + inputSchema: obj({ + reel: S('The reel to script.'), + series: S('Instead of reel: the series to make a new reel in.'), + title: S('Its title.'), + hook: S('The first line.'), + scenes: { type: 'array', items: obj({ narration: S('What the narrator says.'), visual: S('The image prompt for it.') }, ['narration', 'visual']) }, + caption: S('What it posts with.'), + hashtags: { type: 'array', items: { type: 'string' } }, + }, ['scenes', 'caption']), + }, + { name: 'reels_review', description: 'Approves a rendered reel for posting, or rejects it with a note on what to fix (back to the writer). You cannot approve your own script.', inputSchema: obj({ reel: S('The reel id.'), approve: { type: 'boolean' }, note: S('What to fix (needed to reject).') }, ['reel', 'approve']) }, + { name: 'reels_remake', description: 'Makes a reel\'s video again (after its series\' music or style changed).', inputSchema: obj({ reel: S('The reel id.') }, ['reel']) }, + { name: 'reels_post', description: 'Posts an approved reel now, outside its schedule. Only when a person asks.', inputSchema: obj({ reel: S('The reel id.') }, ['reel']), annotations: { destructiveHint: false, openWorldHint: true } }, + { name: 'reels_stats', description: 'Fetches views, likes, comments and shares for every post.', inputSchema: obj({}), annotations: { openWorldHint: true } }, + { name: 'reels_note', description: 'Writes down a lesson from the numbers on a series, for the writer and scout.', inputSchema: obj({ series: S('Series id or name.'), text: S('One specific, actionable lesson.') }, ['series', 'text']) }, + { + name: 'reels_series', + description: 'Starts a series (no series given) or changes one: its niche, art style, voice, tone, music file, platforms, posts a day, backlog, call to action, hashtags, privacy, paused.', + inputSchema: obj({ + series: S('The series to change; leave out to start one.'), + name: S('Its name.'), niche: S('What its reels are about.'), style: S('The art style of every image.'), voice: S('The narrator\'s voice.'), tone: S('How the narrator reads.'), music: S('A track\'s file name in the studio\'s music folder.'), + platforms: { type: 'array', items: { type: 'string', enum: ['tiktok', 'instagram', 'youtube'] } }, + postsPerDay: { type: 'number' }, backlog: { type: 'integer' }, cta: S('Ends every caption.'), hashtags: { type: 'array', items: { type: 'string' } }, + tiktokPrivacy: { type: 'string', enum: ['SELF_ONLY', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'PUBLIC_TO_EVERYONE'] }, youtubePrivacy: { type: 'string', enum: ['private', 'unlisted', 'public'] }, paused: { type: 'boolean' }, + }), + }, + { name: 'reels_join_crew', description: 'Joins the reels crew yourself, in a role, and returns your brief.', inputSchema: obj({ role: { type: 'string', enum: ROLES }, series: S('The one series to work on; all of them when left out.') }, ['role']) }, + { name: 'reels_hire_crew', description: 'Hires a worker for each crew role (scout, writer, editor, analyst) nobody has yet.', inputSchema: obj({ series: S('The one series they work on.'), roles: { type: 'array', items: { type: 'string', enum: ROLES } }, provider: S('Which agent runs them.'), model: S('A model.'), effort: S('Reasoning effort.') }) }, +]; + +/** A tool's arguments as a call to the studio. */ +export function toolCall(name, a) { + switch (name) { + case 'reels_status': return { cmd: 'status', method: 'GET', path: '' }; + case 'reels_show': return { cmd: 'show', method: 'GET', path: '/reel', query: { id: a.reel } }; + case 'reels_add_idea': return { cmd: 'idea', method: 'POST', path: '/idea', body: a }; + case 'reels_script': return { cmd: 'script', method: 'POST', path: '/script', body: a }; + case 'reels_review': return { cmd: 'review', method: 'POST', path: '/review', body: a }; + case 'reels_remake': return { cmd: 'remake', method: 'POST', path: '/remake', body: a }; + case 'reels_post': return { cmd: 'post', method: 'POST', path: '/post', body: a }; + case 'reels_stats': return { cmd: 'stats', method: 'POST', path: '/stats', body: {} }; + case 'reels_note': return { cmd: 'note', method: 'POST', path: '/note', body: a }; + case 'reels_series': return { cmd: 'series', method: 'POST', path: a.series ? '/series/set' : '/series', body: a }; + case 'reels_join_crew': return { cmd: 'crew', method: 'POST', path: '/crew/join', body: a }; + case 'reels_hire_crew': return { cmd: 'crew', method: 'POST', path: '/crew', body: a }; + default: throw new Error(`Unknown tool: ${name}`); + } +} + +export async function handleMcp(msg, io) { + if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') { + return msg && typeof msg === 'object' && 'id' in msg && !('method' in msg) ? undefined : { jsonrpc: '2.0', id: msg?.id ?? null, error: { code: -32600, message: 'Invalid request' } }; + } + if (!('id' in msg)) return undefined; + const ok = (result) => ({ jsonrpc: '2.0', id: msg.id, result }); + switch (msg.method) { + case 'initialize': { + const asked = msg.params?.protocolVersion; + return ok({ + protocolVersion: MCP_VERSIONS.includes(asked) ? asked : MCP_VERSIONS[0], + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'agent-office-reels', title: 'Agent Office reels studio', version: '1.0.0' }, + instructions: 'The reels studio of Agent Office: a faceless short-video page run by a crew of agents. Use these tools (or the office-reels command) for anything about the reels, series, crew or posts.', + }); + } + case 'ping': + return ok({}); + case 'tools/list': + return ok({ tools: TOOLS }); + case 'tools/call': { + const name = msg.params?.name; + if (!TOOLS.some((t) => t.name === name)) return { jsonrpc: '2.0', id: msg.id, error: { code: -32602, message: `Unknown tool: ${name}` } }; + try { + const c = toolCall(name, msg.params?.arguments ?? {}); + const r = await call(c, io); + return ok({ content: [{ type: 'text', text: c.cmd === 'status' || c.cmd === 'show' ? JSON.stringify(r, null, 1) : formatResult(c.cmd, r) }] }); + } catch (e) { + return ok({ content: [{ type: 'text', text: e.message }], isError: true }); + } + } + default: + return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } }; + } +} + +function serveMcp(io, stdin, stdout) { + return new Promise((resolve) => { + const lines = createInterface({ input: stdin, crlfDelay: Infinity }); + const pending = new Set(); + lines.on('line', (line) => { + if (!line.trim()) return; + let msg; + try { + msg = JSON.parse(line); + } catch { + stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`); + return; + } + const p = handleMcp(msg, io).catch((e) => ({ jsonrpc: '2.0', id: msg?.id ?? null, error: { code: -32603, message: e.message } })); + pending.add(p); + void p.then((res) => res && stdout.write(`${JSON.stringify(res)}\n`)).finally(() => pending.delete(p)); + }); + lines.on('close', () => void Promise.allSettled([...pending]).then(() => resolve())); + }); +} + +function readStdin(stdin) { + return new Promise((resolve, reject) => { + let data = ''; + stdin.setEncoding('utf8'); + stdin.on('data', (c) => (data += c)); + stdin.on('end', () => resolve(data)); + stdin.on('error', reject); + }); +} + +export async function main(argv, io = {}) { + const env = io.env ?? process.env; + const stdin = io.stdin ?? process.stdin; + const out = io.out ?? ((s) => process.stdout.write(s + '\n')); + const err = io.err ?? ((s) => process.stderr.write(s + '\n')); + const ctx = { env, fetch: io.fetch ?? fetch }; + try { + const c = parseArgs(argv); + if (c.cmd === 'help') { + out(USAGE); + return 0; + } + if (c.cmd === 'mcp') { + await serveMcp(ctx, stdin, io.stdout ?? process.stdout); + return 0; + } + officeEnv(env); + if (c.stdin) { + if (stdin.isTTY) throw new UsageError(`${c.cmd} reads ${c.stdin === 'json' ? 'JSON' : 'its text'} on stdin: office-reels ${c.cmd} … <<'EOF' … EOF`); + const text = (await readStdin(stdin)).trim(); + if (c.stdin === 'text') c.body.text = text; + else { + try { + Object.assign(c.body, JSON.parse(text || '{}')); + } catch (e) { + throw new UsageError(`That isn't JSON: ${e.message}`); + } + } + } + const r = await call(c, ctx); + if (c.json) out(JSON.stringify(r, null, 2)); + else if (c.cmd === 'status' || c.cmd === 'ls') out(formatStatus(r, c.series)); + else if (c.cmd === 'show') out(JSON.stringify(r, null, 2)); + else if (c.cmd === 'brief') out(r.brief); + else out(formatResult(c.cmd, r)); + return 0; + } catch (e) { + err(`office-reels: ${e.message}`); + if (e instanceof UsageError) err(`\n${USAGE}`); + return e instanceof UsageError ? 2 : 1; + } +} + +const invoked = (() => { + try { + return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +})(); +if (invoked) process.exitCode = await main(process.argv.slice(2)); diff --git a/bin/reels-kokoro.py b/bin/reels-kokoro.py new file mode 100644 index 000000000..9dc846eda --- /dev/null +++ b/bin/reels-kokoro.py @@ -0,0 +1,22 @@ +# reels-kokoro: reads a narration out with Kokoro, the free voice that runs on the office's own +# machine (src/server/reels/media.ts calls it). Takes JSON on stdin: {"text", "voice", "speed", +# "out", "model", "voices"}, writes a WAV to "out", and prints how long it plays, in seconds. +# Runs in the Python environment that has kokoro-onnx and soundfile (see docs/reels.md). + +import json +import sys + +import soundfile as sf +from kokoro_onnx import Kokoro + + +def main() -> None: + ask = json.load(sys.stdin) + kokoro = Kokoro(ask["model"], ask["voices"]) + samples, rate = kokoro.create(ask["text"], voice=ask.get("voice") or "am_michael", speed=float(ask.get("speed") or 1.0), lang="en-us") + sf.write(ask["out"], samples, rate) + print(json.dumps({"seconds": len(samples) / rate})) + + +if __name__ == "__main__": + main() diff --git a/docs/reels.md b/docs/reels.md new file mode 100644 index 000000000..9c3958b2a --- /dev/null +++ b/docs/reels.md @@ -0,0 +1,136 @@ +# The reels studio + +Back to the [README](../README.md). + +The reels studio runs a faceless short-video page on TikTok, Instagram Reels and YouTube Shorts, staffed by the office's own agents. Each reel is a narrated story over AI images, with big captions that follow the voice. The **crew** (four ordinary workers) decides what the reels say, and the **studio** (part of the office's server) makes the videos and posts them on a schedule. + +| Who | What they do | +| --- | --- | +| 🔭 **Scout** | Keeps every series stocked with specific story ideas, and makes more of what the numbers say works. | +| ✍️ **Writer** | Turns ideas into scripts: 6–12 scenes, each a line of narration and an image prompt, plus the caption and hashtags. | +| 🎬 **Editor** | Reviews every finished video against the house rules and approves it or sends it back with a note. Nothing posts without an approval, and nobody approves their own script. | +| 📈 **Analyst** | Fetches every post's views, likes, comments and shares once a day, writes down what's working for the writer and scout, tunes the series, and watches the page against the payout thresholds. | +| 🏭 **The studio** | Voices each script, makes an image per scene, times the captions to the voice, renders a 1080×1920 MP4 with ffmpeg, posts approved reels when each series is due, and messages the crew when there's work for them. | + +The crew are workers like any other: they sit at desks, you can open their terminals and talk to them, and everyone in the office sees what they do. Any worker can join the crew, or hire it, with the `office-reels` command the office puts on every worker's PATH. + +## Getting started + +1. **Install ffmpeg** on the machine that runs the office, so `ffmpeg` and `ffprobe` are on its PATH (`winget install Gyan.FFmpeg`, `brew install ffmpeg`, `apt install ffmpeg`), or point `REELS_FFMPEG` and `REELS_FFPROBE` at them. +2. **Set up the free voice** (see [Voice and images](#voice-and-images)): Kokoro, which reads the narration on the office's own machine. The images are free from the start, from Pollinations. Paying for a better voice or faster images later is one key in the office's environment, with nothing else to change. +3. **Start a series and hire the crew.** From any worker's terminal, or by asking a worker to: + + ```bash + office-reels series add --name "Dark History" --niche "true, little-known dark stories from history, told as mysteries" --style "moody oil painting, candlelight, deep shadows" --platforms tiktok,youtube --postsPerDay 1 --cta "Follow for tomorrow's story" + office-reels crew hire --series "Dark History" + ``` + + Four workers start at free desks with their briefs, and the studio keeps them busy from there. +4. **Connect the accounts** to post to (see [Connecting the platforms](#connecting-the-platforms)). Until then, approved reels wait, and the office says so once. + +## The studio's page + +**☰ → 🎬 Reels studio** opens the studio's own page, `/reels`, in a tab beside the office. It's for the people running the page, and lets you do everything there without an agent: + +- **My series:** every series as a card, with its latest video, what it's doing next and an **Autopilot** switch to pause or resume it. +- **Create series:** three steps. Pick a niche (or describe your own), then an art style and a narrator, then where and how often it posts. Tick **Hire the AI crew** and four agents start at desks right away. +- **A series:** its videos as a grid you can filter (to review, scheduled, posted, in the making, ideas, failed), with ideas to add by hand, the crew's notes and every setting. Click a video for its script, caption and posts. +- **Review:** the new videos one at a time. Watch, then **Approve** (A) or **Send back** (S) with a note for the writer. +- **AI crew, Analytics and Accounts:** who's on the crew and hiring the rest, views by video and progress toward the payout bars, and which platforms are connected. + +On a phone, the sidebar becomes a tab bar along the bottom. + +## The pipeline + +A reel goes **idea → scripted → rendering → rendered → approved → posted**. A rejected reel goes back to the writer with the editor's note, and one whose production broke is **failed** with the reason. + +Every minute the studio: + +- makes the oldest scripted reel into a video (one at a time), and tells the office when it's ready for review; +- posts the oldest approved reel of each series whose gap since its last post has passed (24 hours ÷ `postsPerDay`), to each of its platforms that's connected; +- asks the crew for work: the editor when reels wait for review, the writer when fewer than `backlog` reels are in the pipeline and there are ideas, the scout when fewer than three ideas are left, the analyst once a day. It only asks a worker that's sitting still, and no more often than every three hours. + +Everything is kept in `.agent-office/reels/`: `reels.json` (the series, reels and crew), `out/` (the finished videos), `work/` (each reel's voice, images and captions until it's posted), `music/` (tracks for the series) and `accounts.json` (the platforms' sign-ins). + +## Commands + +Run `office-reels help` in a worker's terminal for the full list. + +| Command | What it does | +| --- | --- | +| `office-reels status` | The series and their pipelines, the analyst's notes, every reel on the go, the crew, and what's set up | +| `office-reels show ` | One reel in full: its script, caption, video file and posts | +| `office-reels series add …` / `series set …` | Start or change a series: `--niche`, `--style`, `--voice`, `--tone`, `--music`, `--platforms`, `--postsPerDay`, `--backlog`, `--cta`, `--hashtags`, `--tiktokPrivacy`, `--youtubePrivacy`, `--pause`, `--resume` | +| `office-reels idea --title … --hook …` | Add an idea | +| `office-reels script ` | A script, as JSON on stdin: `{"title", "hook", "scenes": [{"narration", "visual"}], "caption", "hashtags"}` | +| `office-reels review --approve` / `--reject --note …` | Approve a video, or send it back | +| `office-reels remake ` | Make its video again (after new music, say) | +| `office-reels post ` | Post an approved reel now, outside its schedule | +| `office-reels stats` | Fetch every post's numbers | +| `office-reels note "…"` | Write down a lesson for the crew | +| `office-reels crew hire [--series …] [--roles …]` | Hire a worker for each role nobody has | +| `office-reels crew join ` | Join the crew yourself | +| `office-reels brief ` | What a role is told | + +`office-reels mcp` serves the same as MCP tools on stdio, for an agent you'd rather give tools than a shell command. + +## Voice and images + +A reel costs nothing by default. **Kokoro** reads the narration on the office's machine, and **Pollinations** draws the images. Once a paid service's key is in the office's environment, it takes over by itself. To pick one outright, set `REELS_VOICE` (`kokoro`, `elevenlabs`, `openai` or `placeholder`) or `REELS_IMAGES` (`pollinations`, `fal`, `openai` or `placeholder`). `office-reels status` says which ones are in use. + +### Free: Kokoro and Pollinations + +**Kokoro** is an open-source voice that runs on the CPU. It's set up once, in `~/.agent-office/kokoro/` (or wherever `REELS_KOKORO_DIR` says), as a Python environment and two model files: + +```bash +mkdir -p ~/.agent-office/kokoro && cd ~/.agent-office/kokoro +python3 -m venv venv +venv/bin/pip install kokoro-onnx soundfile +curl -LO https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.int8.onnx +curl -LO https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin +``` + +On Windows, use `py -3.12 -m venv venv` and `venv\Scripts\pip`. The studio finds the setup by itself. Until it's there, the narration comes out silent. Kokoro is slower than real time on a laptop CPU (a minute of narration takes a few minutes), which is fine for a studio that makes reels in the background. A series' `--voice` can name a Kokoro voice (`am_michael`, the default, or `am_adam`, `af_heart`, `bm_george`…). `REELS_KOKORO_VOICE` sets the default, and `REELS_KOKORO_SPEED` reads faster or slower (1 is normal). + +**Pollinations** draws the images with no account and no key. Its free tier draws one image every 45 seconds, so an eight-scene reel takes about six minutes of images. It also signs each image with a small mark in the corner. A free account's token at enter.pollinations.ai, put in `POLLINATIONS_TOKEN`, lifts both limits. The free service makes no promises: a reel whose images couldn't be made is **failed**, and `office-reels remake` tries it again. + +### Paid + +| Variable | What it's for | +| --- | --- | +| `ELEVENLABS_API_KEY` | The voice, from ElevenLabs, with word-exact caption timing. `ELEVENLABS_VOICE_ID` picks the default voice, and a series' `--voice` can be an ElevenLabs voice id. | +| `OPENAI_API_KEY` | The voice (`gpt-4o-mini-tts`, with the series' `--tone` as its reading instructions), when there's no ElevenLabs key, and the images (`gpt-image-1`), when there's no fal key. A series' `--voice` is an OpenAI voice name (`onyx`, `nova`, `sage`…). | +| `FAL_KEY` | The images, from Flux on fal (`fal-ai/flux/schnell`, about a third of a cent an image; `REELS_FAL_MODEL` picks another). | +| `REELS_MEDIA=placeholder` | Silence and plain colors, whatever keys there are, for trying the pipeline out. | +| `REELS_IMAGE_QUALITY` | OpenAI's image quality: `low` (the default), `medium` or `high`. | +| `REELS_CAPTION_FONT` | The captions' font (default Arial), which must be installed where ffmpeg runs. | + +With paid services, a one-minute reel with eight scenes costs roughly $0.03–0.15 (OpenAI's voice and Flux images), and more with ElevenLabs. + +Music goes in `.agent-office/reels/music/`. Give a series a track with `--music ` and it plays quietly under the voice. Use only music you have the rights to post. + +## Connecting the platforms + +Put the sign-ins in `.agent-office/reels/accounts.json`, readable only by the office's user. The office refreshes the tokens and writes them back. No agent can read the file: the crew only sees which platforms are connected. + +```json +{ + "tiktok": { "clientKey": "…", "clientSecret": "…", "refreshToken": "…" }, + "instagram": { "userId": "1784…", "accessToken": "…" }, + "youtube": { "clientId": "….apps.googleusercontent.com", "clientSecret": "…", "refreshToken": "…" } +} +``` + +- **TikTok.** Make an app at developers.tiktok.com with the **Content Posting API** (Direct Post) and the `video.publish` and `video.list` scopes, and sign the page's account in to it once to get a refresh token. Until TikTok audits the app, it only lets posts go out as private (`SELF_ONLY`). The studio checks what the account allows before each post, falls back to private, and says so. Set `--tiktokPrivacy PUBLIC_TO_EVERYONE` once the audit passes. Every post is marked as AI-generated content. +- **Instagram.** The page needs to be a professional (Business or Creator) account. Make a Meta app with `instagram_content_publish` and `instagram_manage_insights`, and get a long-lived token and the account's Instagram user id. A token from Instagram Login (rather than Facebook Login) also needs `"host": "graph.instagram.com"`. The video goes up by resumable upload, so the office doesn't need a public address. +- **YouTube.** Make an OAuth client in Google Cloud with the YouTube Data API v3 and the `youtube.upload` and `youtube.readonly` scopes, and get a refresh token for the channel. Uploads are marked as containing synthetic media. They go out as `private` until you set `--youtubePrivacy public`. Uploads take a large share of the API's default daily quota, so ask Google for more before posting several a day. + +## Making money from it + +The analyst tracks the page against the platforms' bars, and none of them come quickly: + +- **TikTok Creator Rewards** pays for videos over a minute, once the account has 10,000 followers and 100,000 views in 30 days. That's why the writer aims for 150+ words. +- **The YouTube Partner Program** shares Shorts ad revenue once a channel has 1,000 subscribers and 10 million Shorts views in 90 days. +- **A call to action** (`--cta`) earns before either: a link in the bio to an affiliate product or something of your own, once the page has an audience. + +The platforms push down accounts that look automated and spammy. Start with one post a day, and warm a new account up by using it by hand for a few days. Keep the editor strict: original writing, true history, nothing about real people that could be false. Never buy followers or views. diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..85804fa3e 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -54,6 +54,8 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { { id: 'queue', icon: '📋', label: 'Task queue', section: 'Open', count: () => store.queue.tasks.filter((t) => t.status !== 'done').length, title: () => 'Issues and tasks waiting for a worker', run: waiting.showQueue }, { id: 'services', icon: '🌐', label: 'Services', section: 'Open', count: () => store.services.items.length, title: () => 'Web servers the workers are running', run: () => openServices() }, { id: 'whiteboard', icon: '📝', label: 'Whiteboard', section: 'Open', title: () => 'Draw together, live', run: () => openWhiteboard(net) }, + // A page of its own, in a tab beside the office: the agents' faceless short-video page (docs/reels.md). + { id: 'reels', icon: '🎬', label: 'Reels studio', section: 'Open', title: () => 'The reels your agents make and post: review them, change the series, see the numbers', run: () => window.open('/reels', '_blank', 'noopener') }, // Up on the top bar while a meeting is on: what's being worked through in the meeting room. { id: 'meeting', diff --git a/src/client/reels.html b/src/client/reels.html new file mode 100644 index 000000000..fc83aa16e --- /dev/null +++ b/src/client/reels.html @@ -0,0 +1,19 @@ + + + + + + + Reels Studio · Agent Office + + + + +

+ +
+
+
+ + + diff --git a/src/client/reels/api.ts b/src/client/reels/api.ts new file mode 100644 index 000000000..9f60faa65 --- /dev/null +++ b/src/client/reels/api.ts @@ -0,0 +1,140 @@ +// What the reels studio's page asks the office for (http/routes/reels.ts on the server), and the +// shapes it gets back. + +export type Platform = 'tiktok' | 'instagram' | 'youtube'; +export type ReelStatus = 'idea' | 'scripted' | 'rendering' | 'rendered' | 'approved' | 'posting' | 'posted' | 'rejected' | 'failed'; +export const PLATFORMS: Platform[] = ['tiktok', 'instagram', 'youtube']; +export const ROLES = ['scout', 'writer', 'editor', 'analyst'] as const; + +export interface Series { + id: string; + name: string; + niche: string; + style: string; + voice: string; + tone?: string; + music?: string; + platforms: Platform[]; + postsPerDay: number; + backlog: number; + cta?: string; + hashtags: string[]; + tiktokPrivacy: string; + youtubePrivacy: string; + paused: boolean; + notes: { at: number; by: string; text: string }[]; + pipeline: { ideas: number; inPipe: number; toReview: number; approved: number; posted: number; failed: number }; + lastPost?: number; + nextPostAfter?: number; +} + +export interface ReelRow { + id: string; + series: string; + status: ReelStatus; + title: string; + hook?: string; + by: string; + review?: string; + error?: string; + seconds?: number; + posts?: { platform: Platform; url?: string; error?: string; views?: number }[]; +} + +export interface PostStats { + views?: number; + likes?: number; + comments?: number; + shares?: number; + at: number; +} + +export interface Reel { + id: string; + seriesId: string; + status: ReelStatus; + title: string; + hook?: string; + idea?: string; + scenes?: { narration: string; visual: string }[]; + caption?: string; + hashtags?: string[]; + addedBy: string; + scriptedBy?: string; + reviewedBy?: string; + review?: string; + duration?: number; + error?: string; + videoExists?: boolean; + postCaption?: string; + posts: Partial>; + createdAt: number; + updatedAt: number; +} + +export interface CrewRow { + role: (typeof ROLES)[number]; + name: string; + workerId: string; + seriesId?: string; + status: string; +} + +export interface Overview { + me: { name: string; admin: boolean }; + floors: { id: string; name: string }[]; + series: Series[]; + reels: ReelRow[]; + crew: CrewRow[]; + media: { voice: string; images: string }; + connected: Platform[]; + lastStats?: number; +} + +async function ask(path: string, init?: RequestInit): Promise { + const res = await fetch(`/api/reels${path}`, { credentials: 'same-origin', ...init }); + if (res.status === 401) { + location.href = '/login'; + throw new Error('Signed out'); + } + const body = (await res.json().catch(() => ({}))) as T & { error?: string }; + if (!res.ok) throw new Error(body.error || `The office said no (${res.status})`); + return body; +} + +export const api = { + overview: () => ask(''), + reel: (id: string) => ask(`/reel?id=${encodeURIComponent(id)}`), + videoUrl: (id: string, at?: number) => `/api/reels/video/${encodeURIComponent(id)}${at ? `?v=${at}` : ''}`, + post: (path: string, body: Record) => + ask(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }), +}; + +/** "3h ago", "in 5h", "never". */ +export function when(t: number | undefined, now = Date.now()): string { + if (!t) return 'never'; + const m = Math.round((t - now) / 60000); + const a = Math.abs(m); + const s = a < 1 ? 'now' : a < 60 ? `${a}m` : a < 2880 ? `${Math.round(a / 60)}h` : `${Math.round(a / 1440)}d`; + return s === 'now' ? 'just now' : m < 0 ? `${s} ago` : `in ${s}`; +} + +/** A count, short: 1.2k, 3.4M. */ +export function short(n: number | undefined): string { + if (n === undefined) return '–'; + if (n < 1000) return String(n); + if (n < 1e6) return `${(n / 1000).toFixed(n < 1e4 ? 1 : 0)}k`; + return `${(n / 1e6).toFixed(1)}M`; +} + +export const STATUS_LABEL: Record = { + idea: '💡 Idea', + scripted: '✍️ Scripted', + rendering: '⚙️ Making', + rendered: '👀 To review', + approved: '✅ Approved', + posting: '📲 Posting', + posted: '🚀 Posted', + rejected: '✏️ Sent back', + failed: '⚠️ Failed', +}; diff --git a/src/client/reels/create.ts b/src/client/reels/create.ts new file mode 100644 index 000000000..41ac930f7 --- /dev/null +++ b/src/client/reels/create.ts @@ -0,0 +1,190 @@ +// Making a new series in three steps: what it's about, how it looks and sounds, and where and how +// often it posts. The last step can hire the AI crew for it too, so it starts making videos at once. +// What's picked is kept while the page is open, so going back a step loses nothing. + +import { h } from '../ui/dom'; +import { PLATFORMS, type Platform, type Series } from './api'; +import { FREQUENCIES, NICHES, STYLES, VOICES, styleFor } from './presets'; +import type { Page } from './main'; + +interface Draft { + step: 1 | 2 | 3; + niche: string; + name: string; + custom: string; + style: string; + voice: string; + tone: string; + platforms: Platform[]; + perDay: number; + cta: string; + hashtags: string; + hire: boolean; +} + +const fresh = (): Draft => ({ step: 1, niche: '', name: '', custom: '', style: STYLES[0].id, voice: VOICES[0].id, tone: '', platforms: ['tiktok', 'youtube'], perDay: 1, cta: '', hashtags: '', hire: true }); +let draft = fresh(); + +const STEPS = ['Pick a niche', 'Make it yours', 'Post it']; + +function stepper(at: number): HTMLElement { + return h('ol.rs-steps', {}, ...STEPS.map((label, i) => h('li', { class: i + 1 < at ? 'done' : i + 1 === at ? 'on' : undefined }, h('span.rs-step-n', {}, i + 1 < at ? '✓' : i + 1), h('span', {}, label)))); +} + +/** A grid of tiles to pick one of. */ +function tiles(items: T[], selected: (t: T) => boolean, pick: (t: T) => void, body: (t: T) => (HTMLElement | string)[], cls = ''): HTMLElement { + return h( + `div.rs-tiles${cls}` as 'div', + { role: 'radiogroup' }, + ...items.map((t) => h('button.rs-tile', { type: 'button', role: 'radio', 'aria-checked': selected(t) ? 'true' : 'false', class: selected(t) ? 'on' : undefined, onclick: () => pick(t) }, ...body(t))), + ); +} + +function stepNiche(p: Page, redraw: () => void): HTMLElement[] { + const custom = draft.niche === 'custom'; + const area = h('textarea.rs-input', { rows: 3, placeholder: 'e.g. short true stories about animals that saved people', 'aria-label': 'Your own niche' }); + area.value = draft.custom; + area.addEventListener('input', () => (draft.custom = area.value)); + const name = h('input.rs-input', { value: draft.name, placeholder: 'e.g. Dark History Daily', 'aria-label': 'Series name' }); + name.addEventListener('input', () => (draft.name = name.value)); + return [ + h('h2.rs-q', {}, 'What should your videos be about?'), + h('p.rs-sub', {}, 'Pick one to start: your crew comes up with the stories. You can change everything later.'), + tiles( + [...NICHES, { id: 'custom', emoji: '✏️', name: 'Something else' } as (typeof NICHES)[number]], + (n) => draft.niche === n.id, + (n) => { + draft.niche = n.id; + if (n.id !== 'custom') { + draft.style = n.style; + draft.tone = n.tone; + draft.cta = n.cta; + draft.hashtags = n.hashtags; + if (!draft.name || NICHES.some((x) => x.name === draft.name)) draft.name = n.name; + } + redraw(); + }, + (n) => [h('span.rs-tile-emoji', {}, n.emoji), h('b', {}, n.name)], + '.niches', + ), + custom ? h('label.rs-field', {}, h('span', {}, 'Describe your niche'), area) : null, + h('label.rs-field', {}, h('span', {}, 'Series name'), name), + ].filter(Boolean) as HTMLElement[]; +} + +function stepLook(redraw: () => void): HTMLElement[] { + const tone = h('input.rs-input', { value: draft.tone, placeholder: 'calm, low, suspenseful', 'aria-label': 'How it reads' }); + tone.addEventListener('input', () => (draft.tone = tone.value)); + return [ + h('h2.rs-q', {}, 'How should it look?'), + h('p.rs-sub', {}, 'Every picture in every video is drawn in this style.'), + tiles(STYLES, (s) => draft.style === s.id, (s) => ((draft.style = s.id), redraw()), (s) => [h('span.rs-swatch', { style: `background:${s.swatch}` }, s.emoji), h('b', {}, s.name)], '.styles'), + h('h2.rs-q', {}, 'Who tells the story?'), + tiles(VOICES, (v) => draft.voice === v.id, (v) => ((draft.voice = v.id), redraw()), (v) => [h('span.rs-voice-icon', {}, '🎙️'), h('b', {}, v.name), h('small', {}, v.what)], '.voices'), + h('label.rs-field', {}, h('span', {}, 'How it’s read (optional)'), tone), + ]; +} + +function stepPost(p: Page, redraw: () => void): HTMLElement[] { + const cta = h('input.rs-input', { value: draft.cta, placeholder: 'Follow for part 2', 'aria-label': 'Call to action' }); + cta.addEventListener('input', () => (draft.cta = cta.value)); + const tags = h('input.rs-input', { value: draft.hashtags, placeholder: 'history mystery truestory', 'aria-label': 'Hashtags' }); + tags.addEventListener('input', () => (draft.hashtags = tags.value)); + const label: Record = { tiktok: 'TikTok', instagram: 'Instagram Reels', youtube: 'YouTube Shorts' }; + const hire = h('input', { type: 'checkbox', checked: draft.hire }); + hire.addEventListener('change', () => (draft.hire = hire.checked)); + return [ + h('h2.rs-q', {}, 'Where should it post?'), + tiles( + PLATFORMS, + (pl) => draft.platforms.includes(pl), + (pl) => { + draft.platforms = draft.platforms.includes(pl) ? draft.platforms.filter((x) => x !== pl) : [...draft.platforms, pl]; + redraw(); + }, + (pl) => [h('span.rs-pf.big', { class: { tiktok: 'tt', instagram: 'ig', youtube: 'yt' }[pl] }, label[pl]), h('small', {}, p.data.connected.includes(pl) ? '✓ Connected' : 'Connect it later')], + '.platforms', + ), + h('h2.rs-q', {}, 'How often?'), + tiles(FREQUENCIES, (f) => draft.perDay === f.perDay, (f) => ((draft.perDay = f.perDay), redraw()), (f) => [h('b', {}, f.label), h('small', {}, f.what)], '.freq'), + h('div.rs-two', {}, h('label.rs-field', {}, h('span', {}, 'Call to action'), cta), h('label.rs-field', {}, h('span', {}, 'Hashtags'), tags)), + h('label.rs-check-row', {}, hire, h('span', {}, h('b', {}, 'Hire the AI crew for it'), h('small', {}, 'A scout, a writer, an editor and an analyst start at desks in the office and get to work right away.'))), + ]; +} + +/** Why the step can't go on yet, if it can't. */ +function blocked(): string | undefined { + if (draft.step === 1) { + if (!draft.niche) return 'Pick a niche'; + if (draft.niche === 'custom' && draft.custom.trim().length < 8) return 'Describe your niche'; + if (!draft.name.trim()) return 'Give it a name'; + } + if (draft.step === 3 && !draft.platforms.length) return 'Pick at least one place to post'; + return undefined; +} + +async function launch(p: Page, btn: HTMLButtonElement) { + const preset = NICHES.find((n) => n.id === draft.niche); + btn.disabled = true; + btn.textContent = 'Launching…'; + const s = await p.act( + '/series', + { + name: draft.name.trim(), + niche: preset ? preset.niche : draft.custom.trim(), + style: styleFor(draft.style).prompt, + voice: draft.voice, + tone: draft.tone.trim(), + platforms: draft.platforms, + postsPerDay: draft.perDay, + cta: draft.cta.trim(), + hashtags: draft.hashtags, + }, + `🎉 ${draft.name.trim()} is live`, + ); + if (!s) { + btn.disabled = false; + btn.textContent = '🚀 Launch series'; + return; + } + if (draft.hire && p.data.floors.length) await p.act('/crew', { series: s.id, floor: p.data.floors[0].id }, 'Your AI crew is getting to work'); + draft = fresh(); + p.go(`/series/${s.id}`); +} + +export function createPage(p: Page): HTMLElement { + const root = h('section.rs-page.rs-create'); + const redraw = () => root.replaceChildren(...body()); + const body = (): HTMLElement[] => { + const why = blocked(); + const back = h('button.rs-btn.ghost', { type: 'button', onclick: () => ((draft.step = (draft.step - 1) as Draft['step']), redraw()) }, '← Back'); + const next = h('button.rs-btn.primary', { type: 'button', disabled: !!why, title: why }, draft.step === 3 ? '🚀 Launch series' : 'Continue →'); + next.addEventListener('click', () => { + if (blocked()) return; + if (draft.step === 3) return void launch(p, next); + draft.step = (draft.step + 1) as Draft['step']; + redraw(); + window.scrollTo(0, 0); + }); + const content = draft.step === 1 ? stepNiche(p, redraw) : draft.step === 2 ? stepLook(redraw) : stepPost(p, redraw); + return [ + h('header.rs-page-head', {}, h('div', {}, h('h1', {}, 'Create a series'), h('p.rs-sub', {}, `Step ${draft.step} of 3`)), h('a.rs-btn.ghost', { href: '#/', onclick: () => (draft = fresh()) }, 'Cancel')), + stepper(draft.step), + h('div.rs-panel', {}, ...content), + h('footer.rs-wizard-foot', {}, draft.step > 1 ? back : h('span'), h('div.rs-row', {}, why ? h('span.rs-why', {}, why) : null, next)), + ]; + }; + redraw(); + // Typing changes what's needed to go on: keep the button up to date without drawing the step again. + root.addEventListener('input', () => { + const btn = root.querySelector('.rs-wizard-foot .primary'); + const why = blocked(); + if (btn) { + btn.disabled = !!why; + btn.title = why ?? ''; + } + const note = root.querySelector('.rs-why'); + if (note) note.textContent = why ?? ''; + }); + return root; +} diff --git a/src/client/reels/home.ts b/src/client/reels/home.ts new file mode 100644 index 000000000..f6f7741c5 --- /dev/null +++ b/src/client/reels/home.ts @@ -0,0 +1,84 @@ +// The studio's home: every series on autopilot as a card, with its latest video, what's next and a +// switch to pause it; and, before there are any, how it works and the way to make the first one. + +import { h } from '../ui/dom'; +import { api, when, type Platform, type ReelRow, type Series } from './api'; +import { STYLES } from './presets'; +import type { Page } from './main'; + +const PLATFORM_BADGE: Record = { + tiktok: { label: 'TikTok', cls: 'tt' }, + instagram: { label: 'Instagram', cls: 'ig' }, + youtube: { label: 'YouTube', cls: 'yt' }, +}; + +export const platformBadges = (list: Platform[]) => h('div.rs-platforms', {}, ...list.map((p) => h('span.rs-pf', { class: PLATFORM_BADGE[p].cls, title: PLATFORM_BADGE[p].label }, PLATFORM_BADGE[p].label))); + +/** The art style tile a series' style is, if it came from one. */ +export const swatchOf = (s: Series) => STYLES.find((x) => x.prompt === s.style)?.swatch ?? 'linear-gradient(135deg,#7c3aed,#ec4899)'; + +/** A small switch: on means it makes and posts by itself. */ +export function autopilot(p: Page, s: Series): HTMLElement { + const input = h('input', { type: 'checkbox', checked: !s.paused, 'aria-label': `Autopilot for ${s.name}` }); + input.addEventListener('change', () => void p.act('/series/set', { series: s.id, paused: !input.checked }, input.checked ? `${s.name} is on autopilot` : `${s.name} is paused`)); + return h('label.rs-switch', { title: s.paused ? 'Paused: nothing is made or posted' : 'On autopilot: it makes and posts by itself' }, input, h('span.rs-switch-track', {}, h('span.rs-switch-dot')), h('span.rs-switch-label', {}, s.paused ? 'Paused' : 'Autopilot')); +} + +/** A video's first frame as its cover; the series' colors until it has one. */ +export function cover(s: Series, latest: ReelRow | undefined, cls = 'rs-cover'): HTMLElement { + const el = h(`div.${cls}` as 'div', { style: `background:${swatchOf(s)}` }); + if (latest) el.append(h('video', { src: `${api.videoUrl(latest.id)}#t=1.2`, muted: true, preload: 'metadata', playsinline: true, 'aria-hidden': 'true' })); + else el.append(h('span.rs-cover-emoji', {}, '🎬')); + return el; +} + +function seriesCard(p: Page, s: Series): HTMLElement { + const mine = p.data.reels.filter((r) => r.series === s.id); + const latest = mine.find((r) => r.seconds && ['rendered', 'approved', 'posted'].includes(r.status)); + const c = s.pipeline; + const next = s.paused ? 'Paused' : !c.approved ? (c.inPipe ? 'Making the next video' : 'Waiting for scripts') : s.nextPostAfter && s.nextPostAfter > Date.now() ? `Next post ${when(s.nextPostAfter)}` : 'Posting soon'; + return h( + 'article.rs-series-card', + { class: s.paused ? 'paused' : undefined }, + h('a.rs-series-link', { href: `#/series/${s.id}`, 'aria-label': `Open ${s.name}` }, cover(s, latest)), + h( + 'div.rs-series-body', + {}, + h('div.rs-series-top', {}, h('a.rs-series-name', { href: `#/series/${s.id}` }, s.name), autopilot(p, s)), + h('p.rs-series-niche', {}, s.niche), + platformBadges(s.platforms), + h('div.rs-mini-stats', {}, h('span', {}, h('b', {}, c.posted), ' posted'), h('span', {}, h('b', {}, c.inPipe), ' in the making'), c.toReview ? h('a.rs-pill.warn', { href: '#/review' }, `${c.toReview} to review`) : null), + h('div.rs-series-next', {}, h('span.rs-dot', { class: s.paused ? 'off' : 'on' }), next), + ), + ); +} + +/** Before there's a series: what the studio does, in three steps, and the button to start. */ +function welcome(): HTMLElement { + const step = (n: number, icon: string, title: string, text: string) => h('div.rs-how', {}, h('div.rs-how-n', {}, n), h('div.rs-how-icon', {}, icon), h('h3', {}, title), h('p', {}, text)); + return h( + 'section.rs-welcome', + {}, + h('div.rs-hero', {}, h('span.rs-eyebrow', {}, '✨ Your agents make it, you watch it grow'), h('h1', {}, 'Faceless videos ', h('span.rs-grad', {}, 'on autopilot')), h('p', {}, 'Pick a niche and a look. Your AI crew writes the stories, the studio makes the videos, and they go out to TikTok, Instagram and YouTube on a schedule.'), h('a.rs-btn.primary.big', { href: '#/new' }, '⚡ Create your first series'), h('p.rs-small', {}, 'Free to run: the voice and pictures are made without paid services.')), + h('div.rs-how-row', {}, step(1, '🎯', 'Create a series', 'Pick a niche, like scary stories or history, or describe your own.'), step(2, '🎨', 'Make it yours', 'Choose the art style, the narrator’s voice and how often to post.'), step(3, '🚀', 'Watch it grow', 'Approve the first videos, connect your accounts, and let it run.')), + ); +} + +export function homePage(p: Page): HTMLElement { + const d = p.data; + if (!p.loaded) return h('section.rs-loading', {}, h('div.rs-spinner'), 'Opening the studio…'); + if (!d.series.length) return welcome(); + const sig = d.reels.filter((r) => r.seconds).map((r) => r.id).join(',') + d.series.map((s) => `${s.id}${s.paused}`).join(','); + const toReview = d.reels.filter((r) => r.status === 'rendered').length; + const views = d.reels.reduce((n, r) => n + (r.posts ?? []).reduce((m, x) => m + (x.views ?? 0), 0), 0); + return h( + 'section.rs-page', + { 'data-sig': sig }, + h('header.rs-page-head', {}, h('div', {}, h('h1', {}, 'My series'), h('p.rs-sub', {}, 'Each series is one niche on autopilot. Open one to see its videos.')), h('a.rs-btn.primary', { href: '#/new' }, '+ Create series')), + toReview ? h('a.rs-banner', { href: '#/review' }, h('span.rs-banner-icon', {}, '👀'), h('span', {}, h('b', {}, `${toReview} video${toReview === 1 ? ' is' : 's are'} waiting for your review.`), ' Approve them and they post on schedule.'), h('span.rs-banner-go', {}, 'Review →')) : null, + h('div.rs-kpis', {}, kpi('Series', d.series.length), kpi('Videos posted', d.series.reduce((n, s) => n + s.pipeline.posted, 0)), kpi('Total views', views.toLocaleString()), kpi('In the making', d.series.reduce((n, s) => n + s.pipeline.inPipe, 0))), + h('div.rs-series-grid', {}, ...d.series.map((s) => seriesCard(p, s)), h('a.rs-series-card.rs-add', { href: '#/new' }, h('span.rs-add-plus', {}, '+'), h('b', {}, 'New series'), h('span', {}, 'Another niche, another page'))), + ); +} + +const kpi = (label: string, value: string | number) => h('div.rs-kpi', {}, h('b', {}, value), h('span', {}, label)); diff --git a/src/client/reels/main.ts b/src/client/reels/main.ts new file mode 100644 index 000000000..edb18d244 --- /dev/null +++ b/src/client/reels/main.ts @@ -0,0 +1,162 @@ +import './reels.css'; +// The reels studio's page: your series on autopilot, making a new one in three steps, each series' +// videos, reviewing what's waiting, the crew of agents, the numbers and the accounts posted to. It +// asks the office for everything again every few seconds and draws the page that's open, except +// the steps of a new series and any form someone's typing in. + +import { h } from '../ui/dom'; +import { api, type Overview } from './api'; +import { createPage } from './create'; +import { homePage } from './home'; +import { reviewPage } from './review'; +import { seriesPage } from './series'; +import { accountsPage, analyticsPage, crewPage } from './views'; + +/** What every page draws from, and does with. */ +export interface Page { + data: Overview; + /** Whether the first answer from the office is in. */ + loaded: boolean; + /** Says how something went, for a few seconds. */ + toast(text: string, bad?: boolean): void; + /** Sends `body` to `path`, says how it went, and draws everything again; resolves to what came back, or undefined when it failed. */ + act(path: string, body: Record, done?: string): Promise; + refresh(): Promise; + /** Goes to a page of the studio: '/', '/new', '/series/', '/review'… */ + go(to: string): void; +} + +interface Route { + match: RegExp; + draw: (p: Page, ...args: string[]) => HTMLElement; + /** Left alone by the refresh: drawn once when it's opened. */ + still?: boolean; +} + +const ROUTES: Route[] = [ + { match: /^\/$/, draw: homePage }, + { match: /^\/new$/, draw: createPage, still: true }, + { match: /^\/series\/([\w-]+)$/, draw: seriesPage }, + { match: /^\/review$/, draw: reviewPage, still: true }, + { match: /^\/crew$/, draw: crewPage }, + { match: /^\/analytics$/, draw: analyticsPage }, + { match: /^\/accounts$/, draw: accountsPage }, +]; + +const NAV: { to: string; icon: string; label: string; badge?: (d: Overview) => number }[] = [ + { to: '/', icon: '🗂️', label: 'My series' }, + { to: '/review', icon: '👀', label: 'Review', badge: (d) => d.reels.filter((r) => r.status === 'rendered').length }, + { to: '/crew', icon: '🤖', label: 'AI crew' }, + { to: '/analytics', icon: '📈', label: 'Analytics' }, + { to: '/accounts', icon: '🔗', label: 'Accounts' }, +]; + +const REFRESH_MS = 8000; + +const view = document.getElementById('view')!; +const side = document.getElementById('side')!; +const toasts = document.getElementById('toasts')!; + +const path = () => location.hash.replace(/^#/, '') || '/'; + +const page: Page = { + data: { me: { name: '', admin: false }, floors: [], series: [], reels: [], crew: [], media: { voice: '…', images: '…' }, connected: [] }, + loaded: false, + toast(text, bad = false) { + const t = h('div.rs-toast', { class: bad ? 'bad' : undefined, role: bad ? 'alert' : 'status' }, h('span', {}, bad ? '⚠️' : '✓'), text); + toasts.append(t); + setTimeout(() => t.classList.add('out'), bad ? 6500 : 3000); + setTimeout(() => t.remove(), bad ? 7000 : 3500); + }, + async act(to: string, body: Record, done?: string) { + try { + const r = await api.post(to, body); + if (done) page.toast(done); + await page.refresh(); + return r; + } catch (e) { + page.toast((e as Error).message, true); + return undefined; + } + }, + async refresh() { + try { + page.data = await api.overview(); + page.loaded = true; + } catch (e) { + page.toast(`Can't reach the office: ${(e as Error).message}`, true); + return; + } + draw(false); + }, + go(to) { + if (path() === to) draw(true); + else location.hash = to; + }, +}; + +/** Whether someone is in the middle of a form, so a refresh would lose what they typed. */ +const editing = () => { + const a = document.activeElement; + return !!a && view.contains(a) && a.matches('input, textarea, select'); +}; + +function drawSide() { + const d = page.data; + const here = path(); + const free = (s: string) => (s === 'kokoro' || s === 'pollinations' ? 'Free' : s === 'placeholder' ? 'Off' : 'Paid'); + side.replaceChildren( + h('a.rs-brand', { href: '#/' }, h('span.rs-logo', {}, '🎬'), h('span', {}, 'Reels', h('b', {}, 'Studio'))), + h('a.rs-btn.primary.rs-new', { href: '#/new' }, h('span', {}, '+'), h('span.rs-new-label', {}, 'Create series')), + h( + 'nav.rs-nav', + {}, + ...NAV.map((n) => { + const on = n.to === '/' ? here === '/' || here.startsWith('/series/') : here === n.to; + const count = n.badge?.(d) ?? 0; + return h('a.rs-nav-item', { href: `#${n.to}`, class: on ? 'on' : undefined, 'aria-current': on ? 'page' : undefined }, h('span.rs-nav-icon', {}, n.icon), h('span.rs-nav-label', {}, n.label), count ? h('span.rs-badge', {}, count) : null); + }), + ), + h( + 'div.rs-side-foot', + {}, + h('div.rs-engine', {}, h('span', {}, '🎙️ Voice'), h('b', { class: free(d.media.voice) }, `${d.media.voice} · ${free(d.media.voice)}`)), + h('div.rs-engine', {}, h('span', {}, '🎨 Images'), h('b', { class: free(d.media.images) }, `${d.media.images} · ${free(d.media.images)}`)), + h('a.rs-back', { href: '/' }, '← Back to the office'), + ), + ); +} + +let drawnPath = ''; +let drawnSig = ''; + +/** Draws the page. `force` draws it even when it's one that's left alone, or someone's typing. */ +function draw(force: boolean) { + drawSide(); + const p = path(); + const route = ROUTES.find((r) => r.match.test(p)); + if (!route) return page.go('/'); + const moved = p !== drawnPath; + if (!moved && !force && (route.still || editing())) return; + const next = route.draw(page, ...(route.match.exec(p)!.slice(1))); + // A page whose videos haven't changed keeps the ones there (so one that's playing goes on playing). + const sig = next.getAttribute('data-sig') ?? ''; + if (!moved && !force && sig && sig === drawnSig) { + const old = view.firstElementChild; + const playing = old && [...old.querySelectorAll('video')].some((v) => !v.paused); + if (playing) return; + } + view.replaceChildren(next); + drawnPath = p; + drawnSig = sig; + if (moved) window.scrollTo(0, 0); +} + +window.addEventListener('hashchange', () => draw(true)); +void page.refresh(); +setInterval(() => { + if (!document.hidden) void page.refresh(); +}, REFRESH_MS); +document.addEventListener('visibilitychange', () => { + if (!document.hidden) void page.refresh(); +}); diff --git a/src/client/reels/modal.ts b/src/client/reels/modal.ts new file mode 100644 index 000000000..02aba5764 --- /dev/null +++ b/src/client/reels/modal.ts @@ -0,0 +1,105 @@ +// A reel in full on the studio's page: its video, script, caption and posts, and what a person can +// do with it there. The window closes by its ✕, by Esc, or by a click outside it. + +import { h } from '../ui/dom'; +import { PLATFORMS, STATUS_LABEL, api, short, when, type Reel } from './api'; +import type { Page } from './main'; + +let open: { el: HTMLElement; close: () => void } | undefined; + +function modal(title: string, ...body: (HTMLElement | null)[]): () => void { + open?.close(); + const x = h('button.rs-x', { type: 'button', 'aria-label': 'Close', title: 'Close (Esc)' }, '✕'); + const box = h('div.rs-modal', { role: 'dialog', 'aria-modal': 'true', 'aria-label': title }, h('header.rs-modal-head', {}, h('h2', {}, title), x), h('div.rs-modal-body', {}, ...body)); + const back = h('div.rs-backdrop', {}, box); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') close(); + }; + const close = () => { + back.remove(); + document.removeEventListener('keydown', onKey); + document.body.classList.remove('rs-locked'); + if (open?.el === back) open = undefined; + }; + x.addEventListener('click', close); + back.addEventListener('mousedown', (e) => { + if (e.target === back) close(); + }); + document.addEventListener('keydown', onKey); + document.body.classList.add('rs-locked'); + document.body.append(back); + open = { el: back, close }; + x.focus(); + return close; +} + +/** Approve, or send back with a note on what to fix. */ +export function reviewBox(p: Page, id: string, after?: () => void): HTMLElement { + const note = h('textarea.rs-input', { rows: 2, placeholder: 'What to fix, if you send it back (the writer reads this)', 'aria-label': 'Note for the writer' }); + const approve = h('button.rs-btn.primary', { type: 'button' }, '✅ Approve'); + const reject = h('button.rs-btn.soft-danger', { type: 'button' }, '✏️ Send back'); + const go = async (yes: boolean) => { + if (!yes && !note.value.trim()) { + note.focus(); + return p.toast('Say what to fix first', true); + } + approve.disabled = reject.disabled = true; + const ok = await p.act('/review', { reel: id, approve: yes, note: note.value }, yes ? 'Approved: it posts on the schedule' : 'Sent back to the writer'); + approve.disabled = reject.disabled = false; + if (ok) after?.(); + }; + approve.addEventListener('click', () => void go(true)); + reject.addEventListener('click', () => void go(false)); + return h('div.rs-review-box', {}, note, h('div.rs-row', {}, approve, reject)); +} + +const section = (title: string, ...children: (HTMLElement | string | null)[]) => h('section.rs-sec', {}, h('h3', {}, title), ...children); + +/** Opens a reel in full. */ +export async function openReel(p: Page, id: string): Promise { + let r: Reel; + try { + r = await api.reel(id); + } catch (e) { + return p.toast((e as Error).message, true); + } + const series = p.data.series.find((s) => s.id === r.seriesId); + let close = () => {}; + const actions: HTMLElement[] = []; + const act = (label: string, path: string, done: string, primary = false) => { + const b = h('button.rs-btn', { type: 'button', class: primary ? 'primary' : undefined }, label); + b.addEventListener('click', async () => { + b.disabled = true; + if (await p.act(path, { reel: r.id }, done)) close(); + b.disabled = false; + }); + actions.push(b); + }; + if (r.status === 'approved') act('📲 Post now', '/post', 'Posted', true); + if (r.scenes?.length && !['rendering', 'posting', 'posted'].includes(r.status)) act('🔁 Make the video again', '/remake', 'It’s back in line to be made'); + + const video = r.videoExists ? h('video.rs-video', { src: api.videoUrl(r.id, r.updatedAt), controls: true, preload: 'metadata', playsinline: true }) : null; + const meta = h( + 'p.rs-kicker', + {}, + [STATUS_LABEL[r.status], series?.name, r.duration ? `${Math.round(r.duration)}s` : '', `idea by ${r.addedBy}`, r.scriptedBy ? `written by ${r.scriptedBy}` : '', r.reviewedBy ? `reviewed by ${r.reviewedBy}` : '', `updated ${when(r.updatedAt)}`].filter(Boolean).join(' · '), + ); + const scenes = r.scenes?.length + ? h('ol.rs-scenes', {}, ...r.scenes.map((s) => h('li', {}, h('p', {}, s.narration), h('p.rs-visual', {}, `🖼 ${s.visual}`)))) + : h('p.rs-kicker', {}, r.idea ?? 'No script yet: the writer picks ideas up in order.'); + const posts = PLATFORMS.filter((pl) => r.posts[pl]).map((pl) => { + const post = r.posts[pl]!; + const stats = post.stats ? ` · ${short(post.stats.views)} views, ${short(post.stats.likes)} likes, ${short(post.stats.comments)} comments` : ''; + return h('li', {}, h('b', {}, pl), ' ', post.error ? h('span.bad', {}, post.error) : post.url ? h('a', { href: post.url, target: '_blank', rel: 'noopener' }, 'open the post') : 'posted', stats); + }); + + close = modal( + r.title, + meta, + r.error ? h('p.rs-alert', {}, `⚠️ ${r.error}`) : null, + r.review ? h('p.rs-alert.info', {}, `Editor: ${r.review}`) : null, + h('div.rs-detail', {}, video, h('div.rs-detail-text', {}, r.hook ? section('Hook', h('p.rs-hook', {}, `“${r.hook}”`)) : null, section('Script', scenes), r.postCaption ? section('Posts with', h('pre.rs-caption', {}, r.postCaption)) : null, posts.length ? section('Posts', h('ul.rs-posts', {}, ...posts)) : null)), + r.status === 'rendered' ? section('Your call', reviewBox(p, r.id, () => close())) : null, + actions.length ? h('div.rs-row.rs-actions', {}, ...actions) : null, + ); +} diff --git a/src/client/reels/presets.ts b/src/client/reels/presets.ts new file mode 100644 index 000000000..dc5a59dea --- /dev/null +++ b/src/client/reels/presets.ts @@ -0,0 +1,67 @@ +// What the "new series" steps offer to pick from: niches, art styles, voices and how often to post. +// Each is only a starting point the studio's fields are filled in from; everything stays editable. + +export interface Niche { + id: string; + emoji: string; + name: string; + /** What the scout and writer go by. */ + niche: string; + /** The art style it suits. */ + style: string; + hashtags: string; + cta: string; + tone: string; +} + +export const NICHES: Niche[] = [ + { id: 'scary', emoji: '👻', name: 'Scary stories', niche: 'original short horror stories with a twist ending, told in first person', style: 'dark-fantasy', hashtags: 'scarystories horror creepy', cta: 'Follow for tomorrow’s story… if you dare', tone: 'low, slow, unsettling' }, + { id: 'history', emoji: '🏛️', name: 'Dark history', niche: 'true, little-known dark stories from history, told as mysteries', style: 'oil-painting', hashtags: 'history mystery truestory', cta: 'Follow for tomorrow’s story', tone: 'calm, low, suspenseful' }, + { id: 'myths', emoji: '⚡', name: 'Mythology', niche: 'myths and legends from Greek, Norse and Egyptian mythology, retold as gripping stories', style: 'epic-painting', hashtags: 'mythology greekmythology legends', cta: 'Follow for the next legend', tone: 'grand, dramatic storyteller' }, + { id: 'mystery', emoji: '🔍', name: 'Unsolved mysteries', niche: 'real unsolved mysteries and strange disappearances, told carefully and factually', style: 'noir', hashtags: 'unsolved mystery truecrime', cta: 'What do you think happened? Comment below', tone: 'measured, curious' }, + { id: 'facts', emoji: '🧠', name: 'Mind-blowing facts', niche: 'surprising, true science and nature facts explained as a short story', style: 'cinematic', hashtags: 'facts science didyouknow', cta: 'Follow for a fact a day', tone: 'upbeat, amazed' }, + { id: 'motivation', emoji: '🔥', name: 'Motivation', niche: 'short true stories of people who failed many times before succeeding, with one lesson each', style: 'cinematic', hashtags: 'motivation success mindset', cta: 'Follow for your daily push', tone: 'warm, rising, inspiring' }, + { id: 'heists', emoji: '💎', name: 'Famous heists', niche: 'true stories of famous heists and cons, told like a thriller', style: 'comic', hashtags: 'heist truecrime story', cta: 'Follow for the next heist', tone: 'quick, playful, tense' }, + { id: 'kindness', emoji: '💛', name: 'Acts of kindness', niche: 'heartwarming original stories about small acts of kindness with a twist', style: 'storybook', hashtags: 'kindness wholesome story', cta: 'Follow for more good news', tone: 'gentle, warm' }, +]; + +export interface ArtStyle { + id: string; + name: string; + /** What's added to every image prompt. */ + prompt: string; + /** The tile's colors, since there's no picture to show for it. */ + swatch: string; + emoji: string; +} + +export const STYLES: ArtStyle[] = [ + { id: 'oil-painting', name: 'Oil painting', prompt: 'moody oil painting, candlelight, deep shadows, visible brush strokes', swatch: 'linear-gradient(135deg,#3b2414,#9a5b2a 55%,#e8b26a)', emoji: '🖼️' }, + { id: 'dark-fantasy', name: 'Dark fantasy', prompt: 'dark fantasy digital art, fog, eerie green and blue light, highly detailed', swatch: 'linear-gradient(135deg,#0b1320,#1f4d4a 55%,#7fd1b9)', emoji: '🌑' }, + { id: 'cinematic', name: 'Cinematic', prompt: 'cinematic photograph, 35mm film, dramatic lighting, shallow depth of field', swatch: 'linear-gradient(135deg,#101820,#2c5364 55%,#f4a261)', emoji: '🎬' }, + { id: 'anime', name: 'Anime', prompt: 'anime illustration, vibrant colors, clean line art, studio quality background', swatch: 'linear-gradient(135deg,#ff6fb5,#8b5cf6 55%,#60a5fa)', emoji: '🌸' }, + { id: 'comic', name: 'Comic book', prompt: 'bold comic book illustration, ink outlines, halftone shading, dynamic angles', swatch: 'linear-gradient(135deg,#ffd166,#ef476f 55%,#118ab2)', emoji: '💥' }, + { id: 'epic-painting', name: 'Epic painting', prompt: 'epic classical painting, golden light, dramatic sky, renaissance style', swatch: 'linear-gradient(135deg,#2a1b3d,#a4508b 55%,#f9c74f)', emoji: '⚡' }, + { id: 'noir', name: 'Film noir', prompt: 'black and white film noir, hard shadows, rain, 1940s', swatch: 'linear-gradient(135deg,#0d0d0d,#4a4a4a 55%,#d9d9d9)', emoji: '🕵️' }, + { id: 'storybook', name: 'Storybook', prompt: 'soft watercolor storybook illustration, warm pastel colors, gentle light', swatch: 'linear-gradient(135deg,#fde2e4,#cddafd 55%,#bee1e6)', emoji: '📖' }, + { id: '3d', name: '3D cartoon', prompt: 'cute 3D cartoon render, soft lighting, bright colors, clay style', swatch: 'linear-gradient(135deg,#a0e7e5,#b4f8c8 55%,#fbe7c6)', emoji: '🧸' }, +]; + +export const styleFor = (id: string) => STYLES.find((s) => s.id === id) ?? STYLES[0]; + +/** The free Kokoro voices worth offering, and what each sounds like. */ +export const VOICES: { id: string; name: string; what: string }[] = [ + { id: 'am_michael', name: 'Michael', what: 'Deep, calm storyteller (US)' }, + { id: 'am_adam', name: 'Adam', what: 'Clear, energetic (US)' }, + { id: 'af_heart', name: 'Heart', what: 'Warm, expressive (US)' }, + { id: 'af_bella', name: 'Bella', what: 'Bright, friendly (US)' }, + { id: 'bm_george', name: 'George', what: 'Classic British narrator' }, + { id: 'bf_emma', name: 'Emma', what: 'Soft British voice' }, +]; + +export const FREQUENCIES: { perDay: number; label: string; what: string }[] = [ + { perDay: 0.5, label: 'Every 2 days', what: 'Easy start' }, + { perDay: 1, label: 'Once a day', what: 'Recommended' }, + { perDay: 2, label: 'Twice a day', what: 'Grow faster' }, + { perDay: 3, label: '3 a day', what: 'For established pages' }, +]; diff --git a/src/client/reels/reels.css b/src/client/reels/reels.css new file mode 100644 index 000000000..ecd14c095 --- /dev/null +++ b/src/client/reels/reels.css @@ -0,0 +1,350 @@ +/* The reels studio's page (reels.html): a clean, light app of its own beside the office, with a + sidebar on a computer and a tab bar along the bottom on a phone. */ +:root { + --rs-bg: #f7f7fb; + --rs-card: #ffffff; + --rs-ink: #14121f; + --rs-text: #3f3d56; + --rs-muted: #8a879c; + --rs-line: #ebeaf2; + --rs-primary: #7c3aed; + --rs-primary-2: #a855f7; + --rs-primary-soft: #f3edff; + --rs-pink: #ec4899; + --rs-good: #10b981; + --rs-warn: #f59e0b; + --rs-bad: #ef4444; + --rs-radius: 16px; + --rs-shadow: 0 1px 2px rgba(20, 18, 31, .04), 0 8px 24px rgba(20, 18, 31, .06); + --rs-shadow-lift: 0 2px 4px rgba(20, 18, 31, .06), 0 16px 40px rgba(124, 58, 237, .14); + --rs-font: ui-sans-serif, 'Inter', 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif; +} + +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; } +body.reels { background: var(--rs-bg); color: var(--rs-text); font: 500 15px/1.5 var(--rs-font); -webkit-font-smoothing: antialiased; } +body.rs-locked { overflow: hidden; } +h1, h2, h3 { color: var(--rs-ink); letter-spacing: -.02em; line-height: 1.2; margin: 0; } +h1 { font-size: 30px; font-weight: 800; } +h2 { font-size: 20px; font-weight: 800; } +h3 { font-size: 16px; font-weight: 750; } +a { color: inherit; text-decoration: none; } +button { font: inherit; color: inherit; } +kbd { margin-left: 8px; padding: 1px 6px; border-radius: 6px; background: rgba(255, 255, 255, .25); font: 700 11px var(--rs-font); } +[hidden] { display: none !important; } + +/* The frame: sidebar and page. */ +.rs-app { display: grid; grid-template-columns: 248px 1fr; min-height: 100dvh; } +.rs-side { position: sticky; top: 0; height: 100dvh; display: flex; flex-direction: column; gap: 18px; padding: 22px 16px; background: var(--rs-card); border-right: 1px solid var(--rs-line); } +.rs-brand { display: flex; align-items: center; gap: 10px; padding: 0 8px; font-size: 19px; font-weight: 800; color: var(--rs-ink); letter-spacing: -.02em; } +.rs-brand b { color: var(--rs-primary); margin-left: 3px; } +.rs-logo { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 11px; background: linear-gradient(135deg, var(--rs-primary), var(--rs-pink)); font-size: 18px; box-shadow: 0 6px 16px rgba(124, 58, 237, .35); } +.rs-nav { display: flex; flex-direction: column; gap: 2px; } +.rs-nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 12px; font-weight: 650; color: var(--rs-text); transition: background .15s, color .15s; } +.rs-nav-item:hover { background: var(--rs-bg); color: var(--rs-ink); } +.rs-nav-item.on { background: var(--rs-primary-soft); color: var(--rs-primary); } +.rs-nav-icon { width: 22px; text-align: center; font-size: 17px; } +.rs-badge { margin-left: auto; min-width: 22px; padding: 1px 7px; border-radius: 999px; background: var(--rs-pink); color: #fff; font-size: 12px; font-weight: 800; text-align: center; } +.rs-side-foot { margin-top: auto; display: flex; flex-direction: column; gap: 8px; padding: 14px 10px 0; border-top: 1px solid var(--rs-line); font-size: 13px; } +.rs-engine { display: flex; justify-content: space-between; gap: 8px; color: var(--rs-muted); } +.rs-engine b { font-weight: 700; color: var(--rs-text); } +.rs-engine b.Free { color: var(--rs-good); } +.rs-engine b.Off { color: var(--rs-warn); } +.rs-back { margin-top: 4px; font-weight: 650; color: var(--rs-muted); } +.rs-back:hover { color: var(--rs-primary); } +.rs-main { min-width: 0; padding: 32px clamp(16px, 4vw, 48px) 80px; } +.rs-page { max-width: 1160px; margin: 0 auto; display: flex; flex-direction: column; gap: 22px; animation: rs-in .25s ease-out; } +@keyframes rs-in { from { opacity: 0; transform: translateY(6px); } } +.rs-page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.rs-sub { margin: 4px 0 0; color: var(--rs-muted); font-weight: 500; } +.rs-sub.small, .rs-small { font-size: 13px; } +.rs-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; } +.rs-row.between { justify-content: space-between; } +.rs-two { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 14px; } +.rs-crumb { align-self: flex-start; margin-bottom: -8px; font-weight: 650; color: var(--rs-muted); } +.rs-crumb:hover { color: var(--rs-primary); } + +/* Buttons. */ +.rs-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 42px; padding: 0 18px; border: 1px solid var(--rs-line); border-radius: 12px; background: var(--rs-card); color: var(--rs-ink); font-weight: 700; cursor: pointer; white-space: nowrap; transition: transform .12s, box-shadow .15s, background .15s; } +.rs-btn:hover { box-shadow: var(--rs-shadow); transform: translateY(-1px); } +.rs-btn:active { transform: translateY(0); } +.rs-btn:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; } +.rs-btn.primary { border: 0; background: linear-gradient(135deg, var(--rs-primary), var(--rs-primary-2)); color: #fff; box-shadow: 0 6px 18px rgba(124, 58, 237, .35); } +.rs-btn.primary:hover { box-shadow: 0 10px 26px rgba(124, 58, 237, .45); } +.rs-btn.big { height: 52px; padding: 0 26px; font-size: 16px; border-radius: 14px; } +.rs-btn.ghost { background: transparent; border-color: transparent; color: var(--rs-muted); } +.rs-btn.ghost:hover { color: var(--rs-ink); background: var(--rs-card); } +.rs-btn.danger { border: 0; background: var(--rs-bad); color: #fff; } +.rs-btn.soft-danger { background: #fff1f2; border-color: #ffd5da; color: #be123c; } +.rs-new { width: 100%; height: 46px; } + +/* Bits. */ +.rs-panel { padding: 22px; background: var(--rs-card); border: 1px solid var(--rs-line); border-radius: var(--rs-radius); box-shadow: var(--rs-shadow); display: flex; flex-direction: column; gap: 12px; } +.rs-fold > summary { list-style: none; cursor: pointer; } +.rs-fold > summary::-webkit-details-marker { display: none; } +.rs-fold > summary h3::after { content: '⌄'; margin-left: 8px; color: var(--rs-muted); } +.rs-fold[open] > summary h3::after { content: '⌃'; } +.rs-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; } +.rs-kpi { padding: 16px 18px; background: var(--rs-card); border: 1px solid var(--rs-line); border-radius: var(--rs-radius); box-shadow: var(--rs-shadow); display: flex; flex-direction: column; gap: 2px; } +.rs-kpi b { font-size: 26px; font-weight: 800; color: var(--rs-ink); letter-spacing: -.02em; } +.rs-kpi span { font-size: 13px; color: var(--rs-muted); font-weight: 600; } +.rs-kpis.small { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; } +.rs-kpis.small .rs-kpi { padding: 10px 14px; box-shadow: none; background: var(--rs-bg); } +.rs-kpis.small .rs-kpi b { font-size: 19px; } +.rs-banner { display: flex; align-items: center; gap: 14px; padding: 14px 18px; border-radius: var(--rs-radius); background: linear-gradient(135deg, #fff7ed, #fdf2f8); border: 1px solid #fde4cf; transition: transform .12s; } +.rs-banner:hover { transform: translateY(-1px); } +.rs-banner b { color: var(--rs-ink); } +.rs-banner-icon { font-size: 26px; } +.rs-banner-go { margin-left: auto; font-weight: 800; color: var(--rs-primary); white-space: nowrap; } +.rs-pill { padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 750; } +.rs-pill.warn { background: #fff4e5; color: #b45309; } +.rs-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--rs-muted); } +.rs-dot.on { background: var(--rs-good); box-shadow: 0 0 0 4px rgba(16, 185, 129, .15); } +.rs-dot.off { background: #cbd5e1; } +.rs-eyebrow { display: inline-block; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; color: var(--rs-primary); } +.rs-grad { background: linear-gradient(135deg, var(--rs-primary), var(--rs-pink)); -webkit-background-clip: text; background-clip: text; color: transparent; } +.rs-empty { padding: 48px 24px; text-align: center; background: var(--rs-card); border: 1px dashed #d9d6e6; border-radius: var(--rs-radius); display: flex; flex-direction: column; align-items: center; gap: 8px; } +.rs-empty.small { padding: 28px; } +.rs-empty p { margin: 0; color: var(--rs-muted); max-width: 440px; } +.rs-big-emoji { font-size: 54px; line-height: 1; margin-bottom: 6px; } +.rs-loading { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 80px 0; color: var(--rs-muted); font-weight: 650; } +.rs-spinner { width: 22px; height: 22px; border-radius: 50%; border: 3px solid var(--rs-primary-soft); border-top-color: var(--rs-primary); animation: rs-spin .8s linear infinite; } +@keyframes rs-spin { to { transform: rotate(360deg); } } + +/* Platforms. */ +.rs-platforms { display: flex; flex-wrap: wrap; gap: 6px; } +.rs-pf { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 750; } +.rs-pf.big { padding: 6px 14px; font-size: 14px; } +.rs-pf.tt { background: #111; color: #fff; box-shadow: inset 2px 0 0 #25f4ee, inset -2px 0 0 #fe2c55; } +.rs-pf.ig { background: linear-gradient(135deg, #f58529, #dd2a7b 55%, #8134af); color: #fff; } +.rs-pf.yt { background: #ff0033; color: #fff; } + +/* Switch. */ +.rs-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 13px; font-weight: 750; } +.rs-switch input { position: absolute; opacity: 0; pointer-events: none; } +.rs-switch-track { position: relative; width: 40px; height: 23px; border-radius: 999px; background: #d4d2df; transition: background .2s; } +.rs-switch-dot { position: absolute; top: 3px; left: 3px; width: 17px; height: 17px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .2); transition: transform .2s; } +.rs-switch input:checked + .rs-switch-track { background: var(--rs-good); } +.rs-switch input:checked + .rs-switch-track .rs-switch-dot { transform: translateX(17px); } +.rs-switch input:focus-visible + .rs-switch-track { box-shadow: 0 0 0 3px var(--rs-primary-soft); } +.rs-switch-label { color: var(--rs-muted); } +.rs-switch input:checked ~ .rs-switch-label { color: var(--rs-good); } + +/* Home: the series. */ +.rs-series-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: 18px; } +.rs-series-card { display: flex; flex-direction: column; background: var(--rs-card); border: 1px solid var(--rs-line); border-radius: 20px; overflow: hidden; box-shadow: var(--rs-shadow); transition: transform .15s, box-shadow .2s; } +.rs-series-card:hover { transform: translateY(-3px); box-shadow: var(--rs-shadow-lift); } +.rs-series-card.paused { opacity: .75; } +.rs-cover { position: relative; height: 170px; overflow: hidden; display: grid; place-items: center; } +.rs-cover video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.rs-cover-emoji { font-size: 44px; filter: drop-shadow(0 4px 10px rgba(0, 0, 0, .25)); } +.rs-series-body { display: flex; flex-direction: column; gap: 10px; padding: 16px 18px 18px; } +.rs-series-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.rs-series-name { font-size: 18px; font-weight: 800; color: var(--rs-ink); letter-spacing: -.01em; } +.rs-series-niche { margin: 0; color: var(--rs-muted); font-size: 14px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.rs-mini-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 13px; color: var(--rs-muted); } +.rs-mini-stats b { color: var(--rs-ink); } +.rs-series-next { display: flex; align-items: center; gap: 8px; padding-top: 10px; border-top: 1px solid var(--rs-line); font-size: 13px; font-weight: 650; } +.rs-add { align-items: center; justify-content: center; gap: 4px; min-height: 300px; border: 2px dashed #d9d6e6; background: transparent; box-shadow: none; color: var(--rs-muted); text-align: center; } +.rs-add b { color: var(--rs-ink); font-size: 17px; } +.rs-add-plus { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 6px; border-radius: 50%; background: var(--rs-primary-soft); color: var(--rs-primary); font-size: 26px; font-weight: 800; } + +/* Home, before there's a series. */ +.rs-welcome { max-width: 980px; margin: 0 auto; display: flex; flex-direction: column; gap: 40px; animation: rs-in .3s ease-out; } +.rs-hero { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 40px 0 0; text-align: center; } +.rs-hero h1 { font-size: clamp(34px, 6vw, 56px); font-weight: 850; letter-spacing: -.035em; } +.rs-hero p { margin: 0; max-width: 560px; font-size: 17px; color: var(--rs-muted); } +.rs-hero .rs-small { font-size: 13px; } +.rs-how-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; } +.rs-how { position: relative; padding: 26px 22px; background: var(--rs-card); border: 1px solid var(--rs-line); border-radius: 20px; box-shadow: var(--rs-shadow); } +.rs-how-n { position: absolute; top: 18px; right: 20px; font-size: 13px; font-weight: 800; color: var(--rs-primary); } +.rs-how-icon { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 14px; border-radius: 14px; background: var(--rs-primary-soft); font-size: 26px; } +.rs-how p { margin: 6px 0 0; color: var(--rs-muted); } + +/* Create a series. */ +.rs-create { max-width: 900px; } +.rs-steps { display: flex; gap: 8px; margin: 0; padding: 0; list-style: none; } +.rs-steps li { flex: 1; display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 14px; background: var(--rs-card); border: 1px solid var(--rs-line); color: var(--rs-muted); font-weight: 700; font-size: 14px; } +.rs-steps li.on { border-color: var(--rs-primary); color: var(--rs-ink); box-shadow: 0 0 0 3px var(--rs-primary-soft); } +.rs-steps li.done { color: var(--rs-good); } +.rs-step-n { display: grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--rs-bg); font-size: 13px; font-weight: 800; } +.rs-steps li.on .rs-step-n { background: var(--rs-primary); color: #fff; } +.rs-steps li.done .rs-step-n { background: #d1fae5; color: var(--rs-good); } +.rs-q { font-size: 22px; margin-top: 6px; } +.rs-tiles { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } +.rs-tiles.voices, .rs-tiles.freq { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); } +.rs-tiles.platforms { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); } +.rs-tile { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 108px; padding: 16px 12px; border: 2px solid var(--rs-line); border-radius: 16px; background: var(--rs-card); text-align: center; cursor: pointer; transition: border-color .15s, transform .12s, box-shadow .15s; } +.rs-tile:hover { border-color: #cfc2f5; transform: translateY(-2px); box-shadow: var(--rs-shadow); } +.rs-tile.on { border-color: var(--rs-primary); background: var(--rs-primary-soft); box-shadow: 0 0 0 3px rgba(124, 58, 237, .12); } +.rs-tile.on::after { content: '✓'; position: absolute; top: 8px; right: 10px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: var(--rs-primary); color: #fff; font-size: 11px; font-weight: 900; } +.rs-tile b { color: var(--rs-ink); font-weight: 750; } +.rs-tile small { color: var(--rs-muted); font-size: 12px; } +.rs-tile-emoji { font-size: 32px; } +.rs-tiles.styles .rs-tile { padding: 8px 8px 12px; } +.rs-swatch { display: grid; place-items: center; width: 100%; aspect-ratio: 4 / 3; border: 0; border-radius: 11px; font-size: 26px; } +.rs-swatch.small { width: 40px; aspect-ratio: 1; font-size: 17px; cursor: pointer; border: 2px solid transparent; } +.rs-swatch.small.on { border-color: var(--rs-primary); } +.rs-voice-icon { font-size: 22px; } +.rs-field { display: flex; flex-direction: column; gap: 6px; margin: 0; } +.rs-field > span { font-size: 13px; font-weight: 750; color: var(--rs-ink); } +.rs-field small { font-size: 12px; color: var(--rs-muted); } +.rs-input { width: 100%; padding: 11px 13px; border: 1px solid #dddbe8; border-radius: 12px; background: #fff; color: var(--rs-ink); font: 500 15px var(--rs-font); resize: vertical; transition: border-color .15s, box-shadow .15s; } +.rs-input:focus { outline: none; border-color: var(--rs-primary); box-shadow: 0 0 0 4px var(--rs-primary-soft); } +.rs-check-row { display: flex; gap: 12px; align-items: flex-start; padding: 16px; border-radius: 14px; background: var(--rs-primary-soft); cursor: pointer; } +.rs-check-row input { margin-top: 3px; width: 18px; height: 18px; accent-color: var(--rs-primary); } +.rs-check-row span { display: flex; flex-direction: column; } +.rs-check-row b { color: var(--rs-ink); } +.rs-check-row small { color: var(--rs-text); } +.rs-chip-check { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--rs-line); border-radius: 999px; background: #fff; font-size: 14px; font-weight: 650; cursor: pointer; } +.rs-chip-check input { accent-color: var(--rs-primary); } +.rs-wizard-foot { position: sticky; bottom: 16px; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; background: rgba(255, 255, 255, .92); backdrop-filter: blur(8px); border: 1px solid var(--rs-line); border-radius: 18px; box-shadow: var(--rs-shadow-lift); } +.rs-why { font-size: 13px; font-weight: 650; color: var(--rs-muted); } + +/* A series. */ +.rs-series-hero { display: flex; gap: 22px; align-items: stretch; padding: 18px; background: var(--rs-card); border: 1px solid var(--rs-line); border-radius: 22px; box-shadow: var(--rs-shadow); } +.rs-hero-cover { position: relative; flex: none; width: 150px; aspect-ratio: 9 / 16; border-radius: 16px; overflow: hidden; display: grid; place-items: center; } +.rs-hero-cover video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.rs-hero-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; justify-content: center; } +.rs-hero-text h1 { font-size: 28px; } +.rs-filters { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 2px; } +.rs-filter { flex: none; display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border: 1px solid var(--rs-line); border-radius: 999px; background: var(--rs-card); font-weight: 650; font-size: 14px; cursor: pointer; } +.rs-filter span { padding: 0 7px; border-radius: 999px; background: var(--rs-bg); font-size: 12px; font-weight: 750; } +.rs-filter.on { background: var(--rs-ink); border-color: var(--rs-ink); color: #fff; } +.rs-filter.on span { background: rgba(255, 255, 255, .18); } +.rs-video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 16px; } +.rs-video-tile { display: flex; flex-direction: column; gap: 6px; padding: 0; border: 0; background: none; text-align: left; cursor: pointer; } +.rs-thumb { position: relative; aspect-ratio: 9 / 16; border-radius: 16px; overflow: hidden; background: #1e1b2e; box-shadow: var(--rs-shadow); transition: transform .15s, box-shadow .2s; } +.rs-video-tile:hover .rs-thumb { transform: translateY(-3px); box-shadow: var(--rs-shadow-lift); } +.rs-thumb video { width: 100%; height: 100%; object-fit: cover; pointer-events: none; } +.rs-thumb.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 12px; background: linear-gradient(160deg, #f4f1ff, #fdf2f8); text-align: center; } +.rs-thumb.empty span { font-size: 34px; } +.rs-thumb.empty small { color: var(--rs-muted); font-weight: 650; } +.rs-thumb.empty.rendering span { animation: rs-spin 2.4s linear infinite; } +.rs-thumb.empty.failed { background: linear-gradient(160deg, #fff1f2, #fff7ed); } +.rs-play { position: absolute; left: 50%; top: 50%; display: grid; place-items: center; width: 46px; height: 46px; margin: -23px 0 0 -23px; border-radius: 50%; background: rgba(255, 255, 255, .9); color: var(--rs-ink); font-size: 16px; opacity: 0; transition: opacity .15s; } +.rs-video-tile:hover .rs-play { opacity: 1; } +.rs-dur { position: absolute; right: 8px; bottom: 8px; padding: 1px 7px; border-radius: 6px; background: rgba(0, 0, 0, .65); color: #fff; font-size: 12px; font-weight: 700; } +.rs-status-pill { align-self: flex-start; margin-top: 2px; padding: 2px 9px; border-radius: 999px; background: var(--rs-bg); font-size: 11px; font-weight: 800; } +.rs-status-pill.rendered { background: #fff4e5; color: #b45309; } +.rs-status-pill.approved { background: #e0f2fe; color: #0369a1; } +.rs-status-pill.posted { background: #d1fae5; color: #047857; } +.rs-status-pill.failed, .rs-status-pill.rejected { background: #ffe4e6; color: #be123c; } +.rs-tile-title { color: var(--rs-ink); font-size: 14px; font-weight: 750; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.rs-tile-meta { color: var(--rs-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rs-idea { display: flex; gap: 10px; } +.rs-idea .rs-input { flex: 1; } +.rs-notes { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 10px; } +.rs-notes li { padding: 12px 14px; border-radius: 12px; background: var(--rs-bg); border-left: 3px solid var(--rs-primary); } +.rs-notes p { margin: 0; color: var(--rs-ink); font-weight: 600; } +.rs-notes small { color: var(--rs-muted); } +.rs-settings { display: flex; flex-direction: column; gap: 14px; margin-top: 14px; } +.rs-style-pick { gap: 6px; } + +/* Review. */ +.rs-deck-card { display: flex; gap: 28px; align-items: flex-start; padding: 22px; background: var(--rs-card); border: 1px solid var(--rs-line); border-radius: 24px; box-shadow: var(--rs-shadow-lift); transition: transform .35s ease-in, opacity .35s; } +.rs-deck-card.fly-right { transform: translateX(120px) rotate(4deg); opacity: 0; } +.rs-deck-card.fly-left { transform: translateX(-120px) rotate(-4deg); opacity: 0; } +.rs-deck-video-wrap { flex: none; width: min(360px, 42vw); aspect-ratio: 9 / 16; max-height: calc(100dvh - 180px); border-radius: 18px; overflow: hidden; background: #000; } +.rs-deck-video { width: 100%; height: 100%; object-fit: contain; } +.rs-deck-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12px; } +.rs-deck-count { align-self: flex-start; padding: 3px 10px; border-radius: 999px; background: var(--rs-primary-soft); color: var(--rs-primary); font-size: 12px; font-weight: 800; } +.rs-deck-title { font-size: 26px; } +.rs-deck-hook { margin: 0; font-size: 17px; font-style: italic; color: var(--rs-text); } +.rs-deck-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; } +.rs-sendback { display: flex; flex-direction: column; gap: 10px; padding: 14px; border-radius: 14px; background: #fff1f2; } +.rs-deck-more summary { cursor: pointer; font-weight: 700; color: var(--rs-primary); } +.rs-deck-more .rs-scenes { margin-top: 10px; } +.rs-deck-side .rs-btn.ghost { height: auto; padding: 0; font-size: 13px; text-decoration: underline; } +.rs-all-done h2 { font-size: 24px; } + +/* Crew. */ +.rs-crew-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 16px; } +.rs-crew-card { display: flex; flex-direction: column; gap: 6px; padding: 22px 20px; background: var(--rs-card); border: 1px solid var(--rs-line); border-radius: 20px; box-shadow: var(--rs-shadow); } +.rs-crew-avatar { display: grid; place-items: center; width: 60px; height: 60px; margin-bottom: 6px; border-radius: 18px; background: var(--role); font-size: 30px; } +.rs-crew-members { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } +.rs-member { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 10px 12px; border-radius: 12px; background: var(--rs-bg); } +.rs-member b { color: var(--rs-ink); } +.rs-member small { width: 100%; color: var(--rs-muted); font-size: 12px; } +.rs-member.vacant { border: 1px dashed #d9d6e6; background: transparent; color: var(--rs-muted); margin-top: 8px; } +.rs-mood { padding: 1px 8px; border-radius: 999px; background: #e2e8f0; font-size: 11px; font-weight: 800; } +.rs-mood.working { background: #dbeafe; color: #1d4ed8; } +.rs-mood.idle, .rs-mood.done { background: #d1fae5; color: #047857; } +.rs-mood.needs_input { background: #ffe4e6; color: #be123c; } + +/* Analytics. */ +.rs-goal { display: flex; flex-direction: column; gap: 6px; } +.rs-goal small { color: var(--rs-muted); } +.rs-bar, .rs-top-bar { position: relative; height: 10px; border-radius: 999px; background: var(--rs-bg); overflow: hidden; } +.rs-bar i, .rs-top-bar i { position: absolute; inset: 0 auto 0 0; border-radius: 999px; background: linear-gradient(90deg, var(--rs-primary), var(--rs-pink)); min-width: 4px; } +.rs-top { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; counter-reset: top; } +.rs-top li { counter-increment: top; } +.rs-top-row { display: grid; grid-template-columns: 26px minmax(0, 2fr) minmax(80px, 1fr) 60px; align-items: center; gap: 12px; width: 100%; padding: 8px 6px; border: 0; border-radius: 10px; background: none; text-align: left; cursor: pointer; } +.rs-top-row::before { content: counter(top); font-weight: 800; color: var(--rs-muted); text-align: center; } +.rs-top-row:hover { background: var(--rs-bg); } +.rs-top-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; color: var(--rs-ink); } +.rs-top-row b { text-align: right; color: var(--rs-ink); } + +/* Accounts. */ +.rs-accounts { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } +.rs-account { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding: 22px; background: var(--rs-card); border: 1px solid var(--rs-line); border-radius: 20px; box-shadow: var(--rs-shadow); } +.rs-account.on { border-color: #a7f3d0; } +.rs-account-state { display: flex; align-items: center; gap: 8px; font-weight: 750; color: var(--rs-ink); } + +/* A reel in full. */ +.rs-backdrop { position: fixed; inset: 0; z-index: 50; display: flex; align-items: flex-start; justify-content: center; padding: 32px 16px; overflow-y: auto; background: rgba(20, 18, 31, .5); backdrop-filter: blur(3px); animation: rs-fade .15s; } +@keyframes rs-fade { from { opacity: 0; } } +.rs-modal { width: min(940px, 100%); background: var(--rs-card); border-radius: 22px; box-shadow: 0 30px 80px rgba(20, 18, 31, .35); animation: rs-in .2s ease-out; } +.rs-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 22px; border-bottom: 1px solid var(--rs-line); } +.rs-x { flex: none; display: grid; place-items: center; width: 38px; height: 38px; border: 0; border-radius: 12px; background: var(--rs-bg); font-size: 16px; font-weight: 800; cursor: pointer; } +.rs-x:hover { background: #ffe4e6; color: #be123c; } +.rs-modal-body { display: flex; flex-direction: column; gap: 14px; padding: 20px 22px 24px; } +.rs-modal .rs-video { flex: none; width: 260px; aspect-ratio: 9 / 16; border-radius: 16px; background: #000; } +.rs-detail { display: flex; gap: 22px; align-items: flex-start; } +.rs-detail-text { flex: 1; min-width: 0; } +.rs-kicker { margin: 0; font-size: 13px; color: var(--rs-muted); } +.rs-sec h3 { margin: 10px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--rs-muted); } +.rs-scenes { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 6px; } +.rs-scenes p { margin: 0; color: var(--rs-ink); } +.rs-visual { font-size: 13px; color: var(--rs-muted) !important; } +.rs-caption { margin: 0; padding: 12px 14px; white-space: pre-wrap; font: 500 14px var(--rs-font); background: var(--rs-bg); border-radius: 12px; } +.rs-posts { margin: 0; padding-left: 20px; } +.rs-posts a { color: var(--rs-primary); font-weight: 700; } +.rs-alert { margin: 0; padding: 12px 14px; border-radius: 12px; background: #fff1f2; color: #9f1239; font-weight: 650; overflow-wrap: anywhere; } +.rs-alert.info { background: #fff7ed; color: #9a3412; } +.rs-hook { margin: 0; font-style: italic; color: var(--rs-ink); } +.rs-review-box { display: flex; flex-direction: column; gap: 10px; } +.rs-actions { justify-content: flex-end; } +.bad { color: var(--rs-bad); } + +.rs-toasts { position: fixed; right: 20px; bottom: 20px; z-index: 60; display: flex; flex-direction: column; gap: 10px; max-width: min(420px, calc(100vw - 32px)); } +.rs-toast { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-radius: 14px; background: var(--rs-ink); color: #fff; font-weight: 650; box-shadow: 0 12px 30px rgba(20, 18, 31, .3); animation: rs-in .2s ease-out; transition: opacity .4s, transform .4s; } +.rs-toast span { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: var(--rs-good); font-size: 12px; } +.rs-toast.bad span { background: var(--rs-bad); } +.rs-toast.out { opacity: 0; transform: translateY(8px); } + +/* A phone: the sidebar becomes a bar along the bottom. */ +@media (max-width: 820px) { + .rs-app { grid-template-columns: 1fr; } + .rs-side { position: fixed; inset: auto 0 0 0; z-index: 40; height: auto; flex-direction: row; align-items: center; gap: 0; padding: 6px 6px max(6px, env(safe-area-inset-bottom)); border-right: 0; border-top: 1px solid var(--rs-line); box-shadow: 0 -8px 24px rgba(20, 18, 31, .06); } + .rs-brand, .rs-side-foot { display: none; } + .rs-nav { flex: 1; flex-direction: row; justify-content: space-around; } + .rs-nav-item { position: relative; flex-direction: column; gap: 2px; padding: 6px 4px; font-size: 11px; min-width: 56px; text-align: center; } + .rs-nav-icon { font-size: 20px; } + .rs-badge { position: absolute; top: 0; right: 6px; margin: 0; min-width: 18px; padding: 0 5px; font-size: 11px; } + .rs-new { order: 3; width: 48px; height: 48px; padding: 0; border-radius: 50%; font-size: 22px; margin: 0 4px; } + .rs-new-label { display: none; } + .rs-main { padding: 20px 16px 110px; } + h1 { font-size: 25px; } + .rs-series-hero { flex-direction: column; } + .rs-hero-cover { width: 100%; aspect-ratio: 16 / 9; } + .rs-deck-card, .rs-detail { flex-direction: column; } + .rs-deck-card { padding: 14px; } + .rs-deck-video-wrap { width: 100%; max-height: 70dvh; } + .rs-modal .rs-video { width: 100%; max-width: 320px; align-self: center; } + .rs-steps li span:last-child { display: none; } + .rs-steps li { justify-content: center; } + .rs-wizard-foot { bottom: 84px; } + .rs-video-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; } + .rs-toasts { bottom: 90px; right: 12px; } +} diff --git a/src/client/reels/review.ts b/src/client/reels/review.ts new file mode 100644 index 000000000..4acf7f04c --- /dev/null +++ b/src/client/reels/review.ts @@ -0,0 +1,102 @@ +// Reviewing the new videos one at a time, like a feed: watch it, then approve it (A or →) or send it +// back with a note for the writer (S, then type). The next one comes in on its own. + +import { h } from '../ui/dom'; +import { api, type Reel } from './api'; +import type { Page } from './main'; + +/** The ones skipped this visit, so they come round again last. */ +const skipped = new Set(); +let keys: ((e: KeyboardEvent) => void) | undefined; + +export function reviewPage(p: Page): HTMLElement { + const root = h('section.rs-page.rs-deck-page'); + const queue = () => { + const waiting = p.data.reels.filter((r) => r.status === 'rendered'); + return [...waiting.filter((r) => !skipped.has(r.id)), ...waiting.filter((r) => skipped.has(r.id))]; + }; + let done = 0; + + const draw = async () => { + const list = queue(); + if (keys) document.removeEventListener('keydown', keys); + keys = undefined; + if (!list.length) { + root.replaceChildren( + h('header.rs-page-head', {}, h('div', {}, h('h1', {}, 'Review'), h('p.rs-sub', {}, 'New videos wait here for a yes or a no before they post.'))), + h('div.rs-empty.rs-all-done', {}, h('div.rs-big-emoji', {}, done ? '🎉' : '🍿'), h('h2', {}, done ? `All caught up: ${done} reviewed` : 'Nothing to review'), h('p', {}, done ? 'Approved videos post on each series’ schedule.' : 'New videos show up here as soon as they’re made.'), h('a.rs-btn.primary', { href: '#/' }, 'Back to my series')), + ); + return; + } + const row = list[0]; + let reel: Reel; + try { + reel = await api.reel(row.id); + } catch (e) { + p.toast((e as Error).message, true); + return; + } + const series = p.data.series.find((s) => s.id === reel.seriesId); + const video = h('video.rs-deck-video', { src: api.videoUrl(reel.id, reel.updatedAt), controls: true, autoplay: true, playsinline: true, preload: 'auto' }); + const note = h('textarea.rs-input', { rows: 2, placeholder: 'What should the writer fix? e.g. “the hook is too slow”', 'aria-label': 'Note for the writer' }); + const noteBox = h('div.rs-sendback', { hidden: true }, note, h('div.rs-row', {}, h('button.rs-btn.danger', { type: 'button', onclick: () => void decide(false) }, 'Send back'), h('button.rs-btn.ghost', { type: 'button', onclick: () => ((noteBox.hidden = true), approve.focus()) }, 'Cancel'))); + const approve = h('button.rs-btn.primary.big', { type: 'button' }, '✅ Approve', h('kbd', {}, 'A')); + const reject = h('button.rs-btn.big.soft-danger', { type: 'button' }, '✏️ Send back', h('kbd', {}, 'S')); + const skip = h('button.rs-btn.ghost', { type: 'button' }, 'Skip for now'); + const card = h('div.rs-deck-card'); + + const decide = async (yes: boolean) => { + if (!yes && !note.value.trim()) { + noteBox.hidden = false; + note.focus(); + return; + } + approve.disabled = reject.disabled = true; + card.classList.add(yes ? 'fly-right' : 'fly-left'); + const ok = await p.act('/review', { reel: reel.id, approve: yes, note: note.value }, yes ? 'Approved: it posts on schedule' : 'Sent back to the writer'); + if (ok === undefined) { + card.classList.remove('fly-right', 'fly-left'); + approve.disabled = reject.disabled = false; + return; + } + done++; + skipped.delete(reel.id); + void draw(); + }; + approve.addEventListener('click', () => void decide(true)); + reject.addEventListener('click', () => void decide(false)); + skip.addEventListener('click', () => { + skipped.add(reel.id); + void draw(); + }); + keys = (e: KeyboardEvent) => { + if ((e.target as HTMLElement).matches('input, textarea, select') || !root.isConnected) return; + if (e.key === 'a' || e.key === 'ArrowRight') void decide(true); + else if (e.key === 's' || e.key === 'ArrowLeft') { + e.preventDefault(); + noteBox.hidden = false; + note.focus(); + } + }; + document.addEventListener('keydown', keys); + + card.append( + h('div.rs-deck-video-wrap', {}, video), + h( + 'div.rs-deck-side', + {}, + h('div.rs-deck-count', {}, `${list.length} to review`), + h('span.rs-eyebrow', {}, series?.name ?? 'Series'), + h('h2.rs-deck-title', {}, reel.title), + reel.hook ? h('p.rs-deck-hook', {}, `“${reel.hook}”`) : null, + h('div.rs-deck-actions', {}, approve, reject), + noteBox, + h('details.rs-fold.rs-deck-more', {}, h('summary', {}, 'Script and caption'), h('ol.rs-scenes', {}, ...(reel.scenes ?? []).map((s) => h('li', {}, s.narration))), reel.postCaption ? h('pre.rs-caption', {}, reel.postCaption) : null), + h('p.rs-sub.small', {}, `Written by ${reel.scriptedBy ?? reel.addedBy} · ${Math.round(reel.duration ?? 0)}s · `, skip), + ), + ); + root.replaceChildren(h('header.rs-page-head', {}, h('div', {}, h('h1', {}, 'Review'), h('p.rs-sub', {}, 'Watch it, then approve or send it back. Approved videos post on schedule.'))), card); + }; + void draw(); + return root; +} diff --git a/src/client/reels/series.ts b/src/client/reels/series.ts new file mode 100644 index 000000000..a9c1ba202 --- /dev/null +++ b/src/client/reels/series.ts @@ -0,0 +1,136 @@ +// One series: its videos as a grid to filter by where they are, what's coming up, the crew's notes, +// adding an idea by hand, and its settings. + +import { h } from '../ui/dom'; +import { PLATFORMS, STATUS_LABEL, api, short, when, type ReelRow, type ReelStatus, type Series } from './api'; +import { autopilot, cover, platformBadges } from './home'; +import { openReel } from './modal'; +import { FREQUENCIES, STYLES, VOICES } from './presets'; +import type { Page } from './main'; + +const FILTERS: { id: string; label: string; statuses: ReelStatus[] }[] = [ + { id: 'all', label: 'All', statuses: [] }, + { id: 'review', label: '👀 To review', statuses: ['rendered'] }, + { id: 'ready', label: '✅ Scheduled', statuses: ['approved', 'posting'] }, + { id: 'posted', label: '🚀 Posted', statuses: ['posted'] }, + { id: 'making', label: '⚙️ In the making', statuses: ['scripted', 'rendering'] }, + { id: 'ideas', label: '💡 Ideas', statuses: ['idea', 'rejected'] }, + { id: 'failed', label: '⚠️ Failed', statuses: ['failed'] }, +]; +const filterOf: Record = {}; + +/** One video in the grid: its first frame (or where it is, before there's a video), its title and status. */ +function tile(p: Page, r: ReelRow): HTMLElement { + const views = (r.posts ?? []).reduce((n, x) => n + (x.views ?? 0), 0); + const thumb = r.seconds + ? h('div.rs-thumb', {}, h('video', { src: `${api.videoUrl(r.id)}#t=1.2`, muted: true, preload: 'metadata', playsinline: true }), h('span.rs-play', {}, '▶'), h('span.rs-dur', {}, `0:${String(r.seconds).padStart(2, '0')}`)) + : h('div.rs-thumb.empty', { class: r.status }, h('span', {}, { idea: '💡', scripted: '✍️', rendering: '⚙️', rejected: '✏️', failed: '⚠️' }[r.status as string] ?? '🎬'), h('small', {}, r.status === 'rendering' ? 'Making the video…' : r.status === 'scripted' ? 'In line to be made' : r.status === 'failed' ? 'Couldn’t be made' : r.status === 'rejected' ? 'Being rewritten' : 'Waiting for a script')); + return h( + 'button.rs-video-tile', + { type: 'button', onclick: () => void openReel(p, r.id), title: r.title }, + thumb, + h('span.rs-status-pill', { class: r.status }, STATUS_LABEL[r.status]), + h('b.rs-tile-title', {}, r.title), + h('small.rs-tile-meta', {}, views ? `👁 ${short(views)} views` : r.error ? r.error.slice(0, 60) : r.review && r.status === 'rejected' ? `Editor: ${r.review.slice(0, 60)}` : `by ${r.by}`), + ); +} + +function settingsForm(p: Page, s: Series): HTMLElement { + const input = (name: string, value = '', placeholder = '') => h('input.rs-input', { name, value, placeholder }); + const select = (name: string, value: string, options: [string, string][]) => h('select.rs-input', { name }, ...options.map(([v, l]) => h('option', { value: v, selected: v === value }, l))); + const style = STYLES.find((x) => x.prompt === s.style); + const styleArea = h('textarea.rs-input', { name: 'style', rows: 2 }); + styleArea.value = s.style; + const niche = h('textarea.rs-input', { name: 'niche', rows: 2 }); + niche.value = s.niche; + const field = (label: string, el: HTMLElement, hint?: string) => h('label.rs-field', {}, h('span', {}, label), el, hint ? h('small', {}, hint) : null); + const form = h( + 'form.rs-settings', + { + onsubmit: async (e: Event) => { + e.preventDefault(); + const f = new FormData(form); + const v = (k: string) => String(f.get(k) ?? '').trim(); + await p.act('/series/set', { series: s.id, name: v('name'), niche: v('niche'), style: v('style'), voice: v('voice'), tone: v('tone'), cta: v('cta'), hashtags: v('hashtags'), music: v('music'), postsPerDay: Number(v('postsPerDay')), platforms: f.getAll('platforms').map(String), tiktokPrivacy: v('tiktokPrivacy'), youtubePrivacy: v('youtubePrivacy') }, 'Saved'); + }, + }, + h('div.rs-two', {}, field('Name', input('name', s.name)), field('Posts', select('postsPerDay', String(s.postsPerDay), [...FREQUENCIES.map((f) => [String(f.perDay), f.label] as [string, string]), ...(FREQUENCIES.some((f) => f.perDay === s.postsPerDay) ? [] : [[String(s.postsPerDay), `${s.postsPerDay} a day`] as [string, string]])]))), + field('What it’s about', niche), + h('div.rs-row.rs-style-pick', {}, ...STYLES.map((x) => h('button.rs-swatch.small', { type: 'button', title: x.name, class: x === style ? 'on' : undefined, style: `background:${x.swatch}`, onclick: () => (styleArea.value = x.prompt) }, x.emoji))), + field('Art style', styleArea, 'Pick a swatch above, or describe your own'), + h('div.rs-two', {}, field('Voice', select('voice', s.voice, [...VOICES.map((v) => [v.id, `${v.name} · ${v.what}`] as [string, string]), ...(VOICES.some((v) => v.id === s.voice) ? [] : [[s.voice, s.voice] as [string, string]])])), field('How it’s read', input('tone', s.tone, 'calm, low, suspenseful'))), + h('div.rs-two', {}, field('Call to action', input('cta', s.cta, 'Follow for part 2')), field('Hashtags', input('hashtags', s.hashtags.join(' '), 'history mystery'))), + field('Background music', input('music', s.music, 'track.mp3'), 'A file in .agent-office/reels/music on the office’s computer'), + h('div.rs-field', {}, h('span', {}, 'Posts to'), h('div.rs-row', {}, ...PLATFORMS.map((pl) => h('label.rs-chip-check', {}, h('input', { type: 'checkbox', name: 'platforms', value: pl, checked: s.platforms.includes(pl) }), pl)))), + h('div.rs-two', {}, field('Who sees TikTok posts', select('tiktokPrivacy', s.tiktokPrivacy, [['SELF_ONLY', 'Only me (until TikTok approves the app)'], ['FOLLOWER_OF_CREATOR', 'Followers'], ['MUTUAL_FOLLOW_FRIENDS', 'Friends'], ['PUBLIC_TO_EVERYONE', 'Everyone']])), field('YouTube', select('youtubePrivacy', s.youtubePrivacy, [['private', 'Private'], ['unlisted', 'Unlisted'], ['public', 'Public']]))), + h('div.rs-row', {}, h('button.rs-btn.primary', { type: 'submit' }, 'Save changes')), + ); + return form; +} + +function addIdea(p: Page, s: Series): HTMLElement { + const title = h('input.rs-input', { name: 'title', placeholder: 'A story idea, e.g. “the lighthouse keepers who vanished”', 'aria-label': 'Idea' }); + const form = h( + 'form.rs-idea', + { + onsubmit: async (e: Event) => { + e.preventDefault(); + if (await p.act('/idea', { series: s.id, title: title.value }, 'Idea added: the writer picks it up next')) form.reset(); + }, + }, + title, + h('button.rs-btn', { type: 'submit' }, '💡 Add'), + ); + return form; +} + +export function seriesPage(p: Page, id: string): HTMLElement { + const s = p.data.series.find((x) => x.id === id); + if (!s) return h('section.rs-page', {}, p.loaded ? h('div.rs-empty', {}, h('h2', {}, 'That series is gone'), h('a.rs-btn.primary', { href: '#/' }, 'Back to my series')) : h('div.rs-loading', {}, h('div.rs-spinner'), 'Loading…')); + const mine = p.data.reels.filter((r) => r.series === s.id); + const filter = FILTERS.find((f) => f.id === filterOf[s.id]) ?? FILTERS[0]; + const shown = filter.statuses.length ? mine.filter((r) => filter.statuses.includes(r.status)) : mine; + const latest = mine.find((r) => r.seconds); + const c = s.pipeline; + const views = mine.reduce((n, r) => n + (r.posts ?? []).reduce((m, x) => m + (x.views ?? 0), 0), 0); + const sig = `${mine.map((r) => r.id + r.status).join(',')}|${filter.id}|${s.paused}|${s.notes.length}`; + + const grid = shown.length + ? h('div.rs-video-grid', {}, ...shown.map((r) => tile(p, r))) + : h('div.rs-empty.small', {}, h('p', {}, filter.id === 'all' ? 'No videos yet: the crew is on it. Ideas show up here first, then scripts, then videos.' : 'Nothing here right now.')); + + return h( + 'section.rs-page', + { 'data-sig': sig }, + h('a.rs-crumb', { href: '#/' }, '← My series'), + h( + 'header.rs-series-hero', + {}, + cover(s, latest, 'rs-hero-cover'), + h( + 'div.rs-hero-text', + {}, + h('div.rs-row', {}, h('h1', {}, s.name), autopilot(p, s)), + h('p.rs-sub', {}, s.niche), + platformBadges(s.platforms), + h('div.rs-kpis.small', {}, kpi('Posted', c.posted), kpi('Views', short(views)), kpi('To review', c.toReview), kpi(s.paused ? 'Paused' : c.approved ? 'Next post' : 'Next post, once one’s approved', s.paused || !c.approved ? '—' : when(s.nextPostAfter && s.nextPostAfter > Date.now() ? s.nextPostAfter : Date.now()))), + ), + ), + c.toReview ? h('a.rs-banner', { href: '#/review' }, h('span.rs-banner-icon', {}, '👀'), h('span', {}, h('b', {}, `${c.toReview} new video${c.toReview === 1 ? '' : 's'} to review.`), ' Nothing posts until it’s approved.'), h('span.rs-banner-go', {}, 'Review →')) : null, + h( + 'div.rs-filters', + { role: 'tablist' }, + ...FILTERS.map((f) => { + const n = f.statuses.length ? mine.filter((r) => f.statuses.includes(r.status)).length : mine.length; + if (!n && f.id !== 'all' && f.id !== filter.id) return null; + return h('button.rs-filter', { type: 'button', role: 'tab', 'aria-selected': f === filter ? 'true' : 'false', class: f === filter ? 'on' : undefined, onclick: () => ((filterOf[s.id] = f.id), p.go(`/series/${s.id}`)) }, f.label, h('span', {}, n)); + }), + ), + grid, + h('div.rs-panel', {}, h('h3', {}, '💡 Add your own idea'), h('p.rs-sub', {}, 'Got a story in mind? The writer turns it into a script.'), addIdea(p, s)), + h('details.rs-panel.rs-fold', { open: s.notes.length > 0 && s.notes.length < 4 }, h('summary', {}, h('h3', {}, `📝 What the crew learned (${s.notes.length})`)), s.notes.length ? h('ul.rs-notes', {}, ...[...s.notes].reverse().map((n) => h('li', {}, h('p', {}, n.text), h('small', {}, `${n.by} · ${when(n.at)}`)))) : h('p.rs-sub', {}, 'Once videos are posted, your analyst writes down what works here, and the writer follows it.')), + h('details.rs-panel.rs-fold', {}, h('summary', {}, h('h3', {}, '⚙️ Series settings')), settingsForm(p, s)), + ); +} + +const kpi = (label: string, value: string | number) => h('div.rs-kpi', {}, h('b', {}, value), h('span', {}, label)); diff --git a/src/client/reels/views.ts b/src/client/reels/views.ts new file mode 100644 index 000000000..5e014fb54 --- /dev/null +++ b/src/client/reels/views.ts @@ -0,0 +1,119 @@ +// The studio's other pages: the AI crew and hiring it, the numbers, and the accounts it posts to. + +import { h } from '../ui/dom'; +import { PLATFORMS, ROLES, short, when, type Platform, type ReelRow } from './api'; +import { openReel } from './modal'; +import type { Page } from './main'; + +const ROLE: Record<(typeof ROLES)[number], { emoji: string; title: string; what: string; color: string }> = { + scout: { emoji: '🔭', title: 'Scout', what: 'Finds story ideas that will stop the scroll', color: '#e0f2fe' }, + writer: { emoji: '✍️', title: 'Writer', what: 'Turns ideas into tight 60-second scripts', color: '#fce7f3' }, + editor: { emoji: '🎬', title: 'Editor', what: 'Watches every video and approves the good ones', color: '#ede9fe' }, + analyst: { emoji: '📈', title: 'Analyst', what: 'Reads the numbers and tells the crew what works', color: '#dcfce7' }, +}; + +const STATUS_WORDS: Record = { working: 'Working…', idle: 'Ready', done: 'Ready', needs_input: 'Needs you', starting: 'Starting', exited: 'Resting', offline: 'Offline', gone: 'Left' }; + +export function crewPage(p: Page): HTMLElement { + const d = p.data; + const seriesName = (id?: string) => (id ? d.series.find((s) => s.id === id)?.name ?? id : 'Every series'); + const cards = ROLES.map((role) => { + const r = ROLE[role]; + const members = d.crew.filter((c) => c.role === role); + return h( + 'article.rs-crew-card', + { style: `--role:${r.color}` }, + h('div.rs-crew-avatar', {}, r.emoji), + h('h3', {}, r.title), + h('p.rs-sub', {}, r.what), + members.length + ? h('div.rs-crew-members', {}, ...members.map((m) => h('div.rs-member', {}, h('b', {}, m.name), h('span.rs-mood', { class: m.status }, STATUS_WORDS[m.status] ?? m.status), h('small', {}, seriesName(m.seriesId))))) + : h('div.rs-member.vacant', {}, h('b', {}, 'Nobody yet'), h('small', {}, 'Hire one below')), + ); + }); + + const series = h('select.rs-input', { 'aria-label': 'Works on' }, h('option', { value: '' }, 'Every series'), ...d.series.map((s) => h('option', { value: s.id }, s.name))); + const floor = h('select.rs-input', { 'aria-label': 'Floor' }, ...d.floors.map((f) => h('option', { value: f.id }, f.name))); + const picks = ROLES.map((role) => h('label.rs-chip-check', {}, h('input', { type: 'checkbox', value: role, checked: !d.crew.some((c) => c.role === role) }), `${ROLE[role].emoji} ${ROLE[role].title}`)); + const hire = h('button.rs-btn.primary', { type: 'button' }, '🤝 Hire'); + hire.addEventListener('click', async () => { + const roles = picks.map((l) => l.querySelector('input')!).filter((i) => i.checked).map((i) => i.value); + if (!roles.length) return p.toast('Pick at least one role', true); + hire.disabled = true; + await p.act('/crew', { roles, series: series.value || undefined, floor: floor.value }, 'Hired: they’re at their desks and getting to work'); + hire.disabled = false; + }); + + return h( + 'section.rs-page', + {}, + h('header.rs-page-head', {}, h('div', {}, h('h1', {}, 'Your AI crew'), h('p.rs-sub', {}, 'Four agents run your pages. Each sits at a desk in the office, where you can watch them work and talk to them.'))), + h('div.rs-crew-grid', {}, ...cards), + h('div.rs-panel', {}, h('h3', {}, 'Hire crew'), h('div.rs-row', {}, ...picks), h('div.rs-two', {}, h('label.rs-field', {}, h('span', {}, 'Works on'), series), d.floors.length > 1 ? h('label.rs-field', {}, h('span', {}, 'Sits on the floor'), floor) : h('span')), h('div.rs-row', {}, hire, h('span.rs-sub.small', {}, 'Roles someone already has are skipped.'))), + ); +} + +const viewsOf = (r: ReelRow) => (r.posts ?? []).reduce((n, x) => n + (x.views ?? 0), 0); + +export function analyticsPage(p: Page): HTMLElement { + const d = p.data; + const posted = d.reels.filter((r) => r.status === 'posted' || r.posts?.length); + const total = posted.reduce((n, r) => n + viewsOf(r), 0); + const best = [...posted].sort((a, b) => viewsOf(b) - viewsOf(a)); + const top = viewsOf(best[0] ?? ({} as ReelRow)) || 1; + const fetchBtn = h('button.rs-btn.primary', { type: 'button' }, '🔄 Refresh numbers'); + fetchBtn.addEventListener('click', async () => { + fetchBtn.disabled = true; + fetchBtn.textContent = 'Asking the platforms…'; + await p.act('/stats', {}, 'Numbers updated'); + fetchBtn.disabled = false; + fetchBtn.textContent = '🔄 Refresh numbers'; + }); + const byPlatform = (pl: Platform) => posted.reduce((n, r) => n + (r.posts?.find((x) => x.platform === pl)?.views ?? 0), 0); + // The payout bars: TikTok's Creator Rewards and YouTube's Partner Program, by views (followers aren't read yet). + const goal = (label: string, have: number, need: number, what: string) => h('div.rs-goal', {}, h('div.rs-row.between', {}, h('b', {}, label), h('span', {}, `${short(have)} / ${short(need)}`)), h('div.rs-bar', {}, h('i', { style: `width:${Math.min(100, (have / need) * 100).toFixed(1)}%` })), h('small', {}, what)); + + return h( + 'section.rs-page', + {}, + h('header.rs-page-head', {}, h('div', {}, h('h1', {}, 'Analytics'), h('p.rs-sub', {}, `Last checked ${when(d.lastStats)}. Your analyst checks once a day.`)), fetchBtn), + h('div.rs-kpis', {}, ...[['Total views', short(total)], ['Videos posted', posted.length], ['TikTok views', short(byPlatform('tiktok'))], ['YouTube views', short(byPlatform('youtube'))]].map(([l, v]) => h('div.rs-kpi', {}, h('b', {}, v), h('span', {}, l)))), + h('div.rs-panel', {}, h('h3', {}, '💰 On the way to getting paid'), goal('TikTok Creator Rewards', byPlatform('tiktok'), 100_000, 'Also needs 10k followers, and videos over a minute'), goal('YouTube Partner Program (Shorts)', byPlatform('youtube'), 10_000_000, 'Views in 90 days; also needs 1,000 subscribers')), + posted.length + ? h( + 'div.rs-panel', + {}, + h('h3', {}, '🏆 Top videos'), + h( + 'ol.rs-top', + {}, + ...best.slice(0, 10).map((r) => + h('li', {}, h('button.rs-top-row', { type: 'button', onclick: () => void openReel(p, r.id) }, h('span.rs-top-title', {}, r.title), h('span.rs-top-bar', {}, h('i', { style: `width:${((viewsOf(r) / top) * 100).toFixed(1)}%` })), h('b', {}, short(viewsOf(r))))), + ), + ), + ) + : h('div.rs-empty', {}, h('div.rs-big-emoji', {}, '📊'), h('h2', {}, 'No numbers yet'), h('p', {}, 'Once videos are posted, their views, likes and comments show up here.')), + ); +} + +export function accountsPage(p: Page): HTMLElement { + const info: Record = { + tiktok: { name: 'TikTok', cls: 'tt', how: 'Needs a TikTok developer app with the Content Posting API. Until TikTok audits it, posts go out as private.' }, + instagram: { name: 'Instagram', cls: 'ig', how: 'Needs a Business or Creator account and a Meta developer app that can publish.' }, + youtube: { name: 'YouTube', cls: 'yt', how: 'Needs a Google Cloud app with the YouTube Data API, signed in to your channel.' }, + }; + return h( + 'section.rs-page', + {}, + h('header.rs-page-head', {}, h('div', {}, h('h1', {}, 'Accounts'), h('p.rs-sub', {}, 'Where your videos get posted. Each platform is connected once, by whoever runs the office.'))), + h( + 'div.rs-accounts', + {}, + ...PLATFORMS.map((pl) => { + const on = p.data.connected.includes(pl); + return h('article.rs-account', { class: on ? 'on' : undefined }, h('span.rs-pf.big', { class: info[pl].cls }, info[pl].name), h('div.rs-account-state', {}, h('span.rs-dot', { class: on ? 'on' : 'off' }), on ? 'Connected' : 'Not connected'), h('p.rs-sub', {}, on ? 'Approved videos post here on each series’ schedule.' : info[pl].how)); + }), + ), + h('div.rs-panel', {}, h('h3', {}, 'How to connect'), h('p.rs-sub', {}, 'The sign-ins go in .agent-office/reels/accounts.json on the office’s computer. The step-by-step for each platform is in docs/reels.md. No agent can read them: the crew only sees which platforms are connected.')), + ); +} diff --git a/src/server/hooks/server.ts b/src/server/hooks/server.ts index ad333aebd..57ee7f39d 100644 --- a/src/server/hooks/server.ts +++ b/src/server/hooks/server.ts @@ -1,5 +1,6 @@ // The loopback-only server for the workers' own calls: their agents' hook events, and the office's -// queue and workers for the board agents and the office-workers command. +// queue and workers for the board agents and the office-workers command, and the reels studio for +// the office-reels command. import http from 'node:http'; import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; @@ -7,6 +8,7 @@ import type { Ctx } from '../office/context.js'; import { readBody, send } from '../http/util.js'; import { officeQueue } from './office-queue.js'; import { officeWorkers } from './office-workers.js'; +import { officeReels } from '../reels/endpoint.js'; import { providerHook } from '../providers/index.js'; import type { AgentProvider } from '../../shared/providers.js'; @@ -21,6 +23,7 @@ export async function startHookServer(ctx: Ctx): Promise<{ hookServer: http.Serv } if (url.pathname === '/office/queue') return officeQueue(ctx, req, res, url); if (url.pathname === '/office/workers' || url.pathname.startsWith('/office/workers/')) return officeWorkers(ctx, req, res, url); + if (url.pathname === '/office/reels' || url.pathname.startsWith('/office/reels/')) return officeReels(ctx, req, res, url); // Each provider with hooks has its route, /hooks/ (see providers/). const route = url.pathname.startsWith('/hooks/') ? url.pathname.slice('/hooks/'.length) : ''; const hook = providerHook(route); diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..57b72c531 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -7,6 +7,7 @@ import { authRoutes } from './auth.js'; import { fileRoutes } from './files.js'; import { githubRoutes } from './github.js'; import { pageRoutes } from './pages.js'; +import { reelsRoutes } from './reels.js'; import { searchRoutes } from './search.js'; import { serviceRoutes } from './services.js'; @@ -36,6 +37,8 @@ export const routes: readonly Route[] = [ searchRoutes.search, serviceRoutes.forwards, githubRoutes.github, + reelsRoutes.api, + reelsRoutes.page, pageRoutes.office, pageRoutes.lite, pageRoutes.bundle, diff --git a/src/server/http/routes/reels.ts b/src/server/http/routes/reels.ts new file mode 100644 index 000000000..1a089c926 --- /dev/null +++ b/src/server/http/routes/reels.ts @@ -0,0 +1,106 @@ +// The reels studio's page (reels.html, src/client/reels/) and what it asks the office for: how +// everything stands, a reel in full, its video, and what a person does there (approve a reel, change +// a series, hire the crew…), which goes through the same studio the crew's office-reels command does. + +import { createReadStream, statSync } from 'node:fs'; +import path from 'node:path'; +import { studioOf } from '../../reels/index.js'; +import { hireCrew } from '../../reels/hire.js'; +import { str } from '../../office/input.js'; +import { serveFile } from '../static.js'; +import { readBody, sameOrigin, send } from '../util.js'; +import type { Route } from '../router.js'; +import type { Ctx } from '../../office/context.js'; +import type http from 'node:http'; + +/** Streams a reel's video, a range at a time when the player asks for one (to seek). */ +function streamVideo(req: http.IncomingMessage, res: http.ServerResponse, file: string) { + let size: number; + try { + size = statSync(file).size; + } catch { + return send(res, 404, { error: 'The video is gone' }); + } + const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? ''); + const head = { 'content-type': 'video/mp4', 'accept-ranges': 'bytes', 'cache-control': 'no-store' }; + if (!range || (!range[1] && !range[2])) { + res.writeHead(200, { ...head, 'content-length': size }); + return createReadStream(file).pipe(res); + } + // "bytes=-500" is the last 500 bytes. + const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2])); + const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1; + if (start > end || start >= size) { + res.writeHead(416, { 'content-range': `bytes */${size}` }).end(); + return; + } + res.writeHead(206, { ...head, 'content-length': end - start + 1, 'content-range': `bytes ${start}-${end}/${size}` }); + createReadStream(file, { start, end }).pipe(res); +} + +/** The floors a person can hire the crew onto. */ +const floorsOf = (ctx: Ctx) => [...ctx.floors.values()].map((f) => ({ id: f.id, name: f.def.name })); + +export const reelsRoutes = { + page: { path: ['/reels', '/reels.html'], auth: 'session', handle: (ctx, { res }) => serveFile(res, path.join(ctx.publicDir, 'reels.html'), false) }, + api: { + prefix: '/api/reels', + auth: 'session', + async handle(ctx, { req, res, url, path: p, session }) { + const studio = studioOf(ctx); + const me = ctx.meOf(session.account?.id); + // Someone on the shared office password has no account, so no name of their own. + const who = me.account?.name ?? 'Someone in the office'; + const action = p.slice('/api/reels'.length); + const answer = (r: unknown) => (typeof r === 'string' ? send(res, 400, { error: r }) : send(res, 200, r)); + + if (req.method === 'GET') { + if (action === '' || action === '/') return send(res, 200, { me: { name: who, admin: me.admin }, floors: floorsOf(ctx), ...studio.overview() }); + if (action === '/reel') return answer(studio.show(url.searchParams.get('id') ?? '')); + const video = /^\/video\/([\w-]+)$/.exec(action); + if (video) { + const r = studio.store.reel(video[1]); + // Only ever a file in the studio's own out folder. + if (!r?.video || path.dirname(path.resolve(r.video)) !== path.resolve(studio.store.dir, 'out')) return send(res, 404, { error: 'No video for that reel' }); + return streamVideo(req, res, r.video); + } + return send(res, 404, { error: 'Not found' }); + } + if (req.method !== 'POST') return send(res, 405, { error: 'GET or POST' }); + // Another site mustn't be able to post on a visitor's behalf. + if (!sameOrigin(req, ctx.cfg)) return send(res, 403, { error: 'Not from the office' }); + let body: Record; + try { + const parsed: unknown = JSON.parse((await readBody(req)) || '{}'); + body = parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + return send(res, 400, { error: 'Send JSON' }); + } + switch (action) { + case '/series': + return answer(studio.addSeries(body, who)); + case '/series/set': + return answer(studio.setSeries(body.series, body, who)); + case '/idea': + return answer(studio.addIdea(body.series, body, who)); + case '/review': + return answer(studio.review(body.reel, body.approve === true, str(body.note, 1000), who)); + case '/remake': + return answer(studio.remake(body.reel)); + case '/post': + return answer(await studio.postNow(body.reel, who)); + case '/stats': + return answer(await studio.stats().catch((e: Error) => `Fetching the numbers failed: ${e.message}`)); + case '/note': + return answer(studio.note(body.series, body.text, who)); + case '/crew': { + const floor = ctx.floors.get(str(body.floor, 64)) ?? ctx.floors.values().next().value; + if (!floor) return send(res, 409, { error: 'There is no floor to hire onto' }); + const r = hireCrew(ctx, floor, who, session.account?.id, body); + return send(res, r.status, r.body); + } + } + return send(res, 404, { error: 'Not found' }); + }, + }, +} satisfies Record; diff --git a/src/server/office/timers.ts b/src/server/office/timers.ts index 15eabb94a..517013724 100644 --- a/src/server/office/timers.ts +++ b/src/server/office/timers.ts @@ -1,7 +1,8 @@ import type { Ctx } from './context.js'; import { SLOW_CLIENT_BYTES } from './client.js'; +import { startReels } from '../reels/index.js'; -/** The office's own clocks: terminals re-sent to viewers who fell behind, and the heartbeat. Returns what stops them. */ +/** The office's own clocks: terminals re-sent to viewers who fell behind, the heartbeat and the reels studio's. Returns what stops them. */ export function startTimers(ctx: Ctx): () => void { const { clients } = ctx; const resync = setInterval(() => { @@ -31,7 +32,10 @@ export function startTimers(ctx: Ctx): () => void { if (accountsMoved) ctx.accountsChanged(); }, 20_000); + const stopReels = startReels(ctx); + return () => { + stopReels(); clearInterval(heartbeat); clearInterval(resync); }; diff --git a/src/server/reels/accounts.ts b/src/server/reels/accounts.ts new file mode 100644 index 000000000..5c8e385c7 --- /dev/null +++ b/src/server/reels/accounts.ts @@ -0,0 +1,74 @@ +// The page's sign-ins to TikTok, Instagram and YouTube: what the office posts with. A person puts +// them in /reels/accounts.json (see docs/reels.md for how to get each); the office keeps +// refreshed tokens there too. No agent ever reads them: the studio only says which are connected. + +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { Platform } from './types.js'; + +export interface TikTokAccount { + clientKey: string; + clientSecret: string; + accessToken?: string; + refreshToken: string; + /** When the access token runs out (ms since 1970). */ + expiresAt?: number; +} + +export interface InstagramAccount { + /** The Instagram professional account's id (the IG user id, not the Facebook page's). */ + userId: string; + /** A long-lived token with instagram_content_publish (and instagram_manage_insights for numbers). */ + accessToken: string; + /** graph.facebook.com for a token from Facebook Login; graph.instagram.com for Instagram Login. */ + host?: string; +} + +export interface YouTubeAccount { + clientId: string; + clientSecret: string; + refreshToken: string; + accessToken?: string; + expiresAt?: number; +} + +export interface Accounts { + tiktok?: TikTokAccount; + instagram?: InstagramAccount; + youtube?: YouTubeAccount; +} + +const has = (o: Record | undefined, keys: string[]) => !!o && keys.every((k) => typeof o[k] === 'string' && o[k]); + +/** Which platforms have a usable sign-in. */ +export function connected(a: Accounts): Platform[] { + const out: Platform[] = []; + if (has(a.tiktok as never, ['clientKey', 'clientSecret', 'refreshToken'])) out.push('tiktok'); + if (has(a.instagram as never, ['userId', 'accessToken'])) out.push('instagram'); + if (has(a.youtube as never, ['clientId', 'clientSecret', 'refreshToken'])) out.push('youtube'); + return out; +} + +export class AccountsFile { + private file: string; + + constructor(dir: string) { + this.file = path.join(dir, 'accounts.json'); + } + + /** Read fresh each time, so a person can add one without restarting the office. */ + read(): Accounts { + try { + return JSON.parse(readFileSync(this.file, 'utf8')) as Accounts; + } catch { + return {}; + } + } + + /** Keeps a platform's refreshed tokens. */ + update

(platform: P, patch: Partial>) { + const all = this.read(); + all[platform] = { ...(all[platform] as object), ...patch } as Accounts[P]; + writeFileSync(this.file, JSON.stringify(all, null, 2), { mode: 0o600 }); + } +} diff --git a/src/server/reels/captions.ts b/src/server/reels/captions.ts new file mode 100644 index 000000000..a6b5efaa1 --- /dev/null +++ b/src/server/reels/captions.ts @@ -0,0 +1,120 @@ +// When each word of the narration is said, how long each scene's image stays up, and the burned-in +// captions (an ASS subtitle file) that follow the voice a few words at a time. Pure: no files. + +import type { Scene } from './types.js'; + +/** A word of the narration, when it's said (seconds), and which scene it's in. */ +export interface Word { + text: string; + start: number; + end: number; + scene: number; +} + +/** The narration's words in order, each with the scene it belongs to. */ +export function sceneWords(scenes: Scene[]): { text: string; scene: number }[] { + return scenes.flatMap((s, scene) => s.narration.split(/\s+/).filter(Boolean).map((text) => ({ text, scene }))); +} + +/** + * When each word is said, when the voice gave no timings of its own: the `duration` shared out by + * the length of each word, with a little more after a comma and more after a full stop. + */ +export function estimateTimings(scenes: Scene[], duration: number): Word[] { + const words = sceneWords(scenes); + const weight = (w: string) => w.replace(/[^\p{L}\p{N}]/gu, '').length + 2 + (/[.!?]$/.test(w) ? 4 : /[,;:—-]$/.test(w) ? 2 : 0); + const total = words.reduce((n, w) => n + weight(w.text), 0) || 1; + let t = 0; + return words.map((w) => { + const len = (weight(w.text) / total) * duration; + const word = { ...w, start: t, end: t + len }; + t += len; + return word; + }); +} + +/** + * The words from character timings (ElevenLabs' alignment: a start and an end for every character + * of the text it read), matched back to the scenes' words. The text read is the narrations joined + * by single spaces, which is what produce() sends. + */ +export function wordsFromAlignment(scenes: Scene[], chars: string[], starts: number[], ends: number[]): Word[] { + const words = sceneWords(scenes); + const out: Word[] = []; + let c = 0; + for (const w of words) { + while (c < chars.length && /\s/.test(chars[c])) c++; + const first = c; + c = Math.min(chars.length, c + w.text.length); + const last = Math.max(first, c - 1); + out.push({ ...w, start: starts[first] ?? out.at(-1)?.end ?? 0, end: ends[last] ?? starts[first] ?? 0 }); + } + return out; +} + +/** + * How long each scene's image is on screen: from its first word to the next scene's first word, the + * first from 0 and the last to the end of the audio, so the images cover it with no gap. + */ +export function sceneDurations(words: Word[], sceneCount: number, total: number): number[] { + const startOf = (i: number) => (i === 0 ? 0 : words.find((w) => w.scene === i)?.start); + const starts: number[] = []; + for (let i = 0; i < sceneCount; i++) starts.push(startOf(i) ?? starts.at(-1) ?? 0); + return starts.map((s, i) => Math.max(0.5, (i + 1 < sceneCount ? starts[i + 1] : total) - s)); +} + +/** Words shown together on screen, a line at a time. */ +export function captionChunks(words: Word[], perChunk = 3): Word[] { + const out: Word[] = []; + let group: Word[] = []; + const flush = () => { + if (!group.length) return; + out.push({ text: group.map((w) => w.text).join(' '), start: group[0].start, end: group.at(-1)!.end, scene: group[0].scene }); + group = []; + }; + for (const w of words) { + group.push(w); + // A sentence ends its line, so a line never runs across two. + if (group.length >= perChunk || /[.!?,;:]$/.test(w.text)) flush(); + } + flush(); + // Each line stays up until the next one comes. + for (let i = 0; i + 1 < out.length; i++) out[i].end = out[i + 1].start; + return out; +} + +/** h:mm:ss.cc, as ASS writes a time. */ +export function assTime(s: number): string { + const cs = Math.max(0, Math.round(s * 100)); + const h = Math.floor(cs / 360000); + const m = Math.floor((cs % 360000) / 6000); + const sec = Math.floor((cs % 6000) / 100); + return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(cs % 100).padStart(2, '0')}`; +} + +/** Text safe inside an ASS dialogue line: no override blocks, no line breaks. */ +const assText = (s: string) => s.replace(/[{}\\]/g, '').replace(/\s+/g, ' ').trim().toUpperCase(); + +/** + * The captions as an ASS file for a 1080×1920 video: big, bold, white with a thick black outline, + * a little below the middle where the platforms' buttons don't cover them. + */ +export function assCaptions(words: Word[], font = 'Arial'): string { + const lines = captionChunks(words).map((c) => `Dialogue: 0,${assTime(c.start)},${assTime(c.end)},Caption,,0,0,0,,${assText(c.text)}`); + return [ + '[Script Info]', + 'ScriptType: v4.00+', + 'PlayResX: 1080', + 'PlayResY: 1920', + 'WrapStyle: 0', + '', + '[V4+ Styles]', + 'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding', + `Style: Caption,${font},92,&H00FFFFFF,&H00FFFFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,7,3,2,80,80,700,1`, + '', + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ...lines, + '', + ].join('\n'); +} diff --git a/src/server/reels/crew.ts b/src/server/reels/crew.ts new file mode 100644 index 000000000..807117c3f --- /dev/null +++ b/src/server/reels/crew.ts @@ -0,0 +1,64 @@ +// The agents a page is run by. Each is an ordinary worker in the office, hired with one of these +// briefs, and does its part through the office-reels command (bin/office-reels.js): the studio +// itself only makes videos and posts them. Nobody approves their own script. + +import type { CrewRole } from './types.js'; + +const COMMON = + 'You are part of the crew running a faceless short-video page (TikTok, Instagram Reels, YouTube Shorts) in Agent Office. ' + + 'The office makes and posts the videos; you and the rest of the crew decide what they say. Everything goes through the `office-reels` command ' + + '(or the reels MCP tools): run `office-reels status` first to see the series, what is in the pipeline and what the analyst has learned, and ' + + '`office-reels help` for every command. Everyone in the office sees what you do. The page earns from views (TikTok Creator Rewards, the YouTube ' + + 'Partner Program) and from its call to action, so the work is: reels people watch to the end and follow for more.\n\n' + + 'House rules for every reel: original writing only (no copied stories, scripts, lyrics or characters someone owns; myths, history, public-domain ' + + 'tales and your own fiction are fine); no claims about real living people that could be false; no medical, legal or money advice; nothing hateful, ' + + 'sexual or shocking for its own sake; history told as history must be true. Fiction is fine when it is told as a story. Every post is labelled as AI-made.'; + +const ROLES: Record = { + scout: + 'Your role: SCOUT. You find what to make. Keep each series stocked with ideas: run `office-reels status`, and when a series has fewer than ' + + 'about five ideas waiting, add some with `office-reels idea --title "…" --hook "…" --idea "…"`. A good idea is one specific story with a ' + + 'twist or a question the viewer needs answered (not "facts about Rome" but "the emperor who made his horse a senator"). Read the analyst\'s notes on ' + + 'the series and make more of what worked. Use the web to research if you can. Do not write scripts: that is the writer\'s job.', + writer: + 'Your role: WRITER. You turn ideas into scripts. Pick the oldest ideas with `office-reels status`, then send each script with ' + + '`office-reels script <<\'EOF\'` and a JSON body: {"title": "…", "hook": "…", "scenes": [{"narration": "…", "visual": "…"}, …], ' + + '"caption": "…", "hashtags": ["…"]}. Rules for a script: 6 to 12 scenes; 110 to 170 words of narration in all (40 to 65 seconds, so it qualifies ' + + 'for creator rewards where a minute is needed: aim for 150+); the first scene\'s narration IS the hook and must make someone stop scrolling in two ' + + 'seconds; short spoken sentences; end on a payoff or a cliffhanger that makes them follow. Each visual is an image prompt for that moment: who or ' + + 'what, where, the action, the mood, the camera (close-up, wide, low angle); never ask for words or text in the image, and leave the art style out ' + + '(the series adds it). The caption is one or two lines that make people comment. If the editor rejected a reel, its note says what to fix: ' + + 'send the script again for the same reel id. Read the analyst\'s notes and write more like what worked.', + editor: + 'Your role: EDITOR. Nothing is posted until you approve it. When reels are rendered, watch for them in `office-reels status` and check each ' + + 'with `office-reels show `: the script, the caption, and the video file (look at frames with ffmpeg if you can). Check the house rules, ' + + 'that the hook lands in the first line, that it is 40 to 65 seconds, that the facts are right, that the captions read well and the images fit. ' + + 'Then `office-reels review --approve` or `office-reels review --reject --note "what to fix"` (a rejected reel goes back to ' + + 'the writer). You cannot approve a reel you wrote. Approved reels post on the series\' schedule by themselves; do not post them yourself unless ' + + 'a person asks.', + analyst: + 'Your role: ANALYST. You make the page grow. Run `office-reels stats` once a day or when asked: it fetches views, likes, comments and shares ' + + 'for every post. Work out what is working (which hooks, topics, lengths, posting times) and write it down for the writer and the scout with ' + + '`office-reels note "…"`, one specific, actionable lesson per note. Change a series when the numbers say so, with `office-reels series ' + + 'set --postsPerDay 2` (or --hashtags, --cta, --style, --tone). Track the page against the payout bars: TikTok Creator Rewards needs 10k ' + + 'followers, 100k views in 30 days and videos over a minute; the YouTube Partner Program needs 1,000 subscribers and 10M Shorts views in 90 days. ' + + 'Tell the people in the office (your summary in your terminal is enough) when a bar is near, and suggest a call to action that earns (an affiliate ' + + 'link in the bio, a product) once the page has an audience. Never buy followers or views, and never use engagement bait the platforms ban.', +}; + +/** What a crew member is told when it's hired: the house rules and its role, for `series` if it's given. */ +export function crewBrief(role: CrewRole, series?: { id: string; name: string }): string { + const focus = series ? `\n\nYou work on the series "${series.name}" (${series.id}).` : '\n\nYou work on every series the studio has.'; + return `${COMMON}\n\n${ROLES[role]}${focus}\n\nStart now: run \`office-reels status\` and do your part, then stop. The studio will message you when there is more to do.`; +} + +/** What the studio tells a crew member when there's work for it. */ +export function nudge(role: CrewRole, why: string): string { + const what: Record = { + scout: 'Add ideas where they are running low.', + writer: 'Write scripts for the waiting ideas.', + editor: 'Review the rendered reels.', + analyst: 'Fetch the numbers and write down what you learn.', + }; + return `[office-reels] ${why} ${what[role]} Run \`office-reels status\` to see it.`; +} diff --git a/src/server/reels/endpoint.ts b/src/server/reels/endpoint.ts new file mode 100644 index 000000000..dcae45186 --- /dev/null +++ b/src/server/reels/endpoint.ts @@ -0,0 +1,85 @@ +import type http from 'node:http'; +import type { Ctx } from '../office/context.js'; +import { str } from '../office/input.js'; +import { readBody, send } from '../http/util.js'; +import { crewBrief } from './crew.js'; +import { hireCrew } from './hire.js'; +import { studioOf } from './index.js'; +import { CREW_ROLES, type CrewRole } from './types.js'; + +const ROUTES = ['', '/reel', '/brief', '/series', '/series/set', '/idea', '/script', '/review', '/remake', '/post', '/stats', '/note', '/crew', '/crew/join']; + +/** + * The reels studio, for any worker in the office (bin/office-reels.js is the command and MCP server + * that call it): GET for how everything stands, a reel in full or a crew brief, and a POST for each + * thing the crew does. The worker's own hook token says who's asking; everyone hears what they did. + */ +export async function officeReels(ctx: Ctx, req: http.IncomingMessage, res: http.ServerResponse, url: URL) { + const workerId = url.searchParams.get('worker') ?? ''; + const token = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, ''); + const floor = ctx.workerFloor(workerId); + const me = floor?.workers.authenticate(workerId, token); + if (!floor || !me) return send(res, 401, { error: 'Send your own AGENT_OFFICE_WORKER_ID as ?worker= and AGENT_OFFICE_HOOK_TOKEN as the bearer token' }); + const studio = studioOf(ctx); + const who = me.name; + const action = url.pathname.slice('/office/reels'.length); + if (!ROUTES.includes(action)) return send(res, 404, { error: `No /office/reels${action}` }); + const answer = (r: unknown) => (typeof r === 'string' ? send(res, 400, { error: r }) : send(res, 200, r)); + + if (req.method === 'GET') { + if (action === '') return send(res, 200, { you: { id: me.id, name: who, crew: studio.store.state.crew.filter((c) => c.workerId === me.id).map((c) => c.role) }, ...studio.overview() }); + if (action === '/reel') return answer(studio.show(url.searchParams.get('id') ?? '')); + if (action === '/brief') { + const role = url.searchParams.get('role') as CrewRole; + if (!CREW_ROLES.includes(role)) return send(res, 400, { error: `role is one of ${CREW_ROLES.join(', ')}` }); + const s = url.searchParams.get('series'); + const series = s ? studio.store.series(s) : undefined; + if (s && !series) return send(res, 404, { error: `No series ${s}` }); + return send(res, 200, { brief: crewBrief(role, series) }); + } + return send(res, 405, { error: `POST to /office/reels${action}` }); + } + if (req.method !== 'POST') return send(res, 405, { error: 'GET or POST' }); + let body: Record; + try { + const parsed: unknown = JSON.parse((await readBody(req)) || '{}'); + body = parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + return send(res, 400, { error: 'Send JSON' }); + } + + switch (action) { + case '/series': + return answer(studio.addSeries(body, who)); + case '/series/set': + return answer(studio.setSeries(body.series, body, who)); + case '/idea': + return answer(studio.addIdea(body.series, body, who)); + case '/script': + return answer(studio.script(body.reel, body.series, body, who)); + case '/review': + return answer(studio.review(body.reel, body.approve === true, str(body.note, 1000), who)); + case '/remake': + return answer(studio.remake(body.reel)); + case '/post': + return answer(await studio.postNow(body.reel, who)); + case '/stats': + return answer(await studio.stats().catch((e: Error) => `Fetching the numbers failed: ${e.message}`)); + case '/note': + return answer(studio.note(body.series, body.text, who)); + case '/crew/join': { + const role = body.role as CrewRole; + if (!CREW_ROLES.includes(role)) return send(res, 400, { error: `role is one of ${CREW_ROLES.join(', ')}` }); + const series = body.series ? studio.store.series(str(body.series, 64)) : undefined; + if (body.series && !series) return send(res, 404, { error: `No series ${String(body.series)}` }); + studio.addCrew({ role, workerId: me.id, floorId: floor.id, name: who, hiredAt: Date.now(), ...(series ? { seriesId: series.id } : {}) }); + ctx.toastFloor(floor, `🎬 ${who} joined the reels crew as ${role}`); + return send(res, 200, { ok: true, brief: crewBrief(role, series) }); + } + case '/crew': { + const r = hireCrew(ctx, floor, who, floor.workers.ownerOf(me.id), body); + return send(res, r.status, r.body); + } + } + return send(res, 405, { error: `GET /office/reels${action}` }); +} diff --git a/src/server/reels/hire.ts b/src/server/reels/hire.ts new file mode 100644 index 000000000..6cadc7b4d --- /dev/null +++ b/src/server/reels/hire.ts @@ -0,0 +1,58 @@ +// Hiring the reels crew onto a floor: a worker for each role a series has nobody in, each with its +// brief. Asked for by an agent (endpoint.ts) or a person on the studio's page (http/routes/reels.ts). + +import { nextFreeSeat } from '../../shared/layout.js'; +import { isAgentEffort, isAgentProvider } from '../../shared/protocol.js'; +import type { Floor } from '../floor.js'; +import type { Ctx } from '../office/context.js'; +import { str } from '../office/input.js'; +import { crewBrief } from './crew.js'; +import { studioOf } from './index.js'; +import { CREW_ROLES, type CrewRole } from './types.js'; + +export interface HireResult { + status: number; + body: { hired?: { role: string; name: string; id: string }[]; skipped?: string[]; error?: string }; +} + +/** + * Hires the roles `body.roles` asks for (all four by default) on `floor`, for `body.series` or every + * series, as `who`; they run as `owner`, the account whoever asked runs as. + */ +export function hireCrew(ctx: Ctx, floor: Floor, who: string, owner: string | undefined, body: Record): HireResult { + const studio = studioOf(ctx); + const roles = Array.isArray(body.roles) && body.roles.length ? body.roles : [...CREW_ROLES]; + const bad = roles.find((r) => !CREW_ROLES.includes(r as CrewRole)); + if (bad !== undefined) return { status: 400, body: { error: `roles are ${CREW_ROLES.join(', ')} (got ${String(bad)})` } }; + const series = body.series ? studio.store.series(str(body.series, 64)) : undefined; + if (body.series && !series) return { status: 404, body: { error: `No series ${String(body.series)}` } }; + if (ctx.ledger.hiringPaused) return { status: 409, body: { error: `Hiring is paused: ${ctx.ledger.hiringPaused}` } }; + const provider = isAgentProvider(body.provider) ? body.provider : undefined; + const model = str(body.model, 120).trim() || undefined; + const effort = isAgentEffort(body.effort) ? body.effort : undefined; + const hired: { role: string; name: string; id: string }[] = []; + const skipped: string[] = []; + for (const role of roles as CrewRole[]) { + const there = studio.crewOf(role, series?.id); + if (there && floor.workers.get(there.workerId)) { + skipped.push(`${role}: ${there.name} already is`); + continue; + } + const desk = nextFreeSeat((id) => floor.workers.deskOccupied(id), floor.plan.wing)?.id; + if (!desk) { + skipped.push(`${role}: no desk free`); + continue; + } + // No worktree: the crew writes to the studio, never to the floor's code. + const w = floor.workers.spawn(desk, who, crewBrief(role, series), false, 'agent', provider ?? (model || effort ? floor.workers.officeDefault.provider : undefined), model, effort, undefined, owner); + if (typeof w === 'string') { + skipped.push(`${role}: ${w}`); + continue; + } + studio.addCrew({ role, workerId: w.id, floorId: floor.id, name: w.name, hiredAt: Date.now(), ...(series ? { seriesId: series.id } : {}) }); + hired.push({ role, name: w.name, id: w.id }); + } + if (hired.length) ctx.toastFloor(floor, `🎬 ${who} hired the reels crew${series ? ` for “${series.name}”` : ''}: ${hired.map((h) => `${h.name} (${h.role})`).join(', ')}`); + if (!hired.length && skipped.length) return { status: 409, body: { hired, skipped, error: skipped.join('; ') } }; + return { status: 200, body: { hired, skipped } }; +} diff --git a/src/server/reels/index.ts b/src/server/reels/index.ts new file mode 100644 index 000000000..783fe9d2b --- /dev/null +++ b/src/server/reels/index.ts @@ -0,0 +1,38 @@ +// The office's one reels studio, and its clock. Its crew are workers on any floor; the studio finds +// them, and talks to them, through the office's context. + +import type { Ctx } from '../office/context.js'; +import { Studio, TICK_MS } from './studio.js'; +import type { CrewMember } from './types.js'; + +const studios = new WeakMap(); + +/** The office's studio, made the first time it's asked for. */ +export function studioOf(ctx: Ctx): Studio { + let studio = studios.get(ctx); + if (!studio) { + const worker = (m: CrewMember) => ctx.floors.get(m.floorId)?.workers.get(m.workerId); + studio = new Studio(ctx.cfg.dataDir, { + toast: (text, level) => ctx.toastAll(text, level), + workerStatus: (m) => worker(m)?.status, + tell: (m, text) => { + const workers = ctx.floors.get(m.floorId)?.workers; + if (!workers) return 'its floor closed'; + const err = workers.prompt(m.workerId, text, 'Reels studio'); + // Stopped or asleep: it wakes up with this as its next message. + return err === 'Worker is not running' ? workers.resume(m.workerId, text) : err; + }, + }); + studios.set(ctx, studio); + } + return studio; +} + +/** Starts the studio's clock; returns what stops it. It does nothing until someone makes a series. */ +export function startReels(ctx: Ctx): () => void { + const timer = setInterval(() => { + studioOf(ctx).tick().catch((e) => console.error('[reels]', e)); + }, TICK_MS); + timer.unref(); + return () => clearInterval(timer); +} diff --git a/src/server/reels/media.ts b/src/server/reels/media.ts new file mode 100644 index 000000000..1b07a4926 --- /dev/null +++ b/src/server/reels/media.ts @@ -0,0 +1,222 @@ +// The voice and the pictures a reel is made of. Free by default: Kokoro reads the narration on the +// office's own machine, and Pollinations draws the images. A paid service takes over by itself once +// its key is in the office's environment (ElevenLabs or OpenAI for the voice, fal or OpenAI for the +// images), and REELS_VOICE / REELS_IMAGES pick one outright. REELS_MEDIA=placeholder makes silence +// and plain colors, for trying the pipeline out. Keys never leave the office's environment. + +import { execFile } from 'node:child_process'; +import { existsSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { binScript } from '../workers/process.js'; +import { estimateTimings, wordsFromAlignment, type Word } from './captions.js'; +import type { Scene, Series } from './types.js'; + +type Env = Record; + +export const VOICES = ['kokoro', 'elevenlabs', 'openai', 'placeholder'] as const; +export const IMAGES = ['pollinations', 'fal', 'openai', 'placeholder'] as const; + +export interface MediaKeys { + voice: (typeof VOICES)[number]; + images: (typeof IMAGES)[number]; +} + +/** Where Kokoro is set up: its Python environment and its model files (see docs/reels.md). */ +export function kokoroSetup(env: Env = process.env) { + const dir = env.REELS_KOKORO_DIR || path.join(os.homedir(), '.agent-office', 'kokoro'); + const python = env.REELS_KOKORO_PYTHON || path.join(dir, 'venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'); + const model = ['kokoro-v1.0.int8.onnx', 'kokoro-v1.0.onnx'].map((f) => path.join(dir, f)).find((f) => existsSync(f)); + const voices = path.join(dir, 'voices-v1.0.bin'); + return { dir, python, model, voices, ready: !!model && existsSync(voices) && existsSync(python) }; +} + +/** + * Which services make the voice and the images. REELS_VOICE and REELS_IMAGES pick one; otherwise + * a paid service whose key is set, else the free one (Kokoro once it's set up, Pollinations). + */ +export function mediaKeys(env: Env = process.env): MediaKeys { + if (env.REELS_MEDIA === 'placeholder') return { voice: 'placeholder', images: 'placeholder' }; + const pick = (v: string | undefined, all: readonly T[]) => (all.includes(v as T) ? (v as T) : undefined); + return { + voice: pick(env.REELS_VOICE, VOICES) ?? (env.ELEVENLABS_API_KEY ? 'elevenlabs' : env.OPENAI_API_KEY ? 'openai' : kokoroSetup(env).ready ? 'kokoro' : 'placeholder'), + images: pick(env.REELS_IMAGES, IMAGES) ?? (env.FAL_KEY ? 'fal' : env.OPENAI_API_KEY ? 'openai' : 'pollinations'), + }; +} + +/** The narration as one text, as it's read out: the scenes' narrations, a space between each. */ +export const narrationText = (scenes: Scene[]) => scenes.map((s) => s.narration).join(' '); + +/** About how long a narration takes to read, for the placeholder voice: 2.6 words a second. */ +export const readingTime = (scenes: Scene[]) => Math.max(3, narrationText(scenes).split(/\s+/).length / 2.6); + +/** What a service said when it turned a request down, in a line. */ +async function failed(what: string, res: Response): Promise { + const body = (await res.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 300); + return new Error(`${what} failed (${res.status})${body ? `: ${body}` : ''}`); +} + +export interface Voice { + /** The audio's file name in the work folder, or undefined for the placeholder (silence is made by ffmpeg). */ + file?: string; + /** When each word is said, if the service said; else estimated once the length is known. */ + words?: Word[]; + /** Seconds, if known without probing the file. */ + duration?: number; +} + +/** Kokoro's voice for a series: its own voice when it names a Kokoro one (am_michael, af_heart…). */ +const kokoroVoice = (series: Series, env: Env) => (/^[a-z][fm]_[a-z]+$/.test(series.voice) ? series.voice : env.REELS_KOKORO_VOICE || 'am_michael'); + +/** Reads the narration with Kokoro, on this machine; it takes a few minutes for a minute of speech on a CPU. */ +function kokoro(text: string, series: Series, out: string, env: Env): Promise { + const k = kokoroSetup(env); + const script = binScript('reels-kokoro.py'); + if (!k.ready || !script) return Promise.reject(new Error(`Kokoro isn't set up in ${k.dir} (see docs/reels.md)`)); + return new Promise((resolve, reject) => { + const child = execFile(k.python, ['-I', script], { timeout: 30 * 60_000, windowsHide: true, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => { + if (err) return reject(new Error(`Kokoro failed: ${(stderr || err.message).trim().split('\n').slice(-3).join(' ')}`)); + try { + resolve((JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') as { seconds: number }).seconds); + } catch { + reject(new Error(`Kokoro said something unexpected: ${stdout.slice(0, 200)}`)); + } + }); + child.stdin?.end(JSON.stringify({ text, voice: kokoroVoice(series, env), speed: Number(env.REELS_KOKORO_SPEED) || 1, out, model: k.model, voices: k.voices })); + }); +} + +/** + * Reads the narration out into the work folder `dir`. ElevenLabs says when each character is said, + * so the captions follow the voice exactly; the others' are estimated from the audio's length. + */ +export async function speak(scenes: Scene[], series: Series, dir: string, keys: MediaKeys, env: Env = process.env, fetchImpl: typeof fetch = fetch): Promise { + const text = narrationText(scenes); + if (keys.voice === 'placeholder') { + const duration = readingTime(scenes); + return { duration, words: estimateTimings(scenes, duration) }; + } + if (keys.voice === 'kokoro') { + const duration = (await kokoro(text, series, path.join(dir, 'voice.wav'), env)) + 0.2; + return { file: 'voice.wav', duration }; + } + const file = 'voice.mp3'; + if (keys.voice === 'elevenlabs') { + // An OpenAI or Kokoro voice name means nobody picked an ElevenLabs one: use the office's default. + const voice = /^[A-Za-z0-9]{20}$/.test(series.voice) ? series.voice : env.ELEVENLABS_VOICE_ID || 'pNInz6obpgDQGcFmaJgB'; + const res = await fetchImpl(`https://api.elevenlabs.io/v1/text-to-speech/${voice}/with-timestamps?output_format=mp3_44100_128`, { + method: 'POST', + headers: { 'xi-api-key': env.ELEVENLABS_API_KEY ?? '', 'content-type': 'application/json' }, + body: JSON.stringify({ text, model_id: env.ELEVENLABS_MODEL || 'eleven_multilingual_v2' }), + }); + if (!res.ok) throw await failed('ElevenLabs', res); + const out = (await res.json()) as { audio_base64: string; alignment?: { characters: string[]; character_start_times_seconds: number[]; character_end_times_seconds: number[] } }; + writeFileSync(path.join(dir, file), Buffer.from(out.audio_base64, 'base64')); + const a = out.alignment; + if (!a?.characters?.length) return { file }; + const words = wordsFromAlignment(scenes, a.characters, a.character_start_times_seconds, a.character_end_times_seconds); + return { file, words, duration: (a.character_end_times_seconds.at(-1) ?? 0) + 0.4 }; + } + const res = await fetchImpl('https://api.openai.com/v1/audio/speech', { + method: 'POST', + headers: { authorization: `Bearer ${env.OPENAI_API_KEY}`, 'content-type': 'application/json' }, + body: JSON.stringify({ + model: env.REELS_TTS_MODEL || 'gpt-4o-mini-tts', + voice: /^[a-z]+$/.test(series.voice) ? series.voice : 'onyx', + input: text, + response_format: 'mp3', + ...(series.tone ? { instructions: series.tone } : {}), + }), + }); + if (!res.ok) throw await failed('OpenAI speech', res); + writeFileSync(path.join(dir, file), Buffer.from(await res.arrayBuffer())); + return { file }; +} + +/** The full image prompt for a scene: what's in it, the series' look, and what to leave out. */ +export function imagePrompt(scene: Scene, series: Series): string { + return `${scene.visual}. Style: ${series.style}. Vertical 9:16 frame, no text, no letters, no watermark, no logos.`; +} + +/** An image's file extension, from its first bytes (ffmpeg goes by the name to read a still). */ +export function imageExt(b: Buffer): 'jpg' | 'png' | 'webp' { + if (b[0] === 0xff && b[1] === 0xd8) return 'jpg'; + if (b[0] === 0x89 && b[1] === 0x50) return 'png'; + if (b.subarray(8, 12).toString('latin1') === 'WEBP') return 'webp'; + throw new Error("The image service sent something that isn't an image"); +} + +/** Writes an image as `base` plus the extension its bytes say; returns the file's name. */ +function keep(bytes: Buffer, base: string): string { + const file = `${base}.${imageExt(bytes)}`; + writeFileSync(file, bytes); + return path.basename(file); +} + +/** How many images a service is asked for at once: Pollinations' free tier takes one at a time. */ +export const imageBatch = (keys: MediaKeys) => (keys.images === 'pollinations' ? 1 : 3); + +/** Pollinations' free tier draws one image every 45 seconds for each address; a token lifts that. */ +export const POLLINATIONS_GAP_MS = 46_000; +let lastPollinations = 0; + +/** How long Pollinations asked to be left alone for, from its refusal ("Retry after 40s"), in ms. */ +export const retryAfter = (body: string, fallback: number) => { + const s = /retry after (\d+)\s*s/i.exec(body)?.[1]; + return s ? (Number(s) + 2) * 1000 : fallback; +}; + +/** One image from Pollinations: on the free tier, spaced out as it asks, and tried again when it says it's too soon. */ +async function pollinations(prompt: string, env: Env, fetchImpl: typeof fetch, wait = (ms: number) => new Promise((r) => setTimeout(r, ms))): Promise { + const q = new URLSearchParams({ width: '1080', height: '1920', model: env.REELS_POLLINATIONS_MODEL || 'flux', seed: String(Math.floor(Math.random() * 1e9)), nologo: 'true', private: 'true' }); + const url = `https://image.pollinations.ai/prompt/${encodeURIComponent(prompt)}?${q}`; + const token = env.POLLINATIONS_TOKEN; + const headers: Record = token ? { authorization: `Bearer ${token}` } : {}; + for (let attempt = 0; ; attempt++) { + if (!token) await wait(Math.max(0, lastPollinations + POLLINATIONS_GAP_MS - Date.now())); + lastPollinations = Date.now(); + const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(180_000) }); + if (res.ok) return Buffer.from(await res.arrayBuffer()); + // 402 is its free tier's limit, 429 too many at once. + const busy = res.status === 402 || res.status === 429 || res.status >= 500; + const body = await res.text().catch(() => ''); + if (attempt >= 4 || !busy) throw new Error(`Pollinations failed (${res.status}): ${body.replace(/\s+/g, ' ').slice(0, 300)}`); + await wait(retryAfter(body, POLLINATIONS_GAP_MS)); + } +} + +/** Makes one scene's image as `base` plus its extension; returns its file name, or undefined for a placeholder color. */ +export async function picture(prompt: string, base: string, keys: MediaKeys, env: Env = process.env, fetchImpl: typeof fetch = fetch): Promise { + if (keys.images === 'placeholder') return undefined; + if (keys.images === 'pollinations') return keep(await pollinations(prompt, env, fetchImpl), base); + if (keys.images === 'fal') { + const res = await fetchImpl(`https://fal.run/${env.REELS_FAL_MODEL || 'fal-ai/flux/schnell'}`, { + method: 'POST', + headers: { authorization: `Key ${env.FAL_KEY}`, 'content-type': 'application/json' }, + body: JSON.stringify({ prompt, image_size: 'portrait_16_9', num_images: 1, enable_safety_checker: true }), + }); + if (!res.ok) throw await failed('fal', res); + const out = (await res.json()) as { images?: { url: string }[] }; + const url = out.images?.[0]?.url; + if (!url) throw new Error('fal sent no image'); + const img = await fetchImpl(url); + if (!img.ok) throw await failed('Downloading the image', img); + return keep(Buffer.from(await img.arrayBuffer()), base); + } + const res = await fetchImpl('https://api.openai.com/v1/images/generations', { + method: 'POST', + headers: { authorization: `Bearer ${env.OPENAI_API_KEY}`, 'content-type': 'application/json' }, + body: JSON.stringify({ model: env.REELS_IMAGE_MODEL || 'gpt-image-1', prompt, size: '1024x1536', quality: env.REELS_IMAGE_QUALITY || 'low', n: 1 }), + }); + if (!res.ok) throw await failed('OpenAI images', res); + const out = (await res.json()) as { data?: { b64_json?: string }[] }; + const b64 = out.data?.[0]?.b64_json; + if (!b64) throw new Error('OpenAI sent no image'); + return keep(Buffer.from(b64, 'base64'), base); +} + +/** A placeholder scene's color: dark, and different for each scene. */ +export function placeholderColor(i: number): string { + const palette = ['1b2a49', '3a1c4a', '1f3d36', '4a2a1c', '2b2b40', '402020', '203a4a']; + return `0x${palette[i % palette.length]}`; +} diff --git a/src/server/reels/pipeline.ts b/src/server/reels/pipeline.ts new file mode 100644 index 000000000..f9850711c --- /dev/null +++ b/src/server/reels/pipeline.ts @@ -0,0 +1,82 @@ +// Making a scripted reel into a video: the voice, an image a scene, the captions timed to the voice, +// then ffmpeg. Each reel is made in a work folder of its own, kept until it's posted so a reel +// can be rendered again (with new music, say) without paying for its voice and images twice. + +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { assCaptions, estimateTimings, sceneDurations } from './captions.js'; +import { imageBatch, imagePrompt, mediaKeys, picture, placeholderColor, speak, type MediaKeys } from './media.js'; +import { probeDuration, render, type Shot } from './render.js'; +import type { ReelsStore } from './store.js'; +import type { Reel, Series } from './types.js'; + +/** A breath of quiet after the last word, before the reel loops. */ +const TAIL = 0.6; +export interface ProduceOptions { + keys?: MediaKeys; + env?: Record; + fetch?: typeof fetch; +} + +/** Makes `reel` into a video under the studio's out folder; resolves to its file and length. */ +export async function produce(reel: Reel, series: Series, store: ReelsStore, opts: ProduceOptions = {}): Promise<{ video: string; duration: number }> { + const scenes = reel.scenes; + if (!scenes?.length) throw new Error('It has no script yet'); + const keys = opts.keys ?? mediaKeys(opts.env); + const env = opts.env ?? process.env; + const fetchImpl = opts.fetch ?? fetch; + const work = path.join(store.folder('work'), reel.id); + // A script that changed since the last time means new voice and images: what's there is stale. + const stamp = JSON.stringify({ scenes, style: series.style, voice: series.voice, tone: series.tone, keys }); + const stampFile = path.join(work, 'script.json'); + const fresh = existsSync(stampFile) && readFileSync(stampFile, 'utf8') === stamp; + if (!fresh) rmSync(work, { recursive: true, force: true }); + mkdirSync(work, { recursive: true }); + writeFileSync(stampFile, stamp); + + const timingsFile = path.join(work, 'words.json'); + let words: ReturnType | undefined; + let duration: number | undefined; + /** The voice's file name in the work folder: none for the placeholder's silence. */ + let voiceFile: string | undefined; + if (fresh && existsSync(timingsFile)) { + ({ words, duration, voiceFile } = JSON.parse(readFileSync(timingsFile, 'utf8'))); + } else { + const voice = await speak(scenes, series, work, keys, env, fetchImpl); + voiceFile = voice.file; + duration = voice.duration ?? (await probeDuration(voiceFile!, work)); + words = voice.words ?? estimateTimings(scenes, duration); + writeFileSync(timingsFile, JSON.stringify({ words, duration, voiceFile })); + } + const total = Math.min(90, (duration ?? 0) + TAIL); + + const shots: Shot[] = []; + const seconds = sceneDurations(words!, scenes.length, total); + // Images already made for this script, by scene: scene-3.jpg is scene 3's. + const made = new Map(); + for (const f of readdirSync(work)) { + const m = /^scene-(\d+)\.(jpg|png|webp)$/.exec(f); + if (m) made.set(Number(m[1]), f); + } + const batchSize = imageBatch(keys); + for (let i = 0; i < scenes.length; i += batchSize) { + const batch = scenes.slice(i, i + batchSize).map(async (scene, j) => { + const n = i + j; + const file = made.get(n) ?? (await picture(imagePrompt(scene, series), path.join(work, `scene-${n}`), keys, env, fetchImpl)); + shots[n] = file ? { image: file, seconds: seconds[n] } : { color: placeholderColor(n), seconds: seconds[n] }; + }); + await Promise.all(batch); + } + + writeFileSync(path.join(work, 'captions.ass'), assCaptions(words!, env.REELS_CAPTION_FONT || 'Arial')); + const music = series.music ? path.join(store.folder('music'), series.music) : undefined; + if (music && !existsSync(music)) throw new Error(`The series' music, ${series.music}, isn't in ${store.folder('music')}`); + const video = path.join(store.folder('out'), `${reel.id}.mp4`); + await render({ shots, voice: voiceFile, music, captions: 'captions.ass', total, out: video }, work); + return { video, duration: total }; +} + +/** Clears out a posted reel's work folder: its voice and images aren't needed again. */ +export function tidy(reel: Reel, store: ReelsStore) { + rmSync(path.join(store.dir, 'work', reel.id), { recursive: true, force: true }); +} diff --git a/src/server/reels/publish.ts b/src/server/reels/publish.ts new file mode 100644 index 000000000..77c074ec6 --- /dev/null +++ b/src/server/reels/publish.ts @@ -0,0 +1,236 @@ +// Posting a finished reel, and reading back how it's doing: TikTok's Content Posting API, Instagram's +// Reels publishing (a resumable upload, so the video needn't be on a public URL) and a YouTube Short. +// Every post is marked as AI-made where the platform has a way to say so. + +import { readFileSync, statSync } from 'node:fs'; +import type { AccountsFile, Accounts } from './accounts.js'; +import type { Platform, Post, PostStats } from './types.js'; + +export interface Upload { + video: string; + title: string; + caption: string; + tiktokPrivacy: string; + youtubePrivacy: string; +} + +type Fetch = typeof fetch; +const IG_VERSION = 'v21.0'; +/** How long to wait for a platform to finish processing an upload. */ +const PROCESS_MS = 5 * 60_000; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function json(what: string, res: Response): Promise { + const text = await res.text(); + let body: unknown; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text.slice(0, 300) }; + } + if (!res.ok) throw new Error(`${what} failed (${res.status}): ${JSON.stringify(body).slice(0, 400)}`); + return body as T; +} + +// --- TikTok -------------------------------------------------------------------------------------- + +async function tiktokToken(accounts: AccountsFile, f: Fetch): Promise { + const a = accounts.read().tiktok; + if (!a) throw new Error('TikTok isn\'t connected'); + if (a.accessToken && (a.expiresAt ?? 0) > Date.now() + 60_000) return a.accessToken; + const res = await f('https://open.tiktokapis.com/v2/oauth/token/', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_key: a.clientKey, client_secret: a.clientSecret, grant_type: 'refresh_token', refresh_token: a.refreshToken }), + }); + const t = await json<{ access_token?: string; refresh_token?: string; expires_in?: number; error_description?: string }>('TikTok sign-in', res); + if (!t.access_token) throw new Error(`TikTok sign-in failed: ${t.error_description ?? 'no token'}`); + accounts.update('tiktok', { accessToken: t.access_token, refreshToken: t.refresh_token ?? a.refreshToken, expiresAt: Date.now() + (t.expires_in ?? 3600) * 1000 }); + return t.access_token; +} + +/** + * Posts to TikTok: asks what privacy the creator allows (TikTok requires that), starts a direct + * post with the video in one chunk, uploads it, and waits until TikTok says it's up. + */ +async function postTikTok(u: Upload, accounts: AccountsFile, f: Fetch): Promise { + const token = await tiktokToken(accounts, f); + const auth = { authorization: `Bearer ${token}`, 'content-type': 'application/json; charset=UTF-8' }; + const creator = await json<{ data?: { privacy_level_options?: string[] } }>( + 'TikTok creator info', + await f('https://open.tiktokapis.com/v2/post/publish/creator_info/query/', { method: 'POST', headers: auth }), + ); + const options = creator.data?.privacy_level_options ?? []; + // An app TikTok hasn't audited may only post privately: fall back to that rather than fail. + const privacy = options.includes(u.tiktokPrivacy) ? u.tiktokPrivacy : options.includes('SELF_ONLY') ? 'SELF_ONLY' : options[0] ?? 'SELF_ONLY'; + const size = statSync(u.video).size; + const init = await json<{ data?: { publish_id?: string; upload_url?: string } }>( + 'TikTok post', + await f('https://open.tiktokapis.com/v2/post/publish/video/init/', { + method: 'POST', + headers: auth, + body: JSON.stringify({ + post_info: { title: u.caption.slice(0, 2200), privacy_level: privacy, disable_comment: false, disable_duet: false, disable_stitch: false, is_aigc: true }, + source_info: { source: 'FILE_UPLOAD', video_size: size, chunk_size: size, total_chunk_count: 1 }, + }), + }), + ); + const id = init.data?.publish_id; + const url = init.data?.upload_url; + if (!id || !url) throw new Error('TikTok gave no upload address'); + const put = await f(url, { method: 'PUT', headers: { 'content-type': 'video/mp4', 'content-length': String(size), 'content-range': `bytes 0-${size - 1}/${size}` }, body: readFileSync(u.video) }); + if (!put.ok) throw new Error(`Uploading to TikTok failed (${put.status})`); + const until = Date.now() + PROCESS_MS; + while (Date.now() < until) { + await sleep(5000); + const s = await json<{ data?: { status?: string; fail_reason?: string; publicaly_available_post_id?: (string | number)[] } }>( + 'TikTok post status', + await f('https://open.tiktokapis.com/v2/post/publish/status/fetch/', { method: 'POST', headers: auth, body: JSON.stringify({ publish_id: id }) }), + ); + const status = s.data?.status; + if (status === 'FAILED') throw new Error(`TikTok turned it down: ${s.data?.fail_reason ?? 'no reason given'}`); + if (status === 'PUBLISH_COMPLETE') { + const postId = s.data?.publicaly_available_post_id?.[0]; + return { at: Date.now(), id: postId ? String(postId) : id, ...(postId ? { url: `https://www.tiktok.com/video/${postId}` } : {}), ...(privacy !== u.tiktokPrivacy ? { error: `posted as ${privacy}: TikTok doesn't allow ${u.tiktokPrivacy} for this app yet` } : {}) }; + } + } + return { at: Date.now(), id, error: 'TikTok was still processing it' }; +} + +// --- Instagram ----------------------------------------------------------------------------------- + +/** Posts a Reel: makes a resumable container, uploads the bytes, waits for it to process, publishes. */ +async function postInstagram(u: Upload, accounts: AccountsFile, f: Fetch): Promise { + const a = accounts.read().instagram; + if (!a) throw new Error('Instagram isn\'t connected'); + const host = a.host || 'graph.facebook.com'; + const api = (p: string) => `https://${host}/${IG_VERSION}/${p}`; + const container = await json<{ id?: string; uri?: string }>( + 'Instagram container', + await f(api(`${a.userId}/media`), { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${a.accessToken}` }, + body: JSON.stringify({ media_type: 'REELS', upload_type: 'resumable', caption: u.caption.slice(0, 2200), share_to_feed: true }), + }), + ); + if (!container.id) throw new Error('Instagram made no container'); + const bytes = readFileSync(u.video); + const up = await f(container.uri ?? `https://rupload.facebook.com/ig-api-upload/${IG_VERSION}/${container.id}`, { + method: 'POST', + headers: { authorization: `OAuth ${a.accessToken}`, offset: '0', file_size: String(bytes.length) }, + body: bytes, + }); + if (!up.ok) throw new Error(`Uploading to Instagram failed (${up.status}): ${(await up.text()).slice(0, 200)}`); + const until = Date.now() + PROCESS_MS; + for (;;) { + await sleep(5000); + const s = await json<{ status_code?: string; status?: string }>('Instagram status', await f(api(`${container.id}?fields=status_code,status`), { headers: { authorization: `Bearer ${a.accessToken}` } })); + if (s.status_code === 'FINISHED') break; + if (s.status_code === 'ERROR' || s.status_code === 'EXPIRED') throw new Error(`Instagram couldn't process it: ${s.status ?? s.status_code}`); + if (Date.now() > until) throw new Error('Instagram was still processing it'); + } + const done = await json<{ id?: string }>( + 'Instagram publish', + await f(api(`${a.userId}/media_publish`), { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${a.accessToken}` }, body: JSON.stringify({ creation_id: container.id }) }), + ); + if (!done.id) throw new Error('Instagram published nothing'); + const link = await json<{ permalink?: string }>('Instagram permalink', await f(api(`${done.id}?fields=permalink`), { headers: { authorization: `Bearer ${a.accessToken}` } })).catch(() => ({ permalink: undefined })); + return { at: Date.now(), id: done.id, ...(link.permalink ? { url: link.permalink } : {}) }; +} + +// --- YouTube ------------------------------------------------------------------------------------- + +async function youtubeToken(accounts: AccountsFile, f: Fetch): Promise { + const a = accounts.read().youtube; + if (!a) throw new Error('YouTube isn\'t connected'); + if (a.accessToken && (a.expiresAt ?? 0) > Date.now() + 60_000) return a.accessToken; + const t = await json<{ access_token?: string; expires_in?: number }>( + 'YouTube sign-in', + await f('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: a.clientId, client_secret: a.clientSecret, refresh_token: a.refreshToken, grant_type: 'refresh_token' }), + }), + ); + if (!t.access_token) throw new Error('YouTube gave no token'); + accounts.update('youtube', { accessToken: t.access_token, expiresAt: Date.now() + (t.expires_in ?? 3600) * 1000 }); + return t.access_token; +} + +/** Uploads a Short: a vertical video under three minutes, with #Shorts in its title. */ +async function postYouTube(u: Upload, accounts: AccountsFile, f: Fetch): Promise { + const token = await youtubeToken(accounts, f); + const bytes = readFileSync(u.video); + const title = `${u.title.slice(0, 88)} #Shorts`; + const start = await f('https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json; charset=UTF-8', 'x-upload-content-type': 'video/mp4', 'x-upload-content-length': String(bytes.length) }, + body: JSON.stringify({ + snippet: { title, description: u.caption.slice(0, 4900), categoryId: '24' }, + status: { privacyStatus: u.youtubePrivacy, selfDeclaredMadeForKids: false, containsSyntheticMedia: true }, + }), + }); + if (!start.ok) throw new Error(`YouTube upload failed to start (${start.status}): ${(await start.text()).slice(0, 300)}`); + const at = start.headers.get('location'); + if (!at) throw new Error('YouTube gave no upload address'); + const video = await json<{ id?: string }>('YouTube upload', await f(at, { method: 'PUT', headers: { 'content-type': 'video/mp4' }, body: bytes })); + if (!video.id) throw new Error('YouTube made no video'); + return { at: Date.now(), id: video.id, url: `https://www.youtube.com/shorts/${video.id}` }; +} + +/** Posts the reel to `platform`. */ +export function publish(platform: Platform, u: Upload, accounts: AccountsFile, f: Fetch = fetch): Promise { + if (platform === 'tiktok') return postTikTok(u, accounts, f); + if (platform === 'instagram') return postInstagram(u, accounts, f); + return postYouTube(u, accounts, f); +} + +// --- Numbers ------------------------------------------------------------------------------------- + +/** How each post is doing, by platform and post id; posts a platform wouldn't say about are left out. */ +export async function fetchStats(posts: { platform: Platform; id: string }[], accounts: AccountsFile, f: Fetch = fetch): Promise> { + const out = new Map(); + const at = Date.now(); + const by = (p: Platform) => posts.filter((x) => x.platform === p).map((x) => x.id); + const a: Accounts = accounts.read(); + const tt = by('tiktok').filter((id) => /^\d+$/.test(id)); + if (tt.length && a.tiktok) { + const token = await tiktokToken(accounts, f); + for (let i = 0; i < tt.length; i += 20) { + const r = await json<{ data?: { videos?: { id: string; view_count?: number; like_count?: number; comment_count?: number; share_count?: number }[] } }>( + 'TikTok numbers', + await f('https://open.tiktokapis.com/v2/video/query/?fields=id,view_count,like_count,comment_count,share_count', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ filters: { video_ids: tt.slice(i, i + 20) } }), + }), + ); + for (const v of r.data?.videos ?? []) out.set(`tiktok:${v.id}`, { views: v.view_count, likes: v.like_count, comments: v.comment_count, shares: v.share_count, at }); + } + } + if (a.instagram) { + const host = a.instagram.host || 'graph.facebook.com'; + for (const id of by('instagram')) { + const r = await json<{ data?: { name: string; values?: { value: number }[] }[] }>( + 'Instagram numbers', + await f(`https://${host}/${IG_VERSION}/${id}/insights?metric=views,likes,comments,shares`, { headers: { authorization: `Bearer ${a.instagram.accessToken}` } }), + ).catch(() => undefined); + const v = (name: string) => r?.data?.find((m) => m.name === name)?.values?.[0]?.value; + if (r) out.set(`instagram:${id}`, { views: v('views'), likes: v('likes'), comments: v('comments'), shares: v('shares'), at }); + } + } + const yt = by('youtube'); + if (yt.length && a.youtube) { + const token = await youtubeToken(accounts, f); + for (let i = 0; i < yt.length; i += 50) { + const r = await json<{ items?: { id: string; statistics?: { viewCount?: string; likeCount?: string; commentCount?: string } }[] }>( + 'YouTube numbers', + await f(`https://www.googleapis.com/youtube/v3/videos?part=statistics&id=${yt.slice(i, i + 50).join(',')}`, { headers: { authorization: `Bearer ${token}` } }), + ); + const n = (s?: string) => (s === undefined ? undefined : Number(s)); + for (const v of r.items ?? []) out.set(`youtube:${v.id}`, { views: n(v.statistics?.viewCount), likes: n(v.statistics?.likeCount), comments: n(v.statistics?.commentCount), at }); + } + } + return out; +} diff --git a/src/server/reels/render.ts b/src/server/reels/render.ts new file mode 100644 index 000000000..92ac8b0d0 --- /dev/null +++ b/src/server/reels/render.ts @@ -0,0 +1,109 @@ +// Putting a reel together with ffmpeg: each scene's image slowly zooming for as long as its words +// take, one after another, the captions burned in, the voice over the top and the music under it, +// as a 1080×1920 MP4 the platforms take. ffmpeg (and ffprobe) must be on the office's PATH, or named +// by REELS_FFMPEG / REELS_FFPROBE. + +import { execFile } from 'node:child_process'; + +export const WIDTH = 1080; +export const HEIGHT = 1920; +export const FPS = 30; + +/** One scene for ffmpeg: its image's file name in the work folder, or a color, and how long it's up. */ +export interface Shot { + image?: string; + color?: string; + seconds: number; +} + +export interface RenderPlan { + shots: Shot[]; + /** The voice's file name in the work folder; silence when there's none. */ + voice?: string; + /** The music, a full path; it loops under the voice. */ + music?: string; + /** The captions' file name in the work folder. */ + captions: string; + /** How long the reel runs, in seconds. */ + total: number; + /** Where the finished video goes, a full path. */ + out: string; +} + +/** + * The slow push in (or, on every other scene, out) over a scene's image: the zoompan filter's + * settings for `frames` frames. + */ +export function kenBurns(frames: number, zoomOut: boolean): string { + const step = (0.12 / Math.max(1, frames)).toFixed(5); + const z = zoomOut ? `if(eq(on,0),1.12,max(zoom-${step},1))` : `min(zoom+${step},1.12)`; + return `zoompan=z='${z}':d=${frames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${WIDTH}x${HEIGHT}:fps=${FPS}`; +} + +/** + * ffmpeg's arguments for a plan, run in the work folder (so the captions' file needs no escaping in + * the filter graph, which on Windows it otherwise would). + */ +export function ffmpegArgs(plan: RenderPlan): string[] { + const args = ['-y', '-hide_banner', '-loglevel', 'error']; + const filters: string[] = []; + plan.shots.forEach((shot, i) => { + const frames = Math.max(1, Math.round(shot.seconds * FPS)); + if (shot.image) { + args.push('-i', shot.image); + // Big enough first that the zoom stays sharp, and cropped to the frame's shape. + filters.push(`[${i}:v]scale=1620:2880:force_original_aspect_ratio=increase,crop=1620:2880,setsar=1,${kenBurns(frames, i % 2 === 1)},trim=end_frame=${frames}[v${i}]`); + } else { + args.push('-f', 'lavfi', '-i', `color=c=${shot.color ?? 'black'}:s=${WIDTH}x${HEIGHT}:r=${FPS}:d=${(frames / FPS).toFixed(3)}`); + filters.push(`[${i}:v]setsar=1[v${i}]`); + } + }); + const n = plan.shots.length; + filters.push(`${plan.shots.map((_, i) => `[v${i}]`).join('')}concat=n=${n}:v=1:a=0,format=yuv420p,ass=${plan.captions}[v]`); + + const voice = n; + if (plan.voice) args.push('-i', plan.voice); + else args.push('-f', 'lavfi', '-t', plan.total.toFixed(3), '-i', 'anullsrc=r=44100:cl=stereo'); + if (plan.music) { + args.push('-stream_loop', '-1', '-i', plan.music); + // amix halves both, so the voice is put back where it was and the music sits well under it. + filters.push(`[${n + 1}:a]volume=0.14[m]`, `[${voice}:a][m]amix=inputs=2:duration=first:dropout_transition=0,volume=2[a]`); + } else filters.push(`[${voice}:a]anull[a]`); + + args.push( + '-filter_complex', filters.join(';'), + '-map', '[v]', '-map', '[a]', + '-t', plan.total.toFixed(3), + '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '21', '-r', String(FPS), + '-c:a', 'aac', '-b:a', '160k', '-ar', '44100', + '-movflags', '+faststart', + plan.out, + ); + return args; +} + +const ffmpegBin = () => process.env.REELS_FFMPEG || 'ffmpeg'; +const ffprobeBin = () => process.env.REELS_FFPROBE || 'ffprobe'; + +function run(cmd: string, args: string[], cwd: string, timeout: number): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { cwd, timeout, maxBuffer: 8 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => { + if (!err) return resolve(stdout); + const missing = (err as NodeJS.ErrnoException).code === 'ENOENT'; + reject(new Error(missing ? `${cmd} isn't installed on the office's machine (or set REELS_FFMPEG / REELS_FFPROBE)` : `${cmd} failed: ${(stderr || err.message).trim().split('\n').slice(-4).join(' ')}`)); + }); + }); +} + +/** How long a media file plays, in seconds. */ +export async function probeDuration(file: string, cwd: string): Promise { + const out = await run(ffprobeBin(), ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', file], cwd, 30_000); + const s = Number(out.trim()); + if (!Number.isFinite(s) || s <= 0) throw new Error(`ffprobe couldn't tell how long ${file} is`); + return s; +} + +/** Renders the plan in the work folder `cwd`. */ +export async function render(plan: RenderPlan, cwd: string): Promise { + await run(ffmpegBin(), ffmpegArgs(plan), cwd, 10 * 60_000); +} diff --git a/src/server/reels/schedule.ts b/src/server/reels/schedule.ts new file mode 100644 index 000000000..f6ed2c1ad --- /dev/null +++ b/src/server/reels/schedule.ts @@ -0,0 +1,70 @@ +// What the studio does next, worked out from its records and the clock: which reel to make, which +// series is due to post, and which crew member to ask for more. Pure, so it's tested without a clock. + +import type { CrewMember, CrewRole, Reel, ReelsState, Series } from './types.js'; + +const HOUR = 3600_000; +/** A crew member isn't asked again sooner than this. */ +export const NUDGE_EVERY = 3 * HOUR; +/** The analyst is asked for the numbers once a day. */ +const STATS_EVERY = 24 * HOUR; +/** Below this many ideas waiting, the scout is asked for more. */ +const IDEAS_LOW = 3; + +/** Waiting to go out: scripted and on its way to being posted. */ +const IN_PIPE: Reel['status'][] = ['scripted', 'rendering', 'rendered', 'approved']; + +/** Whether `series` may post now: it isn't paused, and its gap since the last post has passed (with a little slack for the clock's step). */ +export function postDue(series: Series, lastPost: number | undefined, now: number): boolean { + if (series.paused || !series.platforms.length) return false; + if (lastPost === undefined) return true; + return now - lastPost >= (24 * HOUR / series.postsPerDay) * 0.97; +} + +const oldest = (reels: Reel[]) => [...reels].sort((a, b) => a.updatedAt - b.updatedAt)[0]; + +/** The next approved reel of a series to post. */ +export const nextToPost = (state: ReelsState, seriesId: string) => oldest(state.reels.filter((r) => r.seriesId === seriesId && r.status === 'approved')); + +/** The next scripted reel to make, of a series that isn't paused. */ +export function nextToProduce(state: ReelsState): Reel | undefined { + const live = new Set(state.series.filter((s) => !s.paused).map((s) => s.id)); + return oldest(state.reels.filter((r) => r.status === 'scripted' && live.has(r.seriesId))); +} + +/** How a series' pipeline stands, counted by where its reels are. */ +export function counts(state: ReelsState, seriesId: string) { + const mine = state.reels.filter((r) => r.seriesId === seriesId); + const n = (...s: Reel['status'][]) => mine.filter((r) => s.includes(r.status)).length; + return { ideas: n('idea', 'rejected'), inPipe: n(...IN_PIPE), toReview: n('rendered'), approved: n('approved'), posted: n('posted'), failed: n('failed') }; +} + +/** Whether a crew member works on a series: one hired for one, or for them all. */ +const covers = (m: CrewMember, seriesId: string) => !m.seriesId || m.seriesId === seriesId; + +/** + * Who in the crew should be asked for something now, and why. Each role is asked when there's work + * it does: the scout when ideas run low, the writer when the backlog does and there are ideas to + * write (or rejected reels to fix), the editor when there are reels to review, the analyst for the + * daily numbers. `idle` says whether a member is free to be asked; nobody is asked twice in NUDGE_EVERY. + */ +export function nudges(state: ReelsState, now: number, idle: (m: CrewMember) => boolean): { member: CrewMember; why: string }[] { + const lastStats = state.lastStats; + const out: { member: CrewMember; why: string }[] = []; + const asked = new Set(); + const ask = (role: CrewRole, seriesId: string | undefined, why: string) => { + const m = state.crew.find((c) => c.role === role && (!seriesId || covers(c, seriesId)) && !asked.has(c) && idle(c) && (c.nudgedAt === undefined || now - c.nudgedAt >= NUDGE_EVERY)); + if (!m) return; + asked.add(m); + out.push({ member: m, why }); + }; + for (const s of state.series) { + if (s.paused) continue; + const c = counts(state, s.id); + if (c.toReview) ask('editor', s.id, `"${s.name}" has ${c.toReview} reel${c.toReview === 1 ? '' : 's'} waiting for review.`); + if (c.inPipe < s.backlog && c.ideas) ask('writer', s.id, `"${s.name}" has ${c.inPipe} reel${c.inPipe === 1 ? '' : 's'} ready to go, under its backlog of ${s.backlog}, and ${c.ideas} idea${c.ideas === 1 ? '' : 's'} to write.`); + if (c.ideas < IDEAS_LOW) ask('scout', s.id, `"${s.name}" is down to ${c.ideas} idea${c.ideas === 1 ? '' : 's'}.`); + } + if (state.reels.some((r) => r.status === 'posted') && now - (lastStats ?? 0) >= STATS_EVERY) ask('analyst', undefined, 'It is time for the daily numbers.'); + return out; +} diff --git a/src/server/reels/store.ts b/src/server/reels/store.ts new file mode 100644 index 000000000..3118d2870 --- /dev/null +++ b/src/server/reels/store.ts @@ -0,0 +1,193 @@ +// The reels studio's records on disk, and how what an agent sends becomes one: every field an agent +// gives is checked here before the studio keeps it (see endpoint.ts, which hands them over). + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import path from 'node:path'; +import { str } from '../office/input.js'; +import { PLATFORMS, type Platform, type Reel, type ReelsState, type Scene, type Series } from './types.js'; + +/** At most this many scenes to a reel: about a minute of narration. */ +export const MAX_SCENES = 14; +/** How many of the analyst's notes a series keeps. */ +const MAX_NOTES = 30; +/** Posting more than this a day reads as spam to the platforms. */ +export const MAX_POSTS_PER_DAY = 6; + +const TIKTOK_PRIVACY = ['SELF_ONLY', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'PUBLIC_TO_EVERYONE'] as const; +const YOUTUBE_PRIVACY = ['private', 'unlisted', 'public'] as const; + +export const newId = (prefix: string) => `${prefix}-${randomBytes(4).toString('hex')}`; + +/** A file name, never a path: music is only ever read from the studio's own folder. */ +const fileName = (v: unknown) => { + const s = str(v, 120).trim(); + return s && path.basename(s) === s && !s.startsWith('.') ? s : ''; +}; + +const tags = (v: unknown): string[] => + (Array.isArray(v) ? v : typeof v === 'string' ? v.split(/[\s,]+/) : []) + .map((t) => str(t, 40).trim().replace(/^#+/, '')) + .filter((t) => /^[\p{L}\p{N}_]+$/u.test(t)) + .slice(0, 12); + +/** What's settable on a series, from what an agent sent; a string says what's wrong. `base` is the series being changed. */ +export function readSeries(body: unknown, base?: Series): Partial | string { + const b = (body && typeof body === 'object' ? body : {}) as Record; + const out: Partial = {}; + const text = (k: 'name' | 'niche' | 'style' | 'voice' | 'tone' | 'cta', max: number) => { + if (b[k] === undefined) return; + out[k] = str(b[k], max).trim(); + }; + text('name', 60); + text('niche', 400); + text('style', 300); + text('voice', 60); + text('tone', 300); + text('cta', 150); + if (b.music !== undefined) { + const m = fileName(b.music); + if (b.music && !m) return 'music is the file name of a track in the studio\'s music folder, not a path'; + out.music = m || undefined; + } + if (b.platforms !== undefined) { + const list = Array.isArray(b.platforms) ? b.platforms : String(b.platforms).split(/[\s,]+/).filter(Boolean); + const bad = list.find((p) => !PLATFORMS.includes(p as Platform)); + if (bad !== undefined) return `platforms are ${PLATFORMS.join(', ')} (got ${String(bad)})`; + out.platforms = [...new Set(list as Platform[])]; + } + if (b.postsPerDay !== undefined) { + const n = Number(b.postsPerDay); + if (!(n >= 0.25 && n <= MAX_POSTS_PER_DAY)) return `postsPerDay is between 0.25 and ${MAX_POSTS_PER_DAY}`; + out.postsPerDay = n; + } + if (b.backlog !== undefined) { + const n = Number(b.backlog); + if (!Number.isInteger(n) || n < 1 || n > 30) return 'backlog is a whole number from 1 to 30'; + out.backlog = n; + } + if (b.hashtags !== undefined) out.hashtags = tags(b.hashtags); + if (b.tiktokPrivacy !== undefined) { + if (!TIKTOK_PRIVACY.includes(b.tiktokPrivacy as Series['tiktokPrivacy'])) return `tiktokPrivacy is one of ${TIKTOK_PRIVACY.join(', ')}`; + out.tiktokPrivacy = b.tiktokPrivacy as Series['tiktokPrivacy']; + } + if (b.youtubePrivacy !== undefined) { + if (!YOUTUBE_PRIVACY.includes(b.youtubePrivacy as Series['youtubePrivacy'])) return `youtubePrivacy is one of ${YOUTUBE_PRIVACY.join(', ')}`; + out.youtubePrivacy = b.youtubePrivacy as Series['youtubePrivacy']; + } + if (b.paused !== undefined) out.paused = b.paused === true; + if (!base) { + if (!out.name) return 'A series needs a name'; + if (!out.niche) return 'A series needs a niche: what its reels are about'; + } else if (out.name === '' || out.niche === '') return 'A series keeps a name and a niche'; + return out; +} + +/** A new series, with the studio's defaults for what wasn't said. */ +export function makeSeries(fields: Partial, by: string, now: number): Series { + return { + id: newId('s'), + name: fields.name ?? 'Untitled', + niche: fields.niche ?? '', + style: fields.style || 'cinematic digital painting, dramatic lighting, rich color, vertical composition', + voice: fields.voice || 'onyx', + tone: fields.tone, + music: fields.music, + platforms: fields.platforms ?? [], + postsPerDay: fields.postsPerDay ?? 1, + backlog: fields.backlog ?? 3, + cta: fields.cta, + hashtags: fields.hashtags ?? [], + tiktokPrivacy: fields.tiktokPrivacy ?? 'SELF_ONLY', + youtubePrivacy: fields.youtubePrivacy ?? 'private', + paused: fields.paused ?? false, + notes: [], + createdBy: by, + createdAt: now, + }; +} + +/** A writer's script, from what it sent; a string says what's wrong. */ +export function readScript(body: unknown): { title?: string; hook?: string; scenes: Scene[]; caption: string; hashtags: string[] } | string { + const b = (body && typeof body === 'object' ? body : {}) as Record; + if (!Array.isArray(b.scenes) || !b.scenes.length) return 'A script is its scenes: [{"narration": "…", "visual": "…"}, …]'; + if (b.scenes.length > MAX_SCENES) return `At most ${MAX_SCENES} scenes: a reel is a minute at most`; + const scenes: Scene[] = []; + for (const [i, s] of b.scenes.entries()) { + const narration = str((s as Scene)?.narration, 600).replace(/\s+/g, ' ').trim(); + const visual = str((s as Scene)?.visual, 500).replace(/\s+/g, ' ').trim(); + if (!narration || !visual) return `Scene ${i + 1} needs both narration (what's said) and visual (the image prompt)`; + scenes.push({ narration, visual }); + } + const words = scenes.reduce((n, s) => n + s.narration.split(' ').length, 0); + if (words > 190) return `The narration is ${words} words: keep it under 190 (about 70 seconds)`; + const caption = str(b.caption, 1500).trim(); + if (!caption) return 'A script needs the caption it posts with'; + const title = str(b.title, 100).trim() || undefined; + const hook = str(b.hook, 200).trim() || undefined; + return { title, hook, scenes, caption, hashtags: tags(b.hashtags) }; +} + +/** Takes a note onto a series, dropping the oldest past the limit. */ +export function addNote(series: Series, by: string, text: string, now: number) { + series.notes.push({ at: now, by, text }); + if (series.notes.length > MAX_NOTES) series.notes.splice(0, series.notes.length - MAX_NOTES); +} + +/** What a reel posts with: its caption, the call to action and the hashtags, within `max` characters. */ +export function postCaption(reel: Reel, series: Series, max = 2200): string { + const hashtags = [...new Set([...(reel.hashtags ?? []), ...series.hashtags])].map((t) => `#${t}`).join(' '); + return [reel.caption ?? reel.title, series.cta, hashtags].filter(Boolean).join('\n\n').slice(0, max); +} + +/** The studio's records, saved after each change. */ +export class ReelsStore { + readonly dir: string; + private file: string; + state: ReelsState; + + constructor(dataDir: string) { + this.dir = path.join(dataDir, 'reels'); + this.file = path.join(this.dir, 'reels.json'); + this.state = { series: [], reels: [], crew: [], lastPost: {} }; + try { + const saved = JSON.parse(readFileSync(this.file, 'utf8')) as Partial; + this.state = { series: saved.series ?? [], reels: saved.reels ?? [], crew: saved.crew ?? [], lastPost: saved.lastPost ?? {} }; + } catch { + // nothing saved yet + } + // A production or a post the last office was in the middle of didn't finish. + for (const r of this.state.reels) { + if (r.status === 'rendering') r.status = 'scripted'; + if (r.status === 'posting') r.status = 'approved'; + } + } + + /** Whether there's anything at all: the studio sleeps until someone makes a series. */ + get active() { + return this.state.series.length > 0; + } + + series(id: string): Series | undefined { + const key = id.trim().toLowerCase(); + return this.state.series.find((s) => s.id === id) ?? this.state.series.find((s) => s.name.toLowerCase() === key); + } + + reel(id: string): Reel | undefined { + return this.state.reels.find((r) => r.id === id.trim()); + } + + save() { + mkdirSync(this.dir, { recursive: true, mode: 0o700 }); + const tmp = `${this.file}.tmp`; + writeFileSync(tmp, JSON.stringify(this.state, null, 1), { mode: 0o600 }); + renameSync(tmp, this.file); + } + + /** A folder of the studio's, made when first asked for. */ + folder(name: 'out' | 'work' | 'music'): string { + const dir = path.join(this.dir, name); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + return dir; + } +} diff --git a/src/server/reels/studio.ts b/src/server/reels/studio.ts new file mode 100644 index 000000000..32e680787 --- /dev/null +++ b/src/server/reels/studio.ts @@ -0,0 +1,346 @@ +// The reels studio: what its crew asks of it (endpoint.ts passes the asks on), and its own clock, +// which makes the scripted reels into videos one at a time, posts each series' approved reels on +// its schedule, and asks the crew for more when there's work for them (schedule.ts says what). + +import { existsSync } from 'node:fs'; +import type { WorkerStatus } from '../../shared/protocol.js'; +import { AccountsFile, connected } from './accounts.js'; +import { nudge } from './crew.js'; +import { mediaKeys } from './media.js'; +import { produce as produceReel, tidy } from './pipeline.js'; +import { fetchStats as fetchPostStats, publish as publishReel } from './publish.js'; +import { counts, nextToPost, nextToProduce, nudges, postDue } from './schedule.js'; +import { ReelsStore, addNote, makeSeries, newId, postCaption, readScript, readSeries } from './store.js'; +import type { CrewMember, CrewRole, Platform, Post, Reel, Series } from './types.js'; + +export interface StudioDeps { + toast(text: string, level?: 'info' | 'warn'): void; + /** Where a crew member's worker is; undefined once it has left the office. */ + workerStatus(m: CrewMember): WorkerStatus | undefined; + /** Types `text` to a crew member as its next prompt; a string says why it couldn't. */ + tell(m: CrewMember, text: string): string | undefined; + produce?: typeof produceReel; + publish?: typeof publishReel; + fetchStats?: typeof fetchPostStats; + now?: () => number; +} + +/** A crew member may be asked for something when it's sitting still. */ +const RESTING: (WorkerStatus | undefined)[] = ['idle', 'done', 'exited', 'offline']; +/** The clock's step. */ +export const TICK_MS = 60_000; + +/** A reel in a line, for the status list. */ +function summary(r: Reel) { + const posts = Object.entries(r.posts).map(([p, post]) => ({ platform: p, url: post?.url, error: post?.error, views: post?.stats?.views })); + return { + id: r.id, + series: r.seriesId, + status: r.status, + title: r.title, + ...(r.hook ? { hook: r.hook } : {}), + by: r.scriptedBy ?? r.addedBy, + ...(r.review ? { review: r.review } : {}), + ...(r.error ? { error: r.error } : {}), + ...(r.duration ? { seconds: Math.round(r.duration) } : {}), + ...(posts.length ? { posts } : {}), + }; +} + +export class Studio { + readonly store: ReelsStore; + readonly accounts: AccountsFile; + private producing = false; + private posting = false; + /** Series already told they have nowhere to post, so it's said once. */ + private toldUnposted = new Set(); + private now: () => number; + + constructor(dataDir: string, private deps: StudioDeps) { + this.store = new ReelsStore(dataDir); + this.accounts = new AccountsFile(this.store.dir); + this.now = deps.now ?? Date.now; + } + + private series(id: unknown): Series | string { + const s = typeof id === 'string' ? this.store.series(id) : undefined; + return s ?? `No series ${String(id ?? '')}: run office-reels status for their ids`; + } + + private reel(id: unknown): Reel | string { + const r = typeof id === 'string' ? this.store.reel(id) : undefined; + return r ?? `No reel ${String(id ?? '')}: run office-reels status for their ids`; + } + + /** Everything at a glance: the series and how their pipelines stand, the reels on the go, the crew, and what's set up. */ + overview() { + const st = this.store.state; + const recentPosted = st.reels.filter((r) => r.status === 'posted').sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 15); + return { + series: st.series.map((s) => ({ + ...s, + notes: s.notes.slice(-8), + pipeline: counts(st, s.id), + lastPost: st.lastPost[s.id], + nextPostAfter: st.lastPost[s.id] ? st.lastPost[s.id] + Math.round((86400_000 / s.postsPerDay) * 0.97) : undefined, + })), + reels: [...st.reels.filter((r) => r.status !== 'posted'), ...recentPosted].map(summary), + crew: st.crew.map((m) => ({ ...m, status: this.deps.workerStatus(m) ?? 'gone' })), + media: mediaKeys(), + connected: connected(this.accounts.read()), + lastStats: st.lastStats, + }; + } + + /** One reel in full. */ + show(id: unknown) { + const r = this.reel(id); + if (typeof r === 'string') return r; + const s = this.store.series(r.seriesId); + return { ...r, ...(r.video ? { videoExists: existsSync(r.video) } : {}), ...(s ? { postCaption: postCaption(r, s) } : {}) }; + } + + addSeries(body: unknown, by: string): Series | string { + const fields = readSeries(body); + if (typeof fields === 'string') return fields; + if (this.store.series(fields.name!)) return `There's already a series called ${fields.name}`; + const s = makeSeries(fields, by, this.now()); + this.store.state.series.push(s); + this.store.save(); + this.deps.toast(`🎬 ${by} started the reels series “${s.name}”`); + return s; + } + + setSeries(id: unknown, body: unknown, by: string): Series | string { + const s = this.series(id); + if (typeof s === 'string') return s; + const fields = readSeries(body, s); + if (typeof fields === 'string') return fields; + Object.assign(s, fields); + this.store.save(); + this.deps.toast(`🎬 ${by} changed “${s.name}”: ${Object.keys(fields).join(', ')}`); + return s; + } + + addIdea(seriesId: unknown, body: unknown, by: string): Reel | string { + const s = this.series(seriesId); + if (typeof s === 'string') return s; + const b = (body ?? {}) as Record; + const title = typeof b.title === 'string' ? b.title.trim().slice(0, 100) : ''; + if (!title) return 'An idea needs a title'; + const now = this.now(); + const r: Reel = { + id: newId('r'), seriesId: s.id, status: 'idea', title, addedBy: by, posts: {}, createdAt: now, updatedAt: now, + ...(typeof b.hook === 'string' && b.hook.trim() ? { hook: b.hook.trim().slice(0, 200) } : {}), + ...(typeof b.idea === 'string' && b.idea.trim() ? { idea: b.idea.trim().slice(0, 1500) } : {}), + }; + this.store.state.reels.push(r); + this.store.save(); + return r; + } + + /** A writer's script for an idea (or a reel to fix), or a new reel straight from a script when `seriesId` is given instead. */ + script(reelId: unknown, seriesId: unknown, body: unknown, by: string): Reel | string { + const script = readScript(body); + if (typeof script === 'string') return script; + let r: Reel | string; + if (reelId) { + r = this.reel(reelId); + if (typeof r === 'string') return r; + if (['rendering', 'posting', 'posted'].includes(r.status)) return `${r.id} is ${r.status}: it can't be rewritten now`; + } else { + const s = this.series(seriesId); + if (typeof s === 'string') return s; + r = this.addIdea(s.id, { title: script.title ?? script.scenes[0].narration.slice(0, 80) }, by) as Reel; + } + Object.assign(r, { scenes: script.scenes, caption: script.caption, hashtags: script.hashtags, status: 'scripted', scriptedBy: by, updatedAt: this.now() }); + if (script.title) r.title = script.title; + if (script.hook) r.hook = script.hook; + delete r.error; + delete r.reviewedBy; + this.store.save(); + setTimeout(() => void this.tick(), 0).unref?.(); + return r; + } + + review(id: unknown, approve: boolean, note: string, by: string): Reel | string { + const r = this.reel(id); + if (typeof r === 'string') return r; + if (r.status !== 'rendered' && !(r.status === 'approved' && !approve)) return `${r.id} is ${r.status}: only a rendered reel is reviewed`; + if (approve && r.scriptedBy === by) return "You wrote it: another member of the crew has to approve it"; + if (!approve && !note.trim()) return 'Say what to fix: --note "…"'; + Object.assign(r, { status: approve ? 'approved' : 'rejected', reviewedBy: by, updatedAt: this.now() }); + if (note.trim()) r.review = note.trim().slice(0, 1000); + else delete r.review; + this.store.save(); + this.deps.toast(approve ? `✅ ${by} approved the reel “${r.title}”` : `✏️ ${by} sent “${r.title}” back: ${r.review}`); + return r; + } + + /** Makes a reel again (after its music changed, say): it goes to the front of the line. */ + remake(id: unknown): Reel | string { + const r = this.reel(id); + if (typeof r === 'string') return r; + if (!r.scenes?.length) return `${r.id} has no script yet`; + if (['rendering', 'posting', 'posted'].includes(r.status)) return `${r.id} is ${r.status}`; + Object.assign(r, { status: 'scripted', updatedAt: 0 }); + delete r.error; + this.store.save(); + setTimeout(() => void this.tick(), 0).unref?.(); + return r; + } + + note(seriesId: unknown, text: unknown, by: string): Series | string { + const s = this.series(seriesId); + if (typeof s === 'string') return s; + const t = typeof text === 'string' ? text.replace(/\s+/g, ' ').trim().slice(0, 600) : ''; + if (!t) return 'The note is empty'; + addNote(s, by, t, this.now()); + this.store.save(); + return s; + } + + /** Takes a crew member on, in place of anyone already in that role on that series. */ + addCrew(m: CrewMember) { + const st = this.store.state; + st.crew = st.crew.filter((c) => !(c.role === m.role && c.seriesId === m.seriesId) && c.workerId !== m.workerId); + st.crew.push(m); + this.store.save(); + } + + crewOf(role: CrewRole, seriesId?: string) { + return this.store.state.crew.find((c) => c.role === role && c.seriesId === seriesId); + } + + /** Posts an approved reel now, outside the schedule. */ + async postNow(id: unknown, by: string): Promise { + const r = this.reel(id); + if (typeof r === 'string') return r; + if (r.status !== 'approved') return `${r.id} is ${r.status}: only an approved reel is posted`; + if (this.posting) return 'The studio is posting another reel: try again in a minute'; + this.deps.toast(`📲 ${by} is posting “${r.title}” now`); + await this.post(r); + return r; + } + + /** Fetches the numbers for every post, and keeps them. */ + async stats(): Promise<{ id: string; title: string; series: string; posts: Partial> }[] | string> { + const st = this.store.state; + const posted = st.reels.filter((r) => Object.keys(r.posts).length); + const ids = posted.flatMap((r) => Object.entries(r.posts).filter(([, p]) => p?.id && !p.error?.startsWith('Not posted')).map(([platform, p]) => ({ platform: platform as Platform, id: p!.id! }))); + if (!ids.length) return 'Nothing has been posted yet'; + const got = await (this.deps.fetchStats ?? fetchPostStats)(ids, this.accounts); + for (const r of posted) for (const [platform, p] of Object.entries(r.posts)) { + const s = p?.id ? got.get(`${platform}:${p.id}`) : undefined; + if (s && p) p.stats = s; + } + st.lastStats = this.now(); + this.store.save(); + return posted.map((r) => ({ id: r.id, title: r.title, series: r.seriesId, posts: r.posts })); + } + + /** The studio's clock: make the next reel, post what's due, ask the crew for what's needed. */ + async tick() { + if (!this.store.active) return; + const st = this.store.state; + // Crew whose workers have left the office. + const gone = st.crew.filter((m) => this.deps.workerStatus(m) === undefined); + if (gone.length) { + st.crew = st.crew.filter((m) => !gone.includes(m)); + this.store.save(); + } + void this.produceNext(); + void this.postDue(); + const now = this.now(); + for (const { member, why } of nudges(st, now, (m) => RESTING.includes(this.deps.workerStatus(m)))) { + member.nudgedAt = now; + const err = this.deps.tell(member, nudge(member.role, why)); + if (err) this.deps.toast(`🎬 Couldn't ask the ${member.role} ${member.name}: ${err}`, 'warn'); + } + this.store.save(); + } + + private async produceNext() { + if (this.producing) return; + const r = nextToProduce(this.store.state); + const s = r && this.store.series(r.seriesId); + if (!r || !s) return; + this.producing = true; + r.status = 'rendering'; + this.store.save(); + try { + const { video, duration } = await (this.deps.produce ?? produceReel)(r, s, this.store); + Object.assign(r, { status: 'rendered', video, duration, updatedAt: this.now() }); + this.deps.toast(`🎬 The reel “${r.title}” is made (${Math.round(duration)}s): it's waiting for the editor`); + } catch (e) { + Object.assign(r, { status: 'failed', error: (e as Error).message, updatedAt: this.now() }); + this.deps.toast(`🎬 Making “${r.title}” failed: ${r.error}`, 'warn'); + } finally { + this.producing = false; + this.store.save(); + } + // The next one, if there is one. + void this.produceNext(); + } + + private async postDue() { + if (this.posting) return; + const st = this.store.state; + const live = connected(this.accounts.read()); + for (const s of st.series) { + if (!postDue(s, st.lastPost[s.id], this.now())) continue; + const r = nextToPost(st, s.id); + if (!r) continue; + if (!s.platforms.some((p) => live.includes(p))) { + if (!this.toldUnposted.has(s.id)) this.deps.toast(`🎬 “${s.name}” has reels ready but none of its platforms (${s.platforms.join(', ')}) is connected: see docs/reels.md`, 'warn'); + this.toldUnposted.add(s.id); + continue; + } + await this.post(r); + return; + } + } + + /** Posts a reel to each of its series' platforms it isn't on yet. */ + private async post(r: Reel) { + const s = this.store.series(r.seriesId); + if (!s || !r.video) return; + this.posting = true; + r.status = 'posting'; + this.store.save(); + const live = connected(this.accounts.read()); + const went: string[] = []; + const failed: string[] = []; + try { + for (const p of s.platforms) { + if (r.posts[p]?.id) continue; + if (!live.includes(p)) { + r.posts[p] = { at: this.now(), error: `Not posted: ${p} isn't connected` }; + continue; + } + try { + r.posts[p] = await (this.deps.publish ?? publishReel)(p, { video: r.video, title: r.title, caption: postCaption(r, s), tiktokPrivacy: s.tiktokPrivacy, youtubePrivacy: s.youtubePrivacy }, this.accounts); + went.push(r.posts[p]!.url ? `${p} (${r.posts[p]!.url})` : p); + } catch (e) { + r.posts[p] = { at: this.now(), error: (e as Error).message }; + failed.push(`${p}: ${(e as Error).message}`); + } + } + } finally { + this.posting = false; + } + if (went.length) { + r.status = 'posted'; + this.store.state.lastPost[s.id] = this.now(); + tidy(r, this.store); + this.deps.toast(`📲 Posted “${r.title}” to ${went.join(', ')}`); + } else { + // Nothing went: it waits for the next try, rather than going out on the next tick and failing again. + r.status = 'approved'; + this.store.state.lastPost[s.id] = this.now(); + } + if (failed.length) this.deps.toast(`📲 Posting “${r.title}” failed on ${failed.join('; ')}`, 'warn'); + r.updatedAt = this.now(); + this.store.save(); + } +} diff --git a/src/server/reels/types.ts b/src/server/reels/types.ts new file mode 100644 index 000000000..ef1dc5958 --- /dev/null +++ b/src/server/reels/types.ts @@ -0,0 +1,125 @@ +// The reels studio's records: the series a page posts, each reel from idea to posted, and the agents +// crewing it. Kept in /reels/reels.json by store.ts; see docs/reels.md for how they move. + +export const PLATFORMS = ['tiktok', 'instagram', 'youtube'] as const; +export type Platform = (typeof PLATFORMS)[number]; + +/** The agents a page is run by, each with its own brief (crew.ts). */ +export const CREW_ROLES = ['scout', 'writer', 'editor', 'analyst'] as const; +export type CrewRole = (typeof CREW_ROLES)[number]; + +/** + * Where a reel is: an `idea` (a scout's) is `scripted` by a writer, produced into a video + * (`rendering`, then `rendered`), `approved` or `rejected` by an editor, and `posted` once every + * platform its series posts to has it. `failed` is a production that broke, with `error` saying why. + */ +export type ReelStatus = 'idea' | 'scripted' | 'rendering' | 'rendered' | 'approved' | 'posting' | 'posted' | 'rejected' | 'failed'; + +/** A page's channel: one niche, one look, one voice, posted on a schedule. */ +export interface Series { + id: string; + name: string; + /** What it's about, as the scout and writer read it: "true crime from the 1800s, told as a mystery". */ + niche: string; + /** The art style every image prompt ends with: "dark oil painting, dramatic lighting". */ + style: string; + /** The narrator's voice: an OpenAI voice name, or an ElevenLabs voice id with ElevenLabs set up. */ + voice: string; + /** How the narrator reads (OpenAI TTS instructions): "calm, low, suspenseful". */ + tone?: string; + /** A track's file name in /reels/music, played under the voice. */ + music?: string; + platforms: Platform[]; + /** How many reels go out a day, from 0.25 (every four days) to 6. */ + postsPerDay: number; + /** Writers are asked for more once fewer than this many reels are waiting to go out. */ + backlog: number; + /** Ends every caption: "Follow for part 2", a link-in-bio line. */ + cta?: string; + hashtags: string[]; + /** What TikTok shows a post to: SELF_ONLY until the app is audited for public posting. */ + tiktokPrivacy: 'SELF_ONLY' | 'MUTUAL_FOLLOW_FRIENDS' | 'FOLLOWER_OF_CREATOR' | 'PUBLIC_TO_EVERYONE'; + /** YouTube's privacy for a Short. */ + youtubePrivacy: 'private' | 'unlisted' | 'public'; + /** Nothing is posted or produced for it while paused. */ + paused: boolean; + /** What the analyst learned from the numbers, newest last; writers read these. */ + notes: { at: number; by: string; text: string }[]; + createdBy: string; + createdAt: number; +} + +/** A beat of a reel: what the narrator says while one image is on screen. */ +export interface Scene { + narration: string; + /** The image prompt, without the series' style (that's added). */ + visual: string; +} + +export interface PostStats { + views?: number; + likes?: number; + comments?: number; + shares?: number; + at: number; +} + +/** A reel on one platform. */ +export interface Post { + at: number; + /** The platform's id for it: TikTok's publish id until its post id is known. */ + id?: string; + url?: string; + error?: string; + stats?: PostStats; +} + +export interface Reel { + id: string; + seriesId: string; + status: ReelStatus; + title: string; + /** The first line, the one that stops the scroll. */ + hook?: string; + /** What the scout had in mind, for the writer. */ + idea?: string; + scenes?: Scene[]; + caption?: string; + hashtags?: string[]; + addedBy: string; + scriptedBy?: string; + reviewedBy?: string; + /** The editor's note: why it was rejected, or what to fix. */ + review?: string; + /** The finished video, under /reels/out. */ + video?: string; + /** Its length in seconds. */ + duration?: number; + error?: string; + posts: Partial>; + createdAt: number; + updatedAt: number; +} + +/** An agent on a page's crew, and where it sits. */ +export interface CrewMember { + role: CrewRole; + workerId: string; + floorId: string; + name: string; + /** The one series it works on; every series when there's none. */ + seriesId?: string; + hiredAt: number; + /** When the studio last asked it for something, so it isn't asked again and again. */ + nudgedAt?: number; +} + +export interface ReelsState { + series: Series[]; + reels: Reel[]; + crew: CrewMember[]; + /** When each series last posted. */ + lastPost: Record; + /** When the numbers were last fetched. */ + lastStats?: number; +} diff --git a/src/server/workers/process.ts b/src/server/workers/process.ts index 2e5446aed..8a63742ca 100644 --- a/src/server/workers/process.ts +++ b/src/server/workers/process.ts @@ -80,14 +80,14 @@ export function shq(s: string) { } /** - * Writes the office-queue and office-workers commands into the data dir's bin/, each running its + * Writes the office-queue, office-workers and office-reels commands into the data dir's bin/, each running its * script in bin/ with the office's own node, and returns that directory. Rewritten on every start, * so after an upgrade they run the new install's scripts. */ export function writeOfficeCommands(dataDir: string): string | undefined { const dir = path.join(dataDir, 'bin'); let wrote = false; - for (const [name, what] of [['office-queue', "Agent Office's task queue, for the board agents"], ['office-workers', "Agent Office's workers, for every worker"]]) { + for (const [name, what] of [['office-queue', "Agent Office's task queue, for the board agents"], ['office-workers', "Agent Office's workers, for every worker"], ['office-reels', "Agent Office's reels studio, for every worker"]]) { const script = binScript(`${name}.js`); if (!script) continue; mkdirSync(dir, { recursive: true, mode: 0o700 }); diff --git a/tests/reels.test.ts b/tests/reels.test.ts new file mode 100644 index 000000000..7384d7f5b --- /dev/null +++ b/tests/reels.test.ts @@ -0,0 +1,305 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { TOOLS, UsageError, buildRequest, formatStatus, parseArgs, toolCall } from '../bin/office-reels.js'; +import { assCaptions, assTime, captionChunks, estimateTimings, sceneDurations, wordsFromAlignment } from '../src/server/reels/captions.js'; +import { crewBrief } from '../src/server/reels/crew.js'; +import { POLLINATIONS_GAP_MS, imageBatch, imageExt, imagePrompt, mediaKeys, picture, retryAfter } from '../src/server/reels/media.js'; +import { ffmpegArgs } from '../src/server/reels/render.js'; +import { nudges, postDue } from '../src/server/reels/schedule.js'; +import { makeSeries, postCaption, readScript, readSeries } from '../src/server/reels/store.js'; +import { Studio } from '../src/server/reels/studio.js'; +import type { CrewMember, Scene } from '../src/server/reels/types.js'; + +const scenes: Scene[] = [ + { narration: 'In 1872, a ship was found drifting.', visual: 'a ghost ship in fog' }, + { narration: 'Nobody was on board, and the table was set.', visual: 'a dinner table in a ship cabin' }, +]; + +const script = (n = 2) => ({ + title: 'The empty ship', + scenes: Array.from({ length: n }, (_, i) => ({ narration: `Line ${i} of the story, told well.`, visual: `picture ${i}` })), + caption: 'Where did they go?', + hashtags: ['#mystery', 'history', 'bad tag!'], +}); + +test('timings cover the narration, scene by scene, with no gap', () => { + const words = estimateTimings(scenes, 10); + assert.equal(words.length, 16); + assert.equal(words[0].start, 0); + assert.ok(Math.abs(words.at(-1)!.end - 10) < 1e-9); + // A full stop holds longer than a word of the same length without one. + const drifting = words.find((w) => w.text === 'drifting.')!; + const nobody = words.find((w) => w.text === 'Nobody')!; + assert.ok(drifting.end - drifting.start > (nobody.end - nobody.start) * 1.2); + const d = sceneDurations(words, 2, 10.6); + assert.ok(Math.abs(d[0] + d[1] - 10.6) < 1e-9); + assert.equal(d[0], words.find((w) => w.scene === 1)!.start); +}); + +test('character timings become word timings', () => { + const s: Scene[] = [{ narration: 'Hi there.', visual: 'x' }, { narration: 'Bye.', visual: 'y' }]; + const text = 'Hi there. Bye.'; + const chars = [...text]; + const starts = chars.map((_, i) => i * 0.1); + const ends = chars.map((_, i) => i * 0.1 + 0.1); + const w = wordsFromAlignment(s, chars, starts, ends); + assert.deepEqual(w.map((x) => [x.text, x.scene]), [['Hi', 0], ['there.', 0], ['Bye.', 1]]); + assert.ok(Math.abs(w[1].start - 0.3) < 1e-9); + assert.ok(Math.abs(w[2].end - 1.4) < 1e-9); +}); + +test('captions come a few words at a time, a sentence never across two', () => { + const words = estimateTimings(scenes, 10); + const chunks = captionChunks(words, 3); + assert.equal(chunks[0].text, 'In 1872,'); + assert.ok(chunks.every((c) => c.text.split(' ').length <= 3)); + assert.ok(chunks.some((c) => c.text.endsWith('drifting.'))); + for (let i = 0; i + 1 < chunks.length; i++) assert.equal(chunks[i].end, chunks[i + 1].start); + const ass = assCaptions([{ text: 'a {b} \\c', start: 0, end: 1.234, scene: 0 }]); + assert.match(ass, /PlayResX: 1080/); + assert.match(ass, /Dialogue: 0,0:00:00\.00,0:00:01\.23,Caption,,0,0,0,,A B C/); + assert.equal(assTime(3725.5), '1:02:05.50'); +}); + +test('a series is read carefully, and gets the defaults', () => { + assert.equal(readSeries({ niche: 'x' }), 'A series needs a name'); + assert.match(String(readSeries({ name: 'a', niche: 'b', platforms: ['tiktok', 'myspace'] })), /myspace/); + assert.match(String(readSeries({ name: 'a', niche: 'b', postsPerDay: 40 })), /postsPerDay/); + assert.match(String(readSeries({ name: 'a', niche: 'b', music: '../../etc/passwd' })), /not a path/); + const fields = readSeries({ name: 'Myths', niche: 'Greek myths', platforms: 'tiktok, youtube', hashtags: '#myth greek', postsPerDay: 2 }); + assert.equal(typeof fields, 'object'); + const s = makeSeries(fields as object, 'Ada', 1); + assert.deepEqual(s.platforms, ['tiktok', 'youtube']); + assert.deepEqual(s.hashtags, ['myth', 'greek']); + assert.equal(s.tiktokPrivacy, 'SELF_ONLY'); + assert.equal(s.youtubePrivacy, 'private'); + assert.equal(readSeries({ name: '' }, s), 'A series keeps a name and a niche'); +}); + +test('a script is checked before it is kept', () => { + assert.match(String(readScript({})), /scenes/); + assert.match(String(readScript({ ...script(), scenes: [{ narration: 'x' }] })), /Scene 1/); + assert.match(String(readScript({ ...script(15) })), /At most/); + assert.match(String(readScript({ ...script(), caption: '' })), /caption/); + const long = { ...script(1), scenes: Array.from({ length: 10 }, () => ({ narration: Array(25).fill('word').join(' '), visual: 'v' })) }; + assert.match(String(readScript(long)), /under 190/); + const ok = readScript(script()); + assert.equal(typeof ok, 'object'); + assert.deepEqual((ok as { hashtags: string[] }).hashtags, ['mystery', 'history']); +}); + +test('the caption a reel posts with ends with the call to action and hashtags', () => { + const s = makeSeries({ name: 'M', niche: 'n', cta: 'Follow for part 2', hashtags: ['myth'] }, 'Ada', 1); + const cap = postCaption({ caption: 'Who did it?', hashtags: ['myth', 'greek'], title: 't' } as never, s); + assert.equal(cap, 'Who did it?\n\nFollow for part 2\n\n#myth #greek'); + assert.equal(postCaption({ caption: 'x'.repeat(3000), title: 't' } as never, s).length, 2200); +}); + +test('ffmpeg gets a shot per scene, the captions and the voice under the music', () => { + const args = ffmpegArgs({ shots: [{ image: 'scene-0.png', seconds: 2 }, { color: '0x112233', seconds: 1.5 }], voice: 'voice.mp3', music: '/m/track.mp3', captions: 'captions.ass', total: 3.5, out: '/o/r.mp4' }); + const graph = args[args.indexOf('-filter_complex') + 1]; + assert.match(graph, /\[0:v\]scale=1620:2880.*zoompan=.*d=60:/); + assert.match(graph, /\[v0\]\[v1\]concat=n=2:v=1:a=0,format=yuv420p,ass=captions\.ass\[v\]/); + assert.match(graph, /\[3:a\]volume=0\.14\[m\];\[2:a\]\[m\]amix/); + assert.ok(args.includes('color=c=0x112233:s=1080x1920:r=30:d=1.500')); + assert.equal(args.at(-1), '/o/r.mp4'); + // No voice: silence, as long as the reel. + const quiet = ffmpegArgs({ shots: [{ color: 'black', seconds: 3 }], captions: 'c.ass', total: 3, out: 'o.mp4' }); + assert.ok(quiet.includes('anullsrc=r=44100:cl=stereo')); + assert.match(quiet[quiet.indexOf('-filter_complex') + 1], /\[1:a\]anull\[a\]/); +}); + +test('media is free by default, and paid once a key is there', () => { + // No Kokoro set up: silence for the voice, and Pollinations' free images. + const none = { REELS_KOKORO_DIR: path.join(tmpdir(), 'no-kokoro-here') }; + assert.deepEqual(mediaKeys(none), { voice: 'placeholder', images: 'pollinations' }); + assert.deepEqual(mediaKeys({ ...none, OPENAI_API_KEY: 'k' }), { voice: 'openai', images: 'openai' }); + assert.deepEqual(mediaKeys({ ...none, OPENAI_API_KEY: 'k', ELEVENLABS_API_KEY: 'e', FAL_KEY: 'f' }), { voice: 'elevenlabs', images: 'fal' }); + assert.deepEqual(mediaKeys({ ...none, OPENAI_API_KEY: 'k', REELS_MEDIA: 'placeholder' }), { voice: 'placeholder', images: 'placeholder' }); + // Picked outright, whatever keys there are; a name it doesn't know is ignored. + assert.deepEqual(mediaKeys({ ...none, OPENAI_API_KEY: 'k', REELS_VOICE: 'kokoro', REELS_IMAGES: 'pollinations' }), { voice: 'kokoro', images: 'pollinations' }); + assert.deepEqual(mediaKeys({ ...none, REELS_IMAGES: 'midjourney' }), { voice: 'placeholder', images: 'pollinations' }); + assert.equal(imageBatch({ voice: 'kokoro', images: 'pollinations' }), 1); + // Pollinations' free tier says how long to wait; without a number, the tier's own gap. + assert.equal(retryAfter('Free limit reached: 1 image every 45 seconds per IP. Retry after 40s, or get a key', 1), 42_000); + assert.equal(retryAfter('busy', POLLINATIONS_GAP_MS), POLLINATIONS_GAP_MS); + assert.equal(imageBatch({ voice: 'openai', images: 'fal' }), 3); + const s = makeSeries({ name: 'M', niche: 'n', style: 'oil painting' }, 'Ada', 1); + assert.match(imagePrompt(scenes[0], s), /^a ghost ship in fog\. Style: oil painting\. .*no text/); +}); + +test('a series posts once its gap has passed', () => { + const s = makeSeries({ name: 'M', niche: 'n', platforms: ['tiktok'], postsPerDay: 2 }, 'Ada', 1); + const h = 3600_000; + assert.equal(postDue(s, undefined, 0), true); + assert.equal(postDue(s, 0, 11 * h), false); + assert.equal(postDue(s, 0, 12 * h), true); + assert.equal(postDue({ ...s, paused: true }, undefined, 0), false); + assert.equal(postDue({ ...s, platforms: [] }, undefined, 0), false); +}); + +test('briefs carry the house rules and the role', () => { + const b = crewBrief('writer', { id: 's-1', name: 'Myths' }); + assert.match(b, /original writing only/); + assert.match(b, /WRITER/); + assert.match(b, /office-reels script/); + assert.match(b, /"Myths" \(s-1\)/); + assert.match(crewBrief('editor'), /cannot approve a reel you wrote/); +}); + +function studio() { + const dir = mkdtempSync(path.join(tmpdir(), 'reels-')); + const toasts: string[] = []; + const told: [string, string][] = []; + const status = new Map(); + let clock = 1_000_000; + const posted: string[] = []; + const s = new Studio(dir, { + toast: (t) => toasts.push(t), + workerStatus: (m) => status.get(m.workerId) as never, + tell: (m, t) => void told.push([m.name, t]), + produce: async (r) => ({ video: path.join(dir, `${r.id}.mp4`), duration: 42 }), + publish: async (p, u) => { + posted.push(`${p}:${u.caption}`); + return { at: clock, id: `${p}-1`, url: `https://example.test/${p}` }; + }, + now: () => clock, + }); + return { s, dir, toasts, told, status, posted, tick: (ms: number) => (clock += ms), done: () => rmSync(dir, { recursive: true, force: true }) }; +} + +test('a reel goes from idea to approved, and nobody approves their own', async () => { + const t = studio(); + try { + const series = t.s.addSeries({ name: 'Myths', niche: 'Greek myths', platforms: ['tiktok'] }, 'Ada'); + assert.equal(typeof series, 'object'); + const idea = t.s.addIdea('myths', { title: 'The box', hook: 'Never open it.' }, 'Scout'); + assert.equal(typeof idea, 'object'); + const id = (idea as { id: string }).id; + const r = t.s.script(id, undefined, script(), 'Wren'); + assert.equal((r as { status: string }).status, 'scripted'); + await t.s.tick(); + await new Promise((res) => setTimeout(res, 10)); + assert.equal(t.s.store.reel(id)!.status, 'rendered'); + assert.match(String(t.s.review(id, true, '', 'Wren')), /You wrote it/); + assert.match(String(t.s.review(id, false, '', 'Ed')), /Say what to fix/); + t.s.review(id, false, 'The hook is weak', 'Ed'); + assert.equal(t.s.store.reel(id)!.status, 'rejected'); + t.s.script(id, undefined, script(3), 'Wren'); + await t.s.tick(); + await new Promise((res) => setTimeout(res, 10)); + const ok = t.s.review(id, true, '', 'Ed'); + assert.equal((ok as { status: string }).status, 'approved'); + // Nothing is connected: it isn't posted, and that's said once. + await t.s.tick(); + assert.equal(t.s.store.reel(id)!.status, 'approved'); + assert.equal(t.toasts.filter((x) => x.includes('none of its platforms')).length, 1); + } finally { + t.done(); + } +}); + +test('the crew is asked for what the series needs, not twice in a row', () => { + const t = studio(); + try { + t.s.addSeries({ name: 'Myths', niche: 'n', platforms: ['tiktok'], backlog: 2 }, 'Ada'); + const crew = (role: CrewMember['role'], id: string): CrewMember => ({ role, workerId: id, floorId: 'f', name: id, hiredAt: 0 }); + t.s.addCrew(crew('scout', 'sc')); + t.s.addCrew(crew('writer', 'wr')); + const st = t.s.store.state; + const all = () => true; + // No ideas at all: the scout is asked, the writer has nothing to write. + assert.deepEqual(nudges(st, 10 ** 9, all).map((n) => n.member.role), ['scout']); + t.s.addIdea('Myths', { title: 'One' }, 'sc'); + const asked = nudges(st, 10 ** 9, all); + assert.deepEqual(asked.map((n) => n.member.role), ['writer', 'scout']); + for (const n of asked) n.member.nudgedAt = 10 ** 9; + assert.deepEqual(nudges(st, 10 ** 9 + 1000, all), []); + // A busy worker isn't interrupted. + assert.deepEqual(nudges(st, 10 ** 10, (m) => m.role !== 'writer').map((n) => n.member.role), ['scout']); + } finally { + t.done(); + } +}); + +test('crew whose workers left are let go, and the rest are nudged on the tick', async () => { + const t = studio(); + try { + t.s.addSeries({ name: 'Myths', niche: 'n', platforms: ['tiktok'] }, 'Ada'); + t.s.addCrew({ role: 'scout', workerId: 'sc', floorId: 'f', name: 'Sky', hiredAt: 0 }); + t.s.addCrew({ role: 'writer', workerId: 'gone', floorId: 'f', name: 'Ghost', hiredAt: 0 }); + t.status.set('sc', 'done'); + await t.s.tick(); + assert.deepEqual(t.s.store.state.crew.map((c) => c.name), ['Sky']); + assert.equal(t.told.length, 1); + assert.match(t.told[0][1], /^\[office-reels\] "Myths" is down to 0 ideas\. Add ideas/); + } finally { + t.done(); + } +}); + +test('office-reels parses its commands', () => { + assert.deepEqual(parseArgs([]), { cmd: 'help' }); + assert.deepEqual(parseArgs(['status', '--json']), { cmd: 'status', method: 'GET', path: '', json: true }); + assert.deepEqual(parseArgs(['idea', 's-1', '--title', 'T', '--hook=H']), { cmd: 'idea', method: 'POST', path: '/idea', body: { series: 's-1', title: 'T', hook: 'H', idea: undefined } }); + assert.deepEqual(parseArgs(['script', 'r-1']), { cmd: 'script', method: 'POST', path: '/script', body: { reel: 'r-1' }, stdin: 'json' }); + assert.deepEqual(parseArgs(['script', '--series', 'Myths']).body, { series: 'Myths' }); + assert.deepEqual(parseArgs(['review', 'r-1', '--reject', '--note', 'weak']).body, { reel: 'r-1', approve: false, note: 'weak' }); + assert.deepEqual(parseArgs(['series', 'add', '--name', 'M', '--niche', 'n', '--platforms', 'tiktok,youtube', '--postsPerDay', '2']).body, { name: 'M', niche: 'n', platforms: ['tiktok', 'youtube'], postsPerDay: 2 }); + assert.equal(parseArgs(['series', 'add']).stdin, 'json'); + assert.deepEqual(parseArgs(['series', 'set', 'M', '--pause']).body, { series: 'M', paused: true }); + assert.deepEqual(parseArgs(['note', 'M', 'hooks with a number win']).body, { series: 'M', text: 'hooks with a number win' }); + assert.equal(parseArgs(['note', 'M']).stdin, 'text'); + assert.deepEqual(parseArgs(['crew', 'hire', '--roles', 'writer,editor', '--model', 'sonnet']).body, { roles: ['writer', 'editor'], model: 'sonnet' }); + assert.deepEqual(parseArgs(['crew', 'join', 'analyst', '--series', 'M']).body, { role: 'analyst', series: 'M' }); + assert.throws(() => parseArgs(['review', 'r-1']), UsageError); + assert.throws(() => parseArgs(['crew', 'hire', '--roles', 'boss']), UsageError); + assert.throws(() => parseArgs(['idea', 's']), UsageError); + assert.throws(() => parseArgs(['dance']), UsageError); +}); + +test('office-reels calls the office as the worker, with long waits for posting', () => { + const office = { url: 'http://127.0.0.1:4455', worker: 'w1', token: 'tok' }; + const get = buildRequest({ method: 'GET', path: '/reel', query: { id: 'r-1' } }, office); + assert.equal(get.url, 'http://127.0.0.1:4455/office/reels/reel?worker=w1&id=r-1'); + assert.equal(get.headers.authorization, 'Bearer tok'); + const post = buildRequest({ method: 'POST', path: '/post', body: { reel: 'r-1' } }, office); + assert.equal(post.body, '{"reel":"r-1"}'); + assert.ok(post.timeout >= 10 * 60_000); + // Every tool maps to a call. + for (const t of TOOLS) assert.ok(toolCall(t.name, { series: 's', reel: 'r' }).path !== undefined); + assert.equal(toolCall('reels_series', {}).path, '/series'); + assert.equal(toolCall('reels_series', { series: 's' }).path, '/series/set'); + const text = formatStatus({ media: { voice: 'openai', images: 'fal' }, connected: [], series: [], reels: [], crew: [] }); + assert.match(text, /No series yet/); + assert.match(text, /No crew yet/); +}); + +test('an image keeps the extension its bytes say, and Pollinations is asked for a tall one', async () => { + assert.equal(imageExt(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), 'jpg'); + assert.equal(imageExt(Buffer.from([0x89, 0x50, 0x4e, 0x47])), 'png'); + assert.equal(imageExt(Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBPVP8 ')])), 'webp'); + assert.throws(() => imageExt(Buffer.from('busy')), /isn't an image/); + const dir = mkdtempSync(path.join(tmpdir(), 'reels-img-')); + try { + let asked = ''; + let auth: string | undefined; + const fake = (async (url: string, init?: RequestInit) => { + asked = url; + auth = (init?.headers as Record)?.authorization; + return new Response(new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3])); + }) as typeof fetch; + const file = await picture('a ship in fog', path.join(dir, 'scene-0'), { voice: 'kokoro', images: 'pollinations' }, { POLLINATIONS_TOKEN: 'tok' }, fake); + assert.equal(file, 'scene-0.jpg'); + assert.ok(existsSync(path.join(dir, 'scene-0.jpg'))); + assert.ok(asked.startsWith('https://image.pollinations.ai/prompt/a%20ship%20in%20fog?width=1080&height=1920&'), asked); + assert.match(asked, /nologo=true/); + assert.equal(auth, 'Bearer tok'); + assert.equal(await picture('x', path.join(dir, 'scene-1'), { voice: 'placeholder', images: 'placeholder' }), undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/vite.config.ts b/vite.config.ts index 38f15039c..b1b018f0b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -54,6 +54,7 @@ export default defineConfig({ input: { main: resolve(import.meta.dirname, 'src/client/index.html'), lite: resolve(import.meta.dirname, 'src/client/lite.html'), + reels: resolve(import.meta.dirname, 'src/client/reels.html'), login: resolve(import.meta.dirname, 'src/client/login.html'), claim: resolve(import.meta.dirname, 'src/client/claim.html'), join: resolve(import.meta.dirname, 'src/client/join.html'),