// AutoViral Bridge router — mounts at /api/bridge/v1.
// Phase 0: whoami only (smoke test of the wire). Phase 2-3 expand to
// read-only (comp/list/docs) + writes (clip add/set/remove) + UI commands
// (select/seek/play/pause/toast/progress) + approval gate (ask) + tasks
// (export/render). See docs/archive/specs/2026-05-14-agentic-terminal-
// bridge-protocol.md for the full surface.
import { Hono } from "hono";
import { homedir } from "node:os";
import { join, resolve, relative, sep } from "node:path";
import { PACKAGE_ROOT } from "../../infra/paths.js";
import { readFileSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { SelectRequestSchema, SeekRequestSchema, ToastRequestSchema, ProgressRequestSchema, AskRequestSchema, } from "./schemas.js";
import { createAsk } from "./approval-gate.js";
import { readCompositionFor, mutateCompositionFor, dryRunMutate, diffCompositionFor, withWorkLock, } from "./composition-ops.js";
import { ASPECTS } from "../../shared/composition.js";
// S7 (PRD-0007) — generation handoff. POST /scene/:id/generate builds the prompt
// from the scene's own fields, generates ONE image via the same provider
// registry the /api/generate routes use, then registers + links the asset inside
// one locked mutator (atomic — no dangling-reference window). See
// generate.ts:53-71/533-571 for the register/provenance/uri-conversion patterns.
import { getProvider, getDefaultProvider, } from "../../providers/registry.js";
import { dataDir } from "../../infra/config.js";
import { randomUUID } from "node:crypto";
import { preflight } from "../../shared/composition/preflight.js";
import { mutateCarouselFor, applyLayerPatch } from "./carousel-ops.js";
// ADR-009 (S6) — shared composition-ops core. POST /split delegates the split
// math + invariants to `ops.splitClip` (the SAME implementation the studio
// store calls), so the agent-driven write path and the UI path can never drift.
import * as ops from "../../shared/composition/ops/index.js";
import { CompositionOpError } from "../../shared/composition/ops/index.js";
import { SlideBgSchema, makeEmptySlide, } from "../../shared/carousel.js";
import { uiEventBus } from "./ui-events.js";
import { randomBytes } from "node:crypto";
import { runRenderPipeline } from "../render-pipeline.js";
import { resolvePlatformPreset } from "../../shared/platform-presets.js";
import { renderSnapshot, assetUrlToWorkRel } from "../snapshot.js";
import { listCheckpoints, restoreCheckpoint } from "../checkpoints.js";
import { ingestYouTubeIntoWork } from "./ingest-youtube.js";
import { read as readFocus, write as writeFocus, subscribe as subscribeFocus, } from "../../focus/index.js";
import { resolve as resolveVariables } from "../../composition/variables/index.js";
import { synthesizeNarration } from "../../providers/tts/registry.js";
import { runAsrCaptions } from "../../domain/asr-captions.js";
import { getContext, getProfile, getTrends } from "../../context/index.js";
import { lintComposition } from "../../composition/quality/lint.js";
import { inspectComposition } from "../../composition/quality/inspect.js";
import { validateComposition } from "../../composition/quality/validate.js";
import { animationMap } from "../../composition/quality/animation-map.js";
import { createHash } from "node:crypto";
import { z } from "zod";
// Per-workId boolean flag controlling whether the terminal prefix line
// renders. Stored in-memory; frontend mirrors to localStorage for cross-
// reload persistence. CLI flips it via `autoviral context --inject on|off`.
const terminalInjectEnabled = new Map();
function readInject(workId) {
    return terminalInjectEnabled.get(workId) ?? true;
}
function writeInject(workId, enabled) {
    terminalInjectEnabled.set(workId, enabled);
}
// Exported for the B5 sibling-resolution regression test (paths.test.ts), which
// calls THIS function with AUTOVIRAL_MANUAL_DIR unset so the fallback branch is
// actually exercised — a parallel-constructed string in the test would not catch
// a child→child regression here.
export function manualDir() {
    // Anchor on PACKAGE_ROOT (not process.cwd()) so `autoviral docs` resolves the
    // bundled manual inside a packaged Electron app; AUTOVIRAL_MANUAL_DIR still wins.
    // skills/autoviral is a SIBLING of dist/ (= PACKAGE_ROOT), not a child — same
    // invariant as CLI_BIN_DIR and the boot-sync skills sync (server/index.ts:220
    // join(PACKAGE_ROOT, "..", "skills")). The old child resolution pointed at the
    // ghost dir dist/skills/autoviral/manual, so `autoviral docs` served nothing
    // in the dist/packaged layout (B5 same-family fix).
    return (process.env.AUTOVIRAL_MANUAL_DIR ??
        join(PACKAGE_ROOT, "..", "skills", "autoviral", "manual"));
}
export const bridgeRouter = new Hono();
// The version reported by `autoviral whoami` is the package version — the
// single source of truth is the package.json `version` field. We resolve it by
// walking up from PACKAGE_ROOT to the nearest package.json that carries a
// `version`, because PACKAGE_ROOT differs by build:
//   - packaged daemon: PACKAGE_ROOT = dist/, and scripts/write-dist-pkg.mjs
//     stamps the real `version` into dist/package.json → found on step 0.
//   - dev/test: paths.ts lives at src/infra/, so PACKAGE_ROOT = src/ (no
//     package.json) → we walk up one level to the repo root's package.json.
// The "0.0.0" fallback only fires if no manifest with a version is reachable —
// never hardcode a real version here, or `whoami` drifts from the published
// package on every bump.
function readPackageVersion() {
    let dir = PACKAGE_ROOT;
    for (let i = 0; i < 4; i++) {
        try {
            const raw = readFileSync(join(dir, "package.json"), "utf-8");
            const v = JSON.parse(raw).version;
            if (typeof v === "string" && v.length > 0)
                return v;
        }
        catch {
            // no package.json here (or unparseable) — keep walking up.
        }
        const parent = resolve(dir, "..");
        if (parent === dir)
            break;
        dir = parent;
    }
    return "0.0.0";
}
const BRIDGE_VERSION = readPackageVersion();
function workIdOrError(c) {
    const workId = c.req.header("X-AutoViral-Work-Id");
    if (!workId) {
        return {
            ok: false,
            res: c.json({ ok: false, error: "missing X-AutoViral-Work-Id header", code: 4 }, 400),
        };
    }
    return { ok: true, workId };
}
// S13 (US 11/12) — `?dry-run` opts a write into preview mode. Accept the common
// truthy spellings (`?dry-run`, `?dry-run=true`, `?dry-run=1`); a bare flag with
// no value (`?dry-run`) reads as "" which we also treat as on.
function isDryRun(c) {
    const v = c.req.query("dry-run");
    if (v === undefined)
        return false;
    return v === "" || v === "true" || v === "1";
}
bridgeRouter.get("/whoami", (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const port = Number(process.env.AUTOVIRAL_PORT ?? 3271);
    const body = {
        workId: g.workId,
        cwd: join(homedir(), ".autoviral/works", g.workId),
        port,
        version: BRIDGE_VERSION,
    };
    return c.json({ ok: true, result: body });
});
// Phase 2 — read-only composition routes. All three (/comp, /clips, /assets)
// load the same composition.yaml via composition-ops; we deliberately do NOT
// keep a cache because the fixture/dev workflow re-reads on every command
// and an agent that just wrote (Phase 3) must see fresh state immediately.
bridgeRouter.get("/comp", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    try {
        const comp = await readCompositionFor({ workId: g.workId });
        return c.json({ ok: true, result: comp });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message }, 500);
    }
});
// S4 (US 10) — PUT /comp: the full-composition write escape hatch. Before the
// intent-level verbs exist (or for a rich edit no single verb covers), the
// agent composes the whole composition client-side and PUTs it here. The body
// is a COMPLETE composition; it goes through the SAME chokepoint as every other
// write (writeCompositionFor → zod validate → tmpfile → atomic rename), so the
// "validate before disk touch" + "disk untouched on rejection" invariants hold.
// On success we broadcast composition-changed (S2) so Studio refetches without
// waiting on fs.watch. An unparseable body or an invalid composition is an
// input error → 400 + code:4 (S3 contract) and disk is left untouched.
bridgeRouter.put("/comp", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    let comp;
    try {
        comp = await c.req.json();
    }
    catch {
        return c.json({ ok: false, error: "body must be a JSON composition", code: 4 }, 400);
    }
    // S13 (US 11/12) — `?dry-run=true` previews the write WITHOUT touching disk
    // and WITHOUT broadcasting composition-changed. The mutator here is the
    // identity (the body IS the candidate), so we route it through the SAME
    // chokepoint preview (dryRunMutate) every verb uses and return the
    // {ok,errors,warnings} verdict. The request itself succeeds (200) even when
    // the candidate is invalid — the verdict carries the "not ok".
    if (isDryRun(c)) {
        try {
            const verdict = await dryRunMutate({ workId: g.workId }, () => comp);
            return c.json({ ok: true, result: verdict });
        }
        catch (err) {
            // A read failure (missing work) is a genuine request error, not a verdict.
            const message = err instanceof Error ? err.message : String(err);
            return c.json({ ok: false, error: message, code: 4 }, 400);
        }
    }
    try {
        // S6 (PRD-0007 §4.4) — route the whole-composition write through the SAME
        // per-work-locked chokepoint every intent verb uses (mutateCompositionFor),
        // with an IDENTITY mutator (the body IS the candidate — mirrors the dry-run
        // branch's `() => comp as Composition`). This serializes /comp PUT against
        // concurrent intent writes for the same work (lost-update fix) while keeping
        // the existing invariants: writeCompositionFor still zod-validates BEFORE
        // allocating a tmpfile, so an invalid composition throws and leaves
        // composition.yaml untouched, and onCommitted (the broadcast) fires ONLY
        // after the atomic write lands (S2).
        await mutateCompositionFor({ workId: g.workId }, () => comp, () => broadcast(g.workId, "composition-changed", { reason: "comp-put" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        // S3 (US 18/19) — a zod/shape rejection is an input error → 400 + code:4.
        return c.json({ ok: false, error: message, code: 4 }, 400);
    }
    return c.json({ ok: true });
});
// S13 (US 11/12) — POST /comp/validate: PREFLIGHT a candidate composition
// without writing it. The body is a COMPLETE candidate composition (any shape —
// the whole point is to tell the agent how far off it is). Runs the PURE
// `preflight` validator (CompositionSchema.safeParse for errors + IO-free lint
// rules for warnings) and returns {ok,errors,warnings}. NEVER touches disk,
// NEVER broadcasts. This kills the expensive "PUT → 400 → read zod dump → guess"
// loop: the agent validates first and fixes problems before committing.
//
// Like dry-run, an INVALID candidate is still a successful REQUEST (200) — the
// "not ok" lives in result.ok, not the HTTP status. Only a missing/unparseable
// BODY is a 400 + code:4 (the agent never even submitted a candidate to check).
bridgeRouter.post("/comp/validate", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    let candidate;
    try {
        candidate = await c.req.json();
    }
    catch {
        return c.json({ ok: false, error: "body must be a JSON composition", code: 4 }, 400);
    }
    const verdict = preflight(candidate);
    return c.json({ ok: true, result: verdict });
});
// S17 (US 26) — POST /comp/aspect: one-click canvas-ratio switch through the
// shared composition-ops core. Body `{ ratio: "9:16"|"1:1"|"16:9"|"4:5" }`; the
// ratio→dims flip + clip-offset adaptation are `ops.setAspectRatio` — the SAME
// implementation the Studio aspect control runs — so an agent switching ratio via
// the CLI (`autoviral comp aspect 1:1`) and a human clicking the control converge
// on the same composition. On success we broadcast composition-changed so Studio
// refetches and the preview canvas re-sizes. Invalid ratio →
// CompositionOpError{code:4} → HTTP 400 + code:4 → CLI exit 4.
bridgeRouter.post("/comp/aspect", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    if (typeof body.ratio !== "string" ||
        !ASPECTS.includes(body.ratio)) {
        return c.json({ ok: false, error: `invalid ratio (expected one of ${ASPECTS.join("/")})`, code: 4 }, 400);
    }
    const ratio = body.ratio;
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ops.setAspectRatio(comp, { ratio });
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "comp-aspect" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true, result: { ratio } });
});
// PRD-0009 B6 — POST /comp/duration: set the top-level comp.duration through the
// shared `ops.setCompositionDuration` — the ONLY write path for the overall
// timeline length. Body `{ durationSec }` (explicit finite non-negative) OR
// `{ auto: true }` (derive from max clip end, the store口径). The Studio store
// only ever GROWS duration (Math.max guards), so SHORTENING the timeline (e.g.
// crop a tail of static frames) had no agent path short of `comp put` /
// hand-editing the yaml — the dead-end chat-s_2 hit. On success we broadcast
// composition-changed (reason `comp-duration`) so Studio refetches.
//
// The response carries `{ duration, contentEnd, truncatesContent }` so the CLI
// can warn when an explicit duration is SHORTER than content (tail content past
// the new duration won't render — a legitimate but worth-flagging state).
// Invalid input → CompositionOpError{code:4} → HTTP 400 + code:4 → CLI exit 4.
bridgeRouter.post("/comp/duration", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    // Resolve the op param locally so an obviously-malformed body 400s before the
    // (locked, disk-touching) mutate. `auto:true` wins; otherwise durationSec must
    // be a finite non-negative number (the op re-validates as the source of truth).
    const auto = body.auto === true;
    if (!auto) {
        if (typeof body.durationSec !== "number" ||
            !Number.isFinite(body.durationSec) ||
            body.durationSec < 0) {
            return c.json({
                ok: false,
                error: "invalid duration (expected { durationSec: <finite non-negative number> } or { auto: true })",
                code: 4,
            }, 400);
        }
    }
    const param = auto
        ? { auto: true }
        : { durationSec: body.durationSec };
    try {
        const next = await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ops.setCompositionDuration(comp, param);
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "comp-duration" }));
        const contentEnd = ops.compositionContentEnd(next);
        return c.json({
            ok: true,
            result: {
                duration: next.duration,
                contentEnd,
                truncatesContent: next.duration < contentEnd,
            },
        });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
});
// Phase 5 Task 5.4 — unified diff between composition.yaml.previous (the
// snapshot taken just before the most recent write) and the current
// composition.yaml. Returns `{ diff: string, hasBaseline: boolean }`.
// When no baseline exists yet, `hasBaseline=false` and `diff=""` — the
// CLI prints a friendly "no prior write to diff against" message.
bridgeRouter.get("/comp/diff", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    try {
        const result = await diffCompositionFor({ workId: g.workId });
        return c.json({ ok: true, result });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message }, 500);
    }
});
// /clips and /assets are convenience projections of /comp. We keep them
// separate (rather than telling agents to "fetch /comp and filter in jq")
// because the JSON envelope itself is large, and a list-of-summaries is
// the natural shape an agent renders to a TUI table.
bridgeRouter.get("/clips", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const trackFilter = c.req.query("track");
    try {
        const comp = await readCompositionFor({ workId: g.workId });
        const clips = comp.tracks
            .filter((t) => !trackFilter || t.kind === trackFilter)
            .flatMap((t) => t.clips.map((clip) => ({
            id: clip.id,
            kind: clip.kind,
            trackId: t.id,
            trackKind: t.kind,
            trackOffset: clip.trackOffset,
            duration: "out" in clip
                ? clip.out - clip.in
                : clip.duration,
        })));
        return c.json({ ok: true, result: clips });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message }, 500);
    }
});
bridgeRouter.get("/assets", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const kindFilter = c.req.query("kind");
    try {
        const comp = await readCompositionFor({ workId: g.workId });
        const assets = comp.assets.filter((a) => !kindFilter || a.kind === kindFilter);
        return c.json({ ok: true, result: assets });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message }, 500);
    }
});
// Docs return raw markdown, NOT JSON-wrapped — `autoviral docs` pipes the
// body straight to stdout for the agent to read. Topic omitted → all
// manual files concatenated with thematic-break separators, sorted by
// filename so 00-overview / 10-... ordering is the author's lever.
// ─── Phase 3 — UI command routes ────────────────────────────────────────────
// Each route validates the body with zod, then publishes a "ui-*" event on
// UiEventBus. The /ws/bridge/:workId WebSocket forwards events to Studio.
// HTTP returns ok immediately — the round-trip is fire-and-forget; the
// agent does not block waiting for the UI to render. (The `/ask` route in
// Task 3.9 is the exception; it blocks until the user replies.)
function broadcast(workId, type, payload) {
    uiEventBus.publish(workId, { type, workId, ts: Date.now(), payload });
}
bridgeRouter.post("/select", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = SelectRequestSchema.parse(await c.req.json());
    broadcast(g.workId, "ui-select", body.target);
    return c.json({ ok: true, result: { selected: body.target } });
});
bridgeRouter.post("/seek", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = SeekRequestSchema.parse(await c.req.json());
    broadcast(g.workId, "ui-seek", { seconds: body.seconds });
    return c.json({ ok: true, result: { seekedTo: body.seconds } });
});
bridgeRouter.post("/play", (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    broadcast(g.workId, "ui-play", null);
    return c.json({ ok: true });
});
bridgeRouter.post("/pause", (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    broadcast(g.workId, "ui-pause", null);
    return c.json({ ok: true });
});
bridgeRouter.post("/toast", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = ToastRequestSchema.parse(await c.req.json());
    broadcast(g.workId, "ui-toast", body);
    return c.json({ ok: true });
});
bridgeRouter.post("/progress", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = ProgressRequestSchema.parse(await c.req.json());
    broadcast(g.workId, "ui-progress", body);
    return c.json({ ok: true });
});
// ─── H0.1 — Focus channel ───────────────────────────────────────────────────
// The frontend SSoT pushes UI-selection state here whenever the user clicks
// a clip / scrubs the playhead / focuses a panel. Both the chat panel (via
// <viewer-context> envelope) and the terminal panel (via dim [ctx:...] prefix)
// consume the same snapshot — chat reads it on outbound messages, terminal
// renders it as a prefix line. The `ui-focus` event lets any third-party
// surface subscribe via WS.
//
// H0.2 expanded the schema to include playhead, segment, and panel focus.
// All fields are optional in the patch so callers can update just what
// changed (no need to round-trip the full snapshot).
const ActivePanelSchema = z.enum([
    "timeline",
    "inspector",
    "preview",
    "sidebar",
]);
const FocusPatchSchema = z.object({
    selectedClipId: z.string().nullable().optional(),
    playheadSec: z.number().min(0).optional(),
    selectedSegmentId: z.string().nullable().optional(),
    activePanel: ActivePanelSchema.nullable().optional(),
});
bridgeRouter.get("/focus", (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    return c.json({ ok: true, result: readFocus(g.workId) });
});
bridgeRouter.post("/focus", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = FocusPatchSchema.parse(await c.req.json());
    const next = writeFocus(g.workId, body);
    broadcast(g.workId, "ui-focus", next);
    return c.json({ ok: true, result: next });
});
// ─── H0.3 — Context aggregator + SSE stream + inject toggle ─────────────────
bridgeRouter.get("/context", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const q = c.req.query();
    const ctx = await getContext(g.workId, {
        includeProfile: q.profile !== "false",
        includeTrends: q.trends === "true",
    });
    return c.json({
        ok: true,
        result: { ...ctx, terminalInjectEnabled: readInject(g.workId) },
    });
});
// H0.4 — dedicated endpoints (lighter than the full /context call)
bridgeRouter.get("/profile", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const profile = await getProfile();
    return c.json({ ok: true, result: profile });
});
// ─── H1.1-H1.4 — quality gate (static analysis; Puppeteer follow-up tbd) ────
async function readCompForQuality(workId) {
    const comp = await readCompositionFor({ workId });
    const worksRoot = process.env.AUTOVIRAL_WORKS_ROOT ??
        join(homedir(), ".autoviral/works");
    return { comp, workDir: join(worksRoot, workId) };
}
bridgeRouter.post("/quality/lint", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    try {
        const { comp, workDir } = await readCompForQuality(g.workId);
        return c.json({ ok: true, result: lintComposition(comp, { workDir }) });
    }
    catch (err) {
        return c.json({ ok: false, error: err.message }, 500);
    }
});
bridgeRouter.post("/quality/inspect", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    try {
        const { comp } = await readCompForQuality(g.workId);
        return c.json({ ok: true, result: inspectComposition(comp) });
    }
    catch (err) {
        return c.json({ ok: false, error: err.message }, 500);
    }
});
bridgeRouter.post("/quality/validate", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    try {
        const { comp } = await readCompForQuality(g.workId);
        return c.json({ ok: true, result: validateComposition(comp) });
    }
    catch (err) {
        return c.json({ ok: false, error: err.message }, 500);
    }
});
bridgeRouter.post("/quality/animation-map", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    try {
        const { comp } = await readCompForQuality(g.workId);
        return c.json({ ok: true, result: animationMap(comp) });
    }
    catch (err) {
        return c.json({ ok: false, error: err.message }, 500);
    }
});
bridgeRouter.post("/quality/check", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    try {
        const { comp, workDir } = await readCompForQuality(g.workId);
        const lint = lintComposition(comp, { workDir });
        const inspect = inspectComposition(comp);
        const validate = validateComposition(comp);
        const anim = animationMap(comp);
        const totalErrors = lint.counts.error + inspect.counts.error;
        const totalWarnings = lint.counts.warning + inspect.counts.warning + validate.counts.warning;
        return c.json({
            ok: true,
            result: {
                lint,
                inspect,
                validate,
                animationMap: anim,
                summary: {
                    totalErrors,
                    totalWarnings,
                    exitCode: totalErrors > 0 ? 6 : totalWarnings > 0 ? 5 : 0,
                },
            },
        });
    }
    catch (err) {
        return c.json({ ok: false, error: err.message }, 500);
    }
});
bridgeRouter.get("/trends", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const q = c.req.query();
    const platforms = q.platform
        ? q.platform.split(",")
        : undefined;
    const trends = await getTrends({
        platforms,
        topic: q.topic,
    });
    return c.json({ ok: true, result: trends });
});
// SSE stream — every focus-changed event flushes the latest context.
// Native ReadableStream + text/event-stream for max portability across
// Hono adapters (works on both `node` and `bun` and Vercel-style runners).
bridgeRouter.get("/context/stream", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const workId = g.workId;
    const stream = new ReadableStream({
        start(controller) {
            const encoder = new TextEncoder();
            const send = async () => {
                try {
                    const ctx = await getContext(workId);
                    const payload = JSON.stringify({
                        ...ctx,
                        terminalInjectEnabled: readInject(workId),
                    });
                    controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
                }
                catch {
                    // best-effort; do not tear down the stream on a transient read err
                }
            };
            // Initial snapshot
            void send();
            // Subscribe — every focus write fans out a re-snapshot.
            const unsub = subscribeFocus(workId, () => {
                void send();
            });
            // Keep-alive heartbeat every 25s to prevent some proxies from
            // closing idle SSE connections.
            const heartbeat = setInterval(() => {
                try {
                    controller.enqueue(encoder.encode(`: heartbeat\n\n`));
                }
                catch {
                    /* closed */
                }
            }, 25_000);
            // Client-disconnect cleanup via c.req.raw.signal (Hono).
            const signal = c.req.raw.signal;
            const onAbort = () => {
                clearInterval(heartbeat);
                unsub();
                try {
                    controller.close();
                }
                catch {
                    /* already closed */
                }
            };
            signal.addEventListener("abort", onAbort);
        },
    });
    return new Response(stream, {
        headers: {
            "Content-Type": "text/event-stream",
            "Cache-Control": "no-cache, no-transform",
            Connection: "keep-alive",
        },
    });
});
const InjectToggleSchema = z.object({
    enabled: z.boolean(),
});
bridgeRouter.post("/context/inject", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = InjectToggleSchema.parse(await c.req.json());
    writeInject(g.workId, body.enabled);
    broadcast(g.workId, "ui-context-inject", { enabled: body.enabled });
    return c.json({ ok: true, result: { enabled: body.enabled } });
});
// ─── H4.1 — TTS preprocess ──────────────────────────────────────────────────
// Synthesize narration audio through the unified TTS registry (ADR-007): edge-
// tts first, OpenAI as the keyed fallback. Drops the audio into the work's
// assets/audio/ directory and broadcasts an asset-added event so the Studio UI
// picks it up immediately. `voice` is an edge voice id (e.g.
// "zh-CN-XiaoxiaoNeural"); the openai fallback maps it to a gender-matched voice.
const TtsRequestSchema = z.object({
    text: z.string().min(1),
    voice: z.string().optional(),
    format: z.string().optional(),
    language: z.string().optional(),
    filenameStem: z.string().optional(),
});
bridgeRouter.post("/preprocess/tts", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    try {
        const body = TtsRequestSchema.parse(await c.req.json());
        const worksRoot = process.env.AUTOVIRAL_WORKS_ROOT ??
            join(homedir(), ".autoviral/works");
        const workDir = join(worksRoot, g.workId);
        const result = await synthesizeNarration({ ...body, workDir });
        broadcast(g.workId, "asset-added", {
            kind: "audio",
            uri: result.relativeUri,
            bytes: result.bytes,
            origin: "tts",
        });
        return c.json({ ok: true, result });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message }, 500);
    }
});
// ─── Phase 3 — composition write endpoints ──────────────────────────────────
// All three (POST /clip, PATCH /clip/:id, DELETE /clip/:id) go through
// mutateCompositionFor which read-modifies-writes atomically + validates
// via zod. Disk state is left untouched on validation failure. The Phase 3
// surface is intentionally minimal — agents that need richer mutations
// (split clips, reframe, smart-crop) compose them client-side and POST
// the resulting full composition; we add convenience verbs only for the
// few mutations that recur often enough to justify the round-trip.
function newClipId(track) {
    const prefix = track === "video" ? "vc"
        : track === "audio" ? "ac"
            : track === "text" ? "tc"
                : "oc";
    return `${prefix}_${randomBytes(3).toString("hex")}`;
}
bridgeRouter.post("/clip", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json());
    if (!body.track)
        return c.json({ ok: false, error: "missing track", code: 4 }, 400);
    // S10 fix-up — `body.track` is the clip KIND the agent asked for. The kind
    // dispatch below (if video / else if audio / else if text / else overlay)
    // treated `overlay` as a catch-all `else`, so any unknown track string would
    // have been *interpreted* as an overlay kind. The track lookup happens to
    // throw first (no lane of that kind), but the contract was implicit. Reject an
    // unknown kind UP FRONT with an explicit 400 + code:4, so the `else` branch is
    // reached ONLY for a genuine "overlay" value — never as a silent fallback.
    if (!TRACK_KINDS.includes(body.track)) {
        return c.json({ ok: false, error: `invalid track kind: ${body.track} (expected one of ${TRACK_KINDS.join("/")})`, code: 4 }, 400);
    }
    // S10 fix-up — `duration` is a RELATIVE clip length, not an absolute source
    // `out`. For video/audio (source-window clips) the old `out = body.out ??
    // (body.duration ?? 5)` mis-read `--in 2 --duration 3` as out=3 (a 1s clip)
    // instead of out=5 (a 3s clip). Compute the source `out` once, here: an
    // explicit `--out` always wins; else `in + duration`; else `in + 5` default.
    const inSec = body.in ?? 0;
    const outSec = body.out ??
        (body.duration != null ? inSec + body.duration : inSec + 5);
    let newId = "";
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            // S10 — precise targeting by trackId, else fall back to first same-kind.
            let track;
            if (body.trackId) {
                track = comp.tracks.find((t) => t.id === body.trackId);
                if (!track)
                    throw new Error(`No track with id ${body.trackId}`);
                if (track.kind !== body.track) {
                    throw new Error(`track ${body.trackId} is a ${track.kind} lane, not ${body.track}`);
                }
            }
            else {
                track = comp.tracks.find((t) => t.kind === body.track);
                if (!track)
                    throw new Error(`No track of kind ${body.track}`);
            }
            const id = newClipId(body.track);
            newId = id;
            const offset = body.offset ?? 0;
            if (body.track === "video") {
                if (!body.src)
                    throw new Error("video clip requires --src");
                track.clips.push({
                    id,
                    kind: "video",
                    src: body.src,
                    in: inSec,
                    out: outSec,
                    trackOffset: offset,
                    transforms: { scale: 1, x: 0, y: 0, rotation: 0 },
                    filters: { brightness: 0, contrast: 0, saturation: 0 },
                });
            }
            else if (body.track === "audio") {
                if (!body.src)
                    throw new Error("audio clip requires --src");
                track.clips.push({
                    id,
                    kind: "audio",
                    src: body.src,
                    in: inSec,
                    out: outSec,
                    trackOffset: offset,
                    volume: 1,
                    fadeIn: 0,
                    fadeOut: 0,
                });
            }
            else if (body.track === "text") {
                if (!body.text)
                    throw new Error("text clip requires --text");
                track.clips.push({
                    id,
                    kind: "text",
                    text: body.text,
                    trackOffset: offset,
                    duration: body.duration ?? 3,
                });
            }
            else {
                // S10 (US 6) — overlay is a first-class lane now (the old "not yet
                // supported in Phase 3" hard-reject is gone). The OverlayClip schema
                // (composition.ts) and the Scene → OverlayTrackRenderer dispatch both
                // already consume it, so this is NOT a dead field. Default to a
                // full-frame box at full opacity; agents reframe via PATCH /clip.
                if (!body.src)
                    throw new Error("overlay clip requires --src");
                track.clips.push({
                    id,
                    kind: "overlay",
                    src: body.src,
                    trackOffset: offset,
                    duration: body.duration ?? 5,
                    position: { xPct: 0, yPct: 0, wPct: 100, hPct: 100 },
                    opacity: 1,
                });
            }
            return comp;
        }, 
        // S2 (US 17) — explicit write-path broadcast. Only fires after the
        // atomic write lands on disk, so Studio refetches the new composition
        // without waiting on fs.watch (which is silent on missing dirs and
        // flaky on macOS rename events). fs.watch is now just a backstop.
        () => broadcast(g.workId, "composition-changed", { reason: "clip-add" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        // S3 (US 18/19) — input/validation rejection carries code:4 so the CLI
        // branches to exit 4 (vs 5xx service errors → exit 3).
        return c.json({ ok: false, error: message, code: 4 }, 400);
    }
    return c.json({ ok: true, result: { id: newId } });
});
bridgeRouter.delete("/clip/:id", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const id = c.req.param("id");
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => ({
            ...comp,
            tracks: comp.tracks.map((t) => ({
                ...t,
                clips: t.clips.filter((cl) => cl.id !== id),
            })),
        }), 
        // S2 (US 17) — broadcast only after the atomic write lands.
        () => broadcast(g.workId, "composition-changed", { reason: "clip-remove" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        // S3 (US 18/19) — input/validation rejection carries code:4.
        return c.json({ ok: false, error: message, code: 4 }, 400);
    }
    return c.json({ ok: true });
});
// S14 (US 20/21) — POST /captions/generate: the built-not-wired last mile for
// ASR captions. The /api/audio/captions endpoint has run stable-ts transcription
// for ages but had ZERO caller wiring its segments back into the composition —
// the web `fetchCaptions` service had no callers. This route closes the loop:
//
//   1. resolve the work's audio source (body `assetPath`, else auto-pick the
//      first audio-track clip's `src`),
//   2. run the SHARED `runAsrCaptions` core (same path /api/audio/captions uses),
//   3. write each timecoded segment as a TextClip into the text track via the
//      atomic mutateCompositionFor + broadcast composition-changed (S2), so an
//      agent (`autoviral captions generate`) and a human (Studio "生成字幕"
//      button) converge on the same composition.
//
// `runAsrCaptions` is the only side-effect; a 503 (PYTHON_DEP_MISSING) / 500
// (API_ERROR) from ASR is forwarded with its own status so the CLI maps it to a
// clear exit. The text-clip placement reuses the SAME shape POST /clip writes.
bridgeRouter.post("/captions/generate", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    // Resolve the audio source. Default: the first audio-track clip's `src`. The
    // caller may override with an explicit `assetPath` (relative to the work).
    let comp;
    try {
        comp = await readCompositionFor({ workId: g.workId });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message, code: 4 }, 400);
    }
    let src = body.assetPath;
    if (!src) {
        for (const t of comp.tracks) {
            if (t.kind !== "audio")
                continue;
            const first = t.clips.find((cl) => typeof cl.src === "string");
            if (first) {
                src = first.src;
                break;
            }
        }
    }
    if (!src) {
        return c.json({ ok: false, error: "no audio source: composition has no audio clip and no --asset given", code: 4 }, 400);
    }
    // The default audio src comes straight off the composition's audio clip, and
    // a real studio-persisted clip stores its src as a SERVED-URL —
    // `/api/works/<id>/assets/music/bgm.mp3` (leading slash + per-segment
    // encodeURIComponent), NOT a bare relative path. A naive `resolve(workDir,
    // "/api/works/...")` treats that leading slash as an ABSOLUTE path and lands
    // OUTSIDE the work dir, so the traversal guard below would reject the default
    // "生成字幕" entry point for every real work (E2E 2026-06-05, code:4). Strip
    // the `/api/works/<id>/assets/` prefix back to bare-relative so it resolves to
    // the real on-disk file — the SAME normalisation the snapshot path uses.
    //
    // ONLY this exact served-URL form for THIS work is rewritten: a malicious
    // absolute (`/etc/passwd`) or `../`-laden `--asset` does NOT match the
    // `/api/works/<id>/assets/` prefix, so it is left untouched and still caught
    // by the guard below (the `/etc/passwd` rejection test must keep passing).
    const servedPrefix = `/api/works/${g.workId}/assets/`;
    if (src.startsWith(servedPrefix)) {
        const rel = assetUrlToWorkRel(g.workId, src);
        // assetUrlToWorkRel returns the decoded bare-relative for the served-URL
        // form (never null here — the prefix matched), e.g. "assets/music/bgm.mp3".
        if (rel !== null)
            src = rel;
    }
    // Resolve to an absolute path under the work dir. `src` is now a work-relative
    // path like `assets/voice.mp3`; the shared core takes an absolute path.
    //
    // Fix B.1 — path-traversal guard. `src` is attacker-controllable (an agent or
    // a compromised caller can put `../` segments or an absolute path in
    // `--asset`). A bare `join(worksRoot, workId, src)` would happily resolve out
    // of the work dir and hand ASR an arbitrary file (`../../../etc/passwd`). We
    // `resolve` the candidate and assert it stays strictly INSIDE the work dir —
    // same containment check the docs-topic route (above) uses — rejecting any
    // escape with 400 code:4 BEFORE the file is ever touched.
    const worksRoot = process.env.AUTOVIRAL_WORKS_ROOT ?? join(homedir(), ".autoviral/works");
    const workDir = resolve(worksRoot, g.workId);
    const absAudioPath = resolve(workDir, src);
    const within = relative(workDir, absAudioPath);
    if (within.startsWith("..") || within.startsWith(sep) || within === "") {
        return c.json({ ok: false, error: `audio source escapes the work dir: ${src}`, code: 4 }, 400);
    }
    const asr = await runAsrCaptions(absAudioPath, body.language);
    if (!asr.ok) {
        return c.json({ ok: false, error: asr.error, code: asr.code }, asr.status);
    }
    // Fix B.3 — zero-segment ASR (silence / no detectable speech) must not look
    // like a silent success. Short-circuit BEFORE touching the composition (no new
    // empty text lane, no broadcast) and return an explicit signal so the agent /
    // UI knows nothing was written, rather than reading `written:0` as "done".
    if (asr.captions.length === 0) {
        return c.json({
            ok: true,
            result: {
                written: 0,
                language: body.language ?? null,
                message: "no speech detected in the audio source",
            },
        });
    }
    // Write the segments as TextClips into the text track. Same atomic
    // read-modify-write + broadcast the other clip-writes use.
    let written = 0;
    try {
        await mutateCompositionFor({ workId: g.workId }, (draft) => {
            // Target the explicit trackId when given, else the first text lane.
            let track;
            if (body.trackId) {
                track = draft.tracks.find((t) => t.id === body.trackId);
                if (!track)
                    throw new Error(`No track with id ${body.trackId}`);
                if (track.kind !== "text") {
                    throw new Error(`track ${body.trackId} is a ${track.kind} lane, not text`);
                }
            }
            else {
                track = draft.tracks.find((t) => t.kind === "text");
                // Fix B.2 — no text lane yet (the common case for a freshly created
                // video work). Rather than hard-400, AUTO-CREATE one via the SHARED
                // addTrack op (in-place mutate of the immer draft, ADR-009 §1/§2) so
                // captions for a new work just work. The CLI/UI converge on the same
                // lane-add math the "+ lane" button runs. An explicit non-text
                // `body.trackId` still rejects above — auto-create only fires on the
                // implicit "first text lane" path.
                if (!track) {
                    const { trackId } = ops.addTrack(draft, {
                        kind: "text",
                        opts: { language: body.language },
                    });
                    track = draft.tracks.find((t) => t.id === trackId);
                    if (!track)
                        throw new Error("failed to create text track for captions");
                }
            }
            for (const seg of asr.captions) {
                const duration = Math.max(0, seg.end - seg.start);
                track.clips.push({
                    id: newClipId("text"),
                    kind: "text",
                    text: seg.text,
                    trackOffset: Math.max(0, seg.start),
                    duration,
                });
                written += 1;
            }
            return draft;
        }, 
        // S2 (US 17) — broadcast only after the atomic write lands so Studio
        // refetches the new caption clips without waiting on fs.watch.
        () => broadcast(g.workId, "composition-changed", { reason: "captions-generate" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message, code: 4 }, 400);
    }
    return c.json({ ok: true, result: { written, language: body.language ?? null } });
});
// S10 (US 6/7/8) — POST /track: add a new lane through the shared composition-
// ops core. Body `{ kind, afterTrackId?, label?, language? }`; the placement
// math (end-of-same-kind-block default / afterTrackId insert / tail fallback),
// id mint and auto-label are `ops.addTrack` — the SAME implementation the
// Studio "+ lane" button runs — so an agent adding an A2 lane via the CLI
// (`autoviral track add --kind audio`) and a human clicking "+ lane" converge
// on the same composition. Missing work-id / invalid kind → code:4 → CLI exit 4.
// We echo the minted trackId so the agent can immediately add clips to it.
const TRACK_KINDS = ["video", "audio", "text", "overlay"];
bridgeRouter.post("/track", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    if (typeof body.kind !== "string" ||
        !TRACK_KINDS.includes(body.kind)) {
        return c.json({ ok: false, error: `invalid kind (expected one of ${TRACK_KINDS.join("/")})`, code: 4 }, 400);
    }
    const kind = body.kind;
    const opts = {};
    if (typeof body.afterTrackId === "string" && body.afterTrackId) {
        opts.afterTrackId = body.afterTrackId;
    }
    if (typeof body.label === "string" && body.label)
        opts.label = body.label;
    if (typeof body.language === "string" && body.language) {
        opts.language = body.language;
    }
    let trackId = "";
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ({ trackId } = ops.addTrack(comp, { kind, opts }));
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "track-add" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true, result: { trackId } });
});
// S10 (US 6/7/8) — DELETE /track/:id: remove a lane through the shared
// composition-ops core. `ops.removeTrack` splices the lane (its clips go with
// it) + recompacts displayOrder — the SAME implementation the Studio removeTrack
// action runs once its has-clips confirm gate has passed. The bridge has no
// confirm step (an agent that asked for a delete means it). Unknown trackId →
// CompositionOpError{code:4} → HTTP 400 + code:4 → CLI exit 4.
bridgeRouter.delete("/track/:id", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const id = c.req.param("id");
    if (!id) {
        return c.json({ ok: false, error: "missing track id", code: 4 }, 400);
    }
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ops.removeTrack(comp, { trackId: id });
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "track-remove" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true });
});
// ─── S2 (PRD-0007) — scene (分镜 / storyboard) write verbs ────────────────────
// The five scene routes mirror /split exactly: each delegates the mutation to a
// shared `@shared` scene op (the SAME implementation the Studio store runs), so
// an agent driving via `autoviral scene …` and a human editing a card in the
// panel converge on one `comp.scenes` record (ADR-009 agent-人一致). All go
// through mutateCompositionFor (per-work serialized after S6) + reuse the
// existing `composition-changed` broadcast so Studio refetches without waiting
// on fs.watch. Illegal params throw CompositionOpError{code:4} → HTTP 400 +
// code:4 → CLI exit 4. The op owns `order` / `id` / `status` invariants; the
// route does only the minimal shape gate the op cannot (a present title / an
// array of ids) and otherwise passes the body straight through.
// POST /scene — append a new 分镜. Body
// `{ title(必), intent?, prompt?, narration?, durationSec?, shotSize?,
//    cameraMovement?, mdAnchor?, memberClipIds?, memberAssetIds? }`.
// `ops.addScene` mints the `scn_` id + auto-assigns `order`; we echo the
// minted sceneId so the agent can immediately reference / link the new shot.
// A missing / non-string title is the one shape gate the op can't express → 400.
bridgeRouter.post("/scene", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    if (typeof body.title !== "string" || !body.title) {
        return c.json({ ok: false, error: "missing title", code: 4 }, 400);
    }
    let sceneId = "";
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ({ sceneId } = ops.addScene(comp, body));
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "scene-add" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true, result: { sceneId } });
});
// PATCH /scene/:id — patch editable scene props. Body is DIRECTLY the props
// object (mirrors PATCH /clip/:id). `ops.setSceneProps` enforces its own runtime
// allowlist (it ignores order/id/status/asset-state), so the route does not
// re-check those. Unknown sceneId → CompositionOpError{code:4} → 400 + code:4.
bridgeRouter.patch("/scene/:id", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const id = c.req.param("id");
    const props = (await c.req.json().catch(() => ({})));
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ops.setSceneProps(comp, { sceneId: id, props });
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "scene-set" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true });
});
// POST /scene/reorder — reorder scenes to the exact `orderedSceneIds` sequence.
// `ops.reorderScenes` requires a complete permutation of existing scene ids and
// recompacts order to contiguous 0..N-1. A non-array body is the one shape gate
// the op can't express cleanly → 400. An incomplete / mismatched permutation is
// the op's own CompositionOpError{code:4} → 400 + code:4.
bridgeRouter.post("/scene/reorder", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    if (!Array.isArray(body.orderedSceneIds)) {
        return c.json({ ok: false, error: "missing/invalid orderedSceneIds (expected string[])", code: 4 }, 400);
    }
    const orderedSceneIds = body.orderedSceneIds;
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ops.reorderScenes(comp, { orderedSceneIds });
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "scene-reorder" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true });
});
// POST /scene/:id/link — link generated assets onto a scene (the generation-
// handoff write-back). Body `{ assetIds: string[], selectedAssetId?, status? }`.
// `ops.linkSceneAssets` dedups assetIds onto generatedAssetIds + flips status.
// Unknown sceneId → CompositionOpError{code:4} → 400 + code:4.
bridgeRouter.post("/scene/:id/link", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const id = c.req.param("id");
    const body = (await c.req.json().catch(() => ({})));
    // Shape gate the op can't express: `assetIds` must be a non-empty string[].
    // Without it, a valid sceneId + missing assetIds reaches the op's
    // `for (const id of p.assetIds)` and throws a raw "undefined is not iterable"
    // TypeError — the HTTP result is still 400+code:4 (no write, no broadcast),
    // but the message is opaque. Reject up front with a self-describing error,
    // mirroring the title / orderedSceneIds gates.
    if (!Array.isArray(body.assetIds) || body.assetIds.length === 0) {
        return c.json({ ok: false, error: "missing/invalid assetIds (expected non-empty string[])", code: 4 }, 400);
    }
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ops.linkSceneAssets(comp, { sceneId: id, ...body });
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "scene-link" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true });
});
// POST /scene/:id/generate — the S7 generation HANDOFF (PRD-0007 §5). Build the
// image prompt from the SCENE'S OWN fields (prompt/title +景别/运镜/旁白 context),
// generate ONE image via the provider registry OUTSIDE the lock (it's the slow
// part), then register the AssetEntry + provenance edge AND link it onto the
// scene inside ONE locked mutator — so `generatedAssetIds` can never reference an
// id absent from `comp.assets` (no dangling-reference window). RESHOOT is just
// calling this again: linkSceneAssets appends a new take + moves selectedAssetId.
// Body is `{}` (optionally `{ provider }`); we never take a prompt from the body.
// Unknown sceneId / no prompt → 400 + code:4 (no generation, no write). A
// provider failure → 500 + code:4 (no dangling asset).
bridgeRouter.post("/scene/:id/generate", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const id = c.req.param("id");
    const body = (await c.req.json().catch(() => ({})));
    // ── Phase 1: read (unlocked) — build the prompt from the scene's own fields.
    // An unlocked read is fine here: we only use it to assemble the prompt + a
    // per-take filename. The authoritative register+link happens under the lock
    // below (and re-finds the scene there), so a concurrent edit can't corrupt
    // state — at worst the prompt reflects a slightly older scene snapshot.
    let comp;
    try {
        comp = await readCompositionFor({ workId: g.workId });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message, code: 4 }, 400);
    }
    const scene = comp.scenes?.find((s) => s.id === id);
    if (!scene) {
        // Mirror the CompositionOpError→400 mapping the sibling scene routes use
        // (routes.ts unknown-id paths): unknown sceneId → 400 + code:4.
        return c.json({ ok: false, error: `no scene with id ${id}`, code: 4 }, 400);
    }
    // base = prompt, else fall back to title; both empty → nothing to generate.
    const base = (scene.prompt && scene.prompt.trim()) || (scene.title && scene.title.trim()) || "";
    if (!base) {
        return c.json({ ok: false, error: "scene has no prompt to generate from", code: 4 }, 400);
    }
    // Append the directing context lines when present (景别 / 运镜 / 旁白).
    const lines = [base];
    if (scene.shotSize)
        lines.push(`镜头景别: ${scene.shotSize}`);
    if (scene.cameraMovement)
        lines.push(`运镜: ${scene.cameraMovement}`);
    if (scene.narration)
        lines.push(`旁白: ${scene.narration}`);
    const enriched = lines.join("\n");
    // ── Phase 2: generate ONE image OUTSIDE the lock (the slow part). Resolve the
    // provider the same way generate.ts:53-61 does. filename is unique per take so
    // a reshoot never overwrites the previous take's file.
    const provider = body.provider
        ? getProvider("image", body.provider)
        : getDefaultProvider("image");
    if (!provider) {
        return c.json({ ok: false, error: "No image provider available", code: 4 }, 500);
    }
    // Per-take filename. `take` is from the UNLOCKED read, so two genuinely
    // concurrent reshoots of the SAME scene could compute the same take number —
    // a random suffix makes the on-disk file collision-proof regardless (each
    // reshoot writes its own bytes; the keystone no-dangling invariant is held by
    // the locked register+link below either way). (review 2026-06-09 LOW)
    const take = (scene.generatedAssetIds?.length ?? 0) + 1;
    const filename = `scene_${id}_${take}_${randomUUID().slice(0, 4)}.png`;
    let result;
    try {
        result = await provider.generateImage({
            prompt: enriched,
            workId: g.workId,
            filename,
            // Canvas-follow (user decision 2026-06-10): storyboard takes inherit the
            // work's OWN composition aspect — the canvas the user picked — instead of
            // the model's hidden 1024×1024 square. No platform hard-coding: change
            // comp.aspect and the next take follows.
            aspectRatio: comp.aspect,
        });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message, code: 4 }, 500);
    }
    if (!result.success || !result.assetPath) {
        // Provider returned a failure — write NOTHING (no dangling asset).
        return c.json({ ok: false, error: result.error ?? "image generation failed", code: 4 }, 500);
    }
    // Convert the absolute write path → work-relative uri (mirrors
    // generate.ts:67-71 / 533-536): strip the per-work dir prefix.
    const wDirAbs = join(dataDir, "works", g.workId);
    const relativeUri = result.assetPath.startsWith(wDirAbs + "/")
        ? result.assetPath.slice(wDirAbs.length + 1)
        : result.assetPath;
    // ── Phase 3: register + link in ONE locked mutator (S6 per-work write lock).
    // 5b register the AssetEntry, 5c push a provenance edge, 5d link onto the
    // scene — all atomic, so generatedAssetIds[take-1] is in comp.assets the
    // instant the write lands. This is the whole point of S7 (no dangling ref).
    let assetId = "";
    try {
        await mutateCompositionFor({ workId: g.workId }, (live) => {
            assetId = `gen_${randomUUID().slice(0, 8)}`;
            live.assets ??= [];
            const newAsset = {
                id: assetId,
                uri: relativeUri,
                kind: "image",
                status: "ready",
                metadata: {},
            };
            live.assets.push(newAsset);
            live.provenance ??= [];
            const newEdge = {
                fromAssetId: null,
                toAssetId: assetId,
                operation: {
                    type: "generate",
                    actor: "user",
                    timestamp: new Date().toISOString(),
                    params: { providerId: provider.name, prompt: enriched, sceneId: id },
                },
            };
            live.provenance.push(newEdge);
            // linkSceneAssets appends the take + sets selectedAssetId + flips status.
            ops.linkSceneAssets(live, {
                sceneId: id,
                assetIds: [assetId],
                status: "generated",
            });
            return live;
        }, () => broadcast(g.workId, "composition-changed", { reason: "scene-generate" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    // Wrap the payload in the bridge envelope `{ ok, result }` — the SAME shape
    // every other data-returning bridge route uses (scene add → routes.ts:1099),
    // which the CLI's `bridgeRequest` unwraps via `json.result`. A flat body here
    // would make `autoviral scene generate` read `undefined.assetId` and crash.
    // (review 2026-06-09 CRITICAL — both reviewers; the frozen contract's flat
    // shape conflicted with the universal envelope, so the envelope wins.)
    return c.json({
        ok: true,
        result: {
            assetId,
            assetUri: relativeUri,
            sceneId: id,
            selectedAssetId: assetId,
            status: "generated",
        },
    });
});
// DELETE /scene/:id — remove a 分镜 + recompact order. `ops.removeScene` splices
// the existing scenes array + recompacts to contiguous 0..N-1. The bridge has no
// confirm step (an agent that asked for a delete means it). Unknown sceneId →
// CompositionOpError{code:4} → 400 + code:4.
bridgeRouter.delete("/scene/:id", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const id = c.req.param("id");
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ops.removeScene(comp, { sceneId: id });
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "scene-remove" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true });
});
// S6 (US 1/9) — POST /split: the first intent-level verb that goes through the
// shared composition-ops core. Body `{ clipId, at }` splits the clip whose
// time-range contains `at` into two halves, rebasing keyframes. The split math
// + invariants are `ops.splitClip` — the EXACT implementation the studio store
// runs — so an agent splitting via the CLI and a human splitting in the UI
// produce an identical composition. Illegal params (unknown id / out-of-clip /
// boundary) throw CompositionOpError{code:4} → HTTP 400 + code:4 → CLI exit 4.
bridgeRouter.post("/split", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    if (typeof body.clipId !== "string" || !body.clipId) {
        return c.json({ ok: false, error: "missing clipId", code: 4 }, 400);
    }
    if (typeof body.at !== "number" || !Number.isFinite(body.at)) {
        return c.json({ ok: false, error: "missing/invalid at (seconds)", code: 4 }, 400);
    }
    const clipId = body.clipId;
    const at = body.at;
    let newId = "";
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            const { newClipId } = ops.splitClip(comp, { clipId, atSec: at });
            newId = newClipId;
            return comp;
        }, 
        // S2 (US 17) — broadcast only after the atomic write lands so Studio
        // refetches the two new clips without waiting on fs.watch.
        () => broadcast(g.workId, "composition-changed", { reason: "clip-split" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        // CompositionOpError carries its own code (4 for the split guards); fall
        // back to 4 for any other validation-class throw on this write path.
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true, result: { id: newId } });
});
// S7 (US 2/9) — POST /clip/:id/trim: source-window trim through the shared
// composition-ops core. Body `{ in?, out? }` sets the clip's source window
// directly; the adjacency cap, minimum-duration floor and keyframe rebase are
// `ops.trimClip` — the SAME invariants the Studio edge-drag enforces — so an
// agent trimming via the CLI and a human dragging an edge converge on the same
// composition. Missing work-id / illegal params (unknown id, no in/out,
// degenerate window, duration-based clip) → CompositionOpError{code:4} →
// HTTP 400 + code:4 → CLI exit 4.
bridgeRouter.post("/clip/:id/trim", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const id = c.req.param("id");
    if (!id) {
        return c.json({ ok: false, error: "missing clip id", code: 4 }, 400);
    }
    const body = (await c.req.json().catch(() => ({})));
    let inSec;
    let outSec;
    if (body.in !== undefined) {
        if (typeof body.in !== "number" || !Number.isFinite(body.in)) {
            return c.json({ ok: false, error: "invalid in (seconds)", code: 4 }, 400);
        }
        inSec = body.in;
    }
    if (body.out !== undefined) {
        if (typeof body.out !== "number" || !Number.isFinite(body.out)) {
            return c.json({ ok: false, error: "invalid out (seconds)", code: 4 }, 400);
        }
        outSec = body.out;
    }
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ops.trimClip(comp, { clipId: id, in: inSec, out: outSec });
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "clip-trim" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true, result: { id } });
});
// S8 (US 3/9) — POST /clip/:id/move: relocate a clip to another lane of the
// SAME kind through the shared composition-ops core. Body `{ toTrackId }`; the
// same-kind guard, trackOffset preservation and source-track orphan-transition
// prune are `ops.moveClipToTrack` — the SAME invariants the Studio Inspector
// lane-select / drag enforces — so an agent moving via the CLI (`autoviral clip
// move <id> --to-track`) and a human dragging to another lane converge on the
// same composition. Missing work-id / illegal params (unknown clip, unknown
// target, cross-kind move) → CompositionOpError{code:4} → HTTP 400 + code:4 →
// CLI exit 4. (The orphan-transition prune is essential: without it the moved
// clip's now-dangling transition.afterClipId would fail the Track superRefine
// on writeCompositionFor's CompositionWriteSchema.parse → a 400 on a valid move.)
bridgeRouter.post("/clip/:id/move", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const id = c.req.param("id");
    if (!id) {
        return c.json({ ok: false, error: "missing clip id", code: 4 }, 400);
    }
    const body = (await c.req.json().catch(() => ({})));
    if (typeof body.toTrackId !== "string" || !body.toTrackId) {
        return c.json({ ok: false, error: "missing toTrackId", code: 4 }, 400);
    }
    const toTrackId = body.toTrackId;
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ops.moveClipToTrack(comp, { clipId: id, targetTrackId: toTrackId });
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "clip-move" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true, result: { id } });
});
// S9 (US 4/5/9) — POST /transition: add a cut-point transition on a video track
// through the shared composition-ops core. Body `{ trackId, afterClipId, preset,
// durationSec? }`; the video-only guard, the last-clip-anchor rejection, the
// shared-registry preset check and the handle clamp are all `ops.addTransition`
// — the SAME invariants the Studio transition picker enforces — so an agent
// adding a transition via the CLI (`autoviral transition add --track --after
// --preset --duration`) and a human picking one in the UI converge on the same
// composition. Missing work-id / illegal params (unknown track / clip, last-clip
// anchor, non-video track, unknown preset) → CompositionOpError{code:4} → HTTP
// 400 + code:4 → CLI exit 4. We echo the minted transition id back to the agent.
bridgeRouter.post("/transition", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    if (typeof body.trackId !== "string" || !body.trackId) {
        return c.json({ ok: false, error: "missing trackId", code: 4 }, 400);
    }
    if (typeof body.afterClipId !== "string" || !body.afterClipId) {
        return c.json({ ok: false, error: "missing afterClipId", code: 4 }, 400);
    }
    if (typeof body.preset !== "string" || !body.preset) {
        return c.json({ ok: false, error: "missing preset", code: 4 }, 400);
    }
    let durationSec;
    if (body.durationSec !== undefined) {
        if (typeof body.durationSec !== "number" || !Number.isFinite(body.durationSec)) {
            return c.json({ ok: false, error: "invalid durationSec (seconds)", code: 4 }, 400);
        }
        durationSec = body.durationSec;
    }
    const trackId = body.trackId;
    const afterClipId = body.afterClipId;
    const preset = body.preset;
    let newId = "";
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            const { transitionId } = ops.addTransition(comp, {
                trackId,
                afterClipId,
                preset,
                durationSec,
            });
            newId = transitionId;
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "transition-add" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true, result: { id: newId } });
});
// S9 (US 4/5/9) — DELETE /transition/:id: remove a transition (restore a hard
// cut) through the shared `ops.removeTransition`, the SAME op the Studio remove
// path runs, so the agent's `autoviral transition remove <id>` and a human's
// click converge. An unknown transition id → CompositionOpError{code:4} → HTTP
// 400 + code:4 → CLI exit 4.
bridgeRouter.delete("/transition/:id", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const id = c.req.param("id");
    if (!id) {
        return c.json({ ok: false, error: "missing transition id", code: 4 }, 400);
    }
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ops.removeTransition(comp, { transitionId: id });
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "transition-remove" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true, result: { id } });
});
// S12 (US 16 / 35-37 backfill) — POST /clip/:id/keyframe: author one keyframe on
// a numeric clip property through the shared `ops.addKeyframe`, the SAME
// collision math the Studio KeyframePanel runs, so an agent authoring a
// crossfade / Ken Burns curve via the CLI (`autoviral clip keyframe add/set`)
// and a human dragging a keyframe in the UI converge on an identical `keyframes`
// array. This is the verb that finally makes keyframe edits runnable from the
// CLI — the old `clip set --keyframes '[...]'` path could only 400 (a scalar flag
// can't carry a Keyframe[]). Body `{ property, atSec, value, easing? }`. Illegal
// params (unknown clip, text clip, bad property/easing, negative time, speed out
// of range) → CompositionOpError{code:4} → HTTP 400 + code:4 → CLI exit 4.
bridgeRouter.post("/clip/:id/keyframe", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const id = c.req.param("id");
    if (!id) {
        return c.json({ ok: false, error: "missing clip id", code: 4 }, 400);
    }
    const body = (await c.req.json().catch(() => ({})));
    if (typeof body.property !== "string" || !body.property) {
        return c.json({ ok: false, error: "missing property", code: 4 }, 400);
    }
    if (typeof body.atSec !== "number" || !Number.isFinite(body.atSec)) {
        return c.json({ ok: false, error: "missing/invalid atSec (seconds)", code: 4 }, 400);
    }
    if (typeof body.value !== "number" || !Number.isFinite(body.value)) {
        return c.json({ ok: false, error: "missing/invalid value", code: 4 }, 400);
    }
    let easing;
    if (body.easing !== undefined) {
        if (typeof body.easing !== "string" || !body.easing) {
            return c.json({ ok: false, error: "invalid easing", code: 4 }, 400);
        }
        easing = body.easing;
    }
    const property = body.property;
    const atSec = body.atSec;
    const value = body.value;
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            ops.addKeyframe(comp, {
                clipId: id,
                property: property,
                atSec,
                value,
                easing: easing,
            });
            return comp;
        }, () => broadcast(g.workId, "composition-changed", { reason: "keyframe-add" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true, result: { id } });
});
// POST /ask blocks the HTTP response until the Studio user clicks
// YES/NO in the ApprovalPrompt modal — or until timeoutMs elapses.
// CLI exit codes (per protocol §5): yes=0, no=1, cancelled=2, timeout=124.
bridgeRouter.post("/ask", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = AskRequestSchema.parse(await c.req.json());
    const { askId, promise } = createAsk(g.workId, body.timeoutMs);
    broadcast(g.workId, "ui-ask", {
        askId,
        message: body.message,
        kind: body.kind,
    });
    const answer = await promise;
    if (answer === "timeout") {
        return c.json({ ok: false, error: "timeout", code: 124 }, 504);
    }
    return c.json({ ok: true, result: { answer } });
});
// POST /export — wrap runRenderPipeline, stream progress via ui-render-
// progress events. Phase 5 hardens preset selection; Phase 3 just passes
// `--proxy` through for fast review renders.
bridgeRouter.post("/export", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    // S15 (US 22/23/24) — resolve `--preset` against the @shared single-source
    // table BEFORE any work. An unknown name is a caller error: fail loud with
    // 400 + code:4 (S3 contract) instead of silently rendering the comp's old
    // settings (the dead-control bug this slice kills). A blank/omitted preset
    // means "use the comp's own exportPresets[0]" — unchanged legacy behaviour.
    let preset;
    if (body.preset != null && String(body.preset).trim().length > 0) {
        preset = resolvePlatformPreset(body.preset);
        if (!preset) {
            return c.json({ ok: false, error: `unknown preset: ${body.preset}`, code: 4 }, 400);
        }
    }
    try {
        const raw = await readCompositionFor({ workId: g.workId });
        // Resolve variables BEFORE render so the composition reaching Remotion
        // contains concrete values. resolve() is a no-op when raw.variables
        // is absent so existing works are unaffected.
        const resolved = resolveVariables(raw, {
            overrides: body.variables ?? {},
            strict: body.strictVariables === true,
        });
        const { resolvedValues, issues } = resolved;
        // When a preset was supplied, fold its canvas + encode settings into the
        // composition so render-pipeline's encode stage (reads exportPresets[0])
        // and canvas dimensions follow the platform. The loudness LUFS is passed
        // out-of-band to runRenderPipeline (loudnorm stage reads it, not the comp).
        const comp = preset
            ? {
                ...resolved.composition,
                width: preset.width,
                height: preset.height,
                fps: preset.fps,
                exportPresets: [preset],
            }
            : resolved.composition;
        // Output filename gets a short hash of the override JSON so multiple
        // variants don't clobber each other.
        const overrideHash = Object.keys(body.variables ?? {}).length > 0
            ? createHash("sha1")
                .update(JSON.stringify(resolvedValues))
                .digest("hex")
                .slice(0, 8)
            : null;
        const worksRoot = process.env.AUTOVIRAL_WORKS_ROOT ??
            join(homedir(), ".autoviral/works");
        const outDir = join(worksRoot, g.workId, "output");
        const finalPath = await runRenderPipeline({
            comp,
            outDir,
            proxy: body.proxy ?? false,
            // S15 — drive the loudnorm stage from the resolved preset. Without this
            // the pipeline always fell back to its -14 default, so 微信(-16) etc.
            // were unreachable via /export (issue #80). Omitted when no preset so
            // legacy works keep the prior -14 behaviour.
            loudnessTargetLufs: preset?.loudnessTargetLufs,
            // outputTitle becomes the filename stem; appending the hash keeps
            // variant outputs from clobbering each other.
            outputTitle: overrideHash ? `autoviral-export_${overrideHash}` : undefined,
            onProgress: (stage, pct) => {
                uiEventBus.publish(g.workId, {
                    type: "ui-render-progress",
                    workId: g.workId,
                    ts: Date.now(),
                    payload: { stage, pct },
                });
            },
        });
        return c.json({
            ok: true,
            result: { path: finalPath, resolvedValues, issues },
        });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message }, 500);
    }
});
// POST /snapshot — I21. Capture the CURRENT frame (video, via Remotion
// renderStill at the playhead or --at <seconds>) or the current slide
// (carousel, --slide <id>) as a PNG and return its absolute path. The CLI
// prints the path so the agent can Read it and visually self-check the output
// before declaring done (invariant #6 — verify what's visible, not the assumed
// artifact). Much faster than a full export: one frame, not the whole timeline.
bridgeRouter.post("/snapshot", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    try {
        const result = await renderSnapshot({
            workId: g.workId,
            at: typeof body.at === "number" ? body.at : undefined,
            slide: typeof body.slide === "string" ? body.slide : undefined,
        });
        return c.json({ ok: true, result });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message }, 500);
    }
});
// ─── S21 (US 33/34) — agent-reachable checkpoint restore ─────────────────────
// Checkpoints are taken every agent turn (see ws-bridge per-turn createCheckpoint)
// but until now the agent had no verb to roll one BACK. These two endpoints give
// `autoviral checkpoint list|restore` a server surface so the agent can recover
// from a bad hand-edit safely.
//
// GET /checkpoints — list rollback history, newest first, including the optional
// #90 user label. Pure read.
bridgeRouter.get("/checkpoints", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    try {
        const result = await listCheckpoints(g.workId);
        return c.json({ ok: true, result });
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message }, 500);
    }
});
// POST /restore — roll the live deliverable back to a checkpoint. Body:
//   { file: "<ts>__<sha>__<deliverable>" }.
//
// CRITICAL (#68): restoreCheckpoint snapshots the CURRENT live state FIRST and
// only then overwrites it — restore is itself a destructive write, and the
// autosave path (PUT /composition) does NOT checkpoint, so a user's pending
// edits live ONLY in the live yaml. Pre-snapshotting makes restore reversible;
// the primitive throws (rather than swallows) if it can't preserve current
// state, so we surface that as a 500 instead of destroying data. A bad / unknown
// `file` returns null → 404 code:4 (input error per contracts/error-codes.md);
// the live deliverable is left UNTOUCHED. We broadcast composition/carousel
// "changed" so Studio refetches the rolled-back state without waiting on fs.watch.
bridgeRouter.post("/restore", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    const file = typeof body.file === "string" ? body.file : "";
    if (!file) {
        return c.json({ ok: false, error: "restore requires a `file` field", code: 4 }, 400);
    }
    try {
        // S6 — restore is an out-of-band write onto the live deliverable
        // (createCheckpoint → copyFile composition.yaml). Serialize it through the
        // SAME per-work lock every intent verb / PUT /comp uses, so a POST /restore
        // racing an in-flight mutateCompositionFor for this work can't interleave
        // (the copyFile landing between a mutate's read and its atomic rename).
        // restoreCheckpoint never re-enters the lock (no mutateCompositionFor call
        // inside it) → no self-deadlock.
        const result = await withWorkLock(g.workId, () => restoreCheckpoint(g.workId, file));
        if (result === null) {
            // Unknown / malformed filename, or no such snapshot on disk. Input error.
            return c.json({ ok: false, error: `no such checkpoint: ${file}`, code: 4 }, 404);
        }
        // Nudge the right preview to reload the rolled-back deliverable.
        const reason = "checkpoint-restore";
        if (result.deliverable === "carousel.yaml") {
            broadcast(g.workId, "carousel-changed", { reason });
        }
        else {
            broadcast(g.workId, "composition-changed", { reason });
        }
        return c.json({ ok: true, result });
    }
    catch (err) {
        // The #68 pre-restore snapshot failed (or copy failed) — we must NOT have
        // destroyed the live state. Report as a service error, not an input error.
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message }, 500);
    }
});
// S11 (US 13/14/15) — PATCH /clip/:id now routes through the shared
// `ops.patchClipProps` instead of a blind `{ ...clip, ...patch }` shallow
// merge. The old merge let CompositionSchema (which has NO `.strict()`)
// SILENTLY STRIP any unknown / misspelled / nested key — `--scal 2` 200-OK'd
// while changing nothing (PRD-0004 #4, the worst kind of bug: a silent no-op).
// The op validates every key against a per-kind whitelist, resolves dotted
// nested paths (transforms.scale / filters.brightness / ducking.ratio / …),
// and throws CompositionOpError{code:4} on the first unknown key → HTTP 400 +
// code:4 → CLI exit 4. An unknown clip id is likewise a code:4 rejection.
bridgeRouter.patch("/clip/:id", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const id = c.req.param("id");
    const patch = (await c.req.json());
    try {
        await mutateCompositionFor({ workId: g.workId }, (comp) => {
            let found = false;
            for (const track of comp.tracks) {
                const clip = track.clips.find((cl) => cl.id === id);
                if (clip) {
                    // `comp.tracks[*].clips[*]` is a discriminated-union member; the op
                    // narrows on `.kind` internally so a plain cast is enough here.
                    ops.patchClipProps(clip, patch);
                    found = true;
                    break;
                }
            }
            if (!found) {
                throw new CompositionOpError(`no clip with id ${id}`, 4);
            }
            return comp;
        }, 
        // S2 (US 17) — broadcast only after the atomic write lands.
        () => broadcast(g.workId, "composition-changed", { reason: "clip-set" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        // S3 (US 18/19) — input/validation rejection carries code:4. The op throws
        // its own CompositionOpError{code:4}; fall back to 4 for any other
        // validation-class throw on this write path (e.g. a zod range failure).
        const code = err instanceof CompositionOpError ? err.code : 4;
        return c.json({ ok: false, error: message, code }, 400);
    }
    return c.json({ ok: true });
});
// ─── I08 — carousel write endpoints ─────────────────────────────────────────
// The carousel analogue of the clip endpoints. Both go through
// mutateCarouselFor which read-modifies-writes carousel.yaml atomically +
// validates the WHOLE carousel via CarouselSchema before it touches disk.
// On validation failure the mutator throws and disk state is left untouched —
// the only invariant the agent can chain on. Invalid input → HTTP 400
// `{ ok:false, error, code:4 }` → CLI exit 4 (per contracts/error-codes.md).
//
// Why two verbs: `add-slide` + `set-layer` are the carousel mutations that
// recur often enough to justify a round-trip. Richer mutations (reorder
// slides, restyle globals) are composed client-side and PUT as a full
// carousel via /api/works/:id/carousel.
// POST /carousel/slide — append a slide (optionally at an index). Body:
//   { at?: number, bg?: SlideBg }  — all optional; defaults to an empty
//   gradient slide appended at the end. Returns { id } of the new slide.
bridgeRouter.post("/carousel/slide", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const body = (await c.req.json().catch(() => ({})));
    let newId = "";
    try {
        await mutateCarouselFor({ workId: g.workId }, (carousel) => {
            const slide = makeEmptySlide();
            if (body.bg !== undefined) {
                // Validate just the bg sub-shape eagerly so a bad --bg fails with a
                // pointed message rather than a whole-carousel zod dump. The final
                // CarouselSchema.parse in writeCarouselFor is the real gate.
                slide.bg = SlideBgSchema.parse(body.bg);
            }
            newId = slide.id;
            const at = typeof body.at === "number" && Number.isFinite(body.at)
                ? Math.max(0, Math.min(carousel.slides.length, Math.trunc(body.at)))
                : carousel.slides.length;
            const slides = [...carousel.slides];
            slides.splice(at, 0, slide);
            return { ...carousel, slides, updatedAt: new Date().toISOString() };
        }, 
        // S2 (US 17) — broadcast only after the atomic write lands so the
        // carousel preview refetches without waiting on fs.watch.
        () => broadcast(g.workId, "carousel-changed", { reason: "slide-add" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message, code: 4 }, 400);
    }
    return c.json({ ok: true, result: { id: newId } });
});
// POST /carousel/slide/:slideId/layer — add or PATCH one layer on a slide.
// Body is a (possibly partial) Layer object ({ kind:"text"|..., box, ... }).
// If `id` is present and matches an existing layer the body is DEEP-MERGED
// onto it (only the supplied fields are overridden; box / style / filters
// merge per-leaf so a `--text`-only edit preserves the rest) — the carousel
// twin of S11's `clip set` patch (applyLayerPatch). Otherwise a fresh id is
// minted and the layer created with per-kind defaults. `kind` is NOT patchable
// on an existing layer. The result is validated against LayerSchema before the
// whole carousel is re-validated by writeCarouselFor. Returns { id }.
bridgeRouter.post("/carousel/slide/:slideId/layer", async (c) => {
    const g = workIdOrError(c);
    if (!g.ok)
        return g.res;
    const slideId = c.req.param("slideId");
    const raw = (await c.req.json().catch(() => null));
    if (!raw || typeof raw !== "object") {
        return c.json({ ok: false, error: "layer body must be a JSON object", code: 4 }, 400);
    }
    let layerId = "";
    try {
        await mutateCarouselFor({ workId: g.workId }, (carousel) => {
            const idx = carousel.slides.findIndex((s) => s.id === slideId);
            if (idx === -1)
                throw new Error(`no slide with id "${slideId}"`);
            const slide = carousel.slides[idx];
            // applyLayerPatch owns the create-vs-patch decision: a matching id
            // deep-merges (preserving unsupplied fields), a new/absent id creates.
            // It throws on a malformed layer / kind change BEFORE we mutate.
            const layer = applyLayerPatch(slide.layers, raw);
            layerId = layer.id;
            const existing = slide.layers.findIndex((l) => l.id === layer.id);
            const layers = existing === -1
                ? [...slide.layers, layer]
                : slide.layers.map((l, i) => (i === existing ? layer : l));
            const slides = carousel.slides.map((s, i) => i === idx ? { ...s, layers } : s);
            return { ...carousel, slides, updatedAt: new Date().toISOString() };
        }, 
        // S2 (US 17) — broadcast only after the atomic write lands.
        () => broadcast(g.workId, "carousel-changed", { reason: "layer-set" }));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message, code: 4 }, 400);
    }
    return c.json({ ok: true, result: { id: layerId } });
});
// POST /ingest/youtube — download a YouTube URL into the current work,
// transcribe with Whisper, translate to the target language via OpenRouter,
// then bootstrap composition.yaml so the Studio can render the result.
// Long-running (download + ASR + translation can take minutes); progress
// is streamed over the bridge UI event bus, not via HTTP chunks.
bridgeRouter.post("/ingest/youtube", async (c) => {
    const got = workIdOrError(c);
    if (!got.ok)
        return got.res;
    const body = (await c.req.json().catch(() => ({})));
    if (!body.url || typeof body.url !== "string") {
        return c.json({ ok: false, error: "Body must include { url: string }" }, 400);
    }
    const result = await ingestYouTubeIntoWork({
        workId: got.workId,
        url: body.url,
        targetLanguage: body.language ?? "zh-CN",
        translateModel: body.model,
        startSec: typeof body.start === "number" ? body.start : undefined,
        endSec: typeof body.end === "number" ? body.end : undefined,
    });
    if (!result.ok) {
        return c.json({ ok: false, step: result.step, error: result.error, code: result.code }, 500);
    }
    return c.json({ ok: true, result });
});
bridgeRouter.get("/docs", async (c) => {
    const topic = c.req.query("topic");
    const dir = manualDir();
    try {
        if (topic) {
            const rel = topic.endsWith(".md") ? topic : `${topic}.md`;
            // C2 (PRD-0009) — contracts/ + recipes/ are SIBLINGS of manual/ under
            // skills/autoviral/, not children. The manual散文 references them as
            // `contracts/error-codes.md` / `recipes/<...>.md`, but the docs anchor is
            // manual/, so those topics 404'd. Resolve a topic in EITHER sibling
            // namespace against the skill ROOT (manual's parent) instead. The
            // AUTOVIRAL_MANUAL_DIR override is honoured: its parent is treated as the
            // skill root, so an override pointing at a custom manual still finds the
            // contracts/recipes that sit beside it. Manual topics keep resolving inside
            // manual/. Each path keeps its OWN containment guard against the dir it
            // resolves under, so a crafted `contracts/../../secret` (or a manual
            // `../../secret`) can't escape its tree.
            const isSibling = rel.startsWith("contracts/") || rel.startsWith("recipes/");
            const baseDir = isSibling ? resolve(dir, "..") : resolve(dir);
            const file = resolve(baseDir, rel);
            const within = relative(baseDir, file);
            if (within.startsWith("..") || within.startsWith(sep) || within === "") {
                return c.json({ ok: false, error: `invalid docs topic: ${topic}` }, 404);
            }
            const md = await readFile(file, "utf8");
            return c.text(md);
        }
        // No topic → concatenate the whole manual, recursing one level into
        // subdirs (e.g. carousel/) so a content-type's chapters are included.
        const files = await listManualMarkdown(dir);
        const chunks = await Promise.all(files.map((f) => readFile(join(dir, f), "utf8")));
        return c.text(chunks.join("\n\n---\n\n"));
    }
    catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return c.json({ ok: false, error: message }, 404);
    }
});
// Collect markdown chapters under the manual dir, recursing one level into
// subdirectories (carousel/, etc.). Returns paths RELATIVE to `dir`, sorted
// so the dump order is stable (top-level chapters first, then subdir chapters).
async function listManualMarkdown(dir) {
    const entries = await readdir(dir, { withFileTypes: true });
    const out = [];
    for (const e of entries) {
        if (e.isFile() && e.name.endsWith(".md"))
            out.push(e.name);
        else if (e.isDirectory()) {
            const sub = await readdir(join(dir, e.name)).catch(() => []);
            for (const f of sub)
                if (f.endsWith(".md"))
                    out.push(`${e.name}/${f}`);
        }
    }
    return out.sort();
}
//# sourceMappingURL=routes.js.map