editorbrain 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,13 +5,13 @@ Editorial references, brand context and portable video projects for Claude Code
5
5
  Inspect the CLI without configuring your agent:
6
6
 
7
7
  ```sh
8
- npx editorbrain@0.32.0 help
8
+ npx editorbrain@0.34.0 help
9
9
  ```
10
10
 
11
11
  For a persistent installation:
12
12
 
13
13
  ```sh
14
- npm install -g editorbrain@0.32.0
14
+ npm install -g editorbrain@0.34.0
15
15
  editorbrain login --url https://api.editorbrain.com
16
16
  editorbrain status --url https://api.editorbrain.com
17
17
  editorbrain projects list
@@ -1,9 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // packages/cli/src/sfx.ts
4
- import { readdir as readdir2, lstat as lstat3 } from "node:fs/promises";
5
- import { join as join2, extname as extname2, basename } from "node:path";
6
-
7
3
  // packages/core/src/creative-direction.ts
8
4
  var creativePreferenceSections = [
9
5
  { id: "brand", title: "Marca y tipograf\xEDa", question: "\xBFConservamos tus colores, fuentes e idioma, o quieres ajustarlos?" },
@@ -57125,13 +57121,6 @@ function canonicalFiles(files) {
57125
57121
  return result2;
57126
57122
  }
57127
57123
 
57128
- // packages/cli/src/media-runtime.ts
57129
- import { spawn } from "node:child_process";
57130
- import { createHash } from "node:crypto";
57131
- import { constants } from "node:fs";
57132
- import { lstat, open, realpath } from "node:fs/promises";
57133
- import { dirname, extname, parse, resolve } from "node:path";
57134
-
57135
57124
  // packages/cli/src/errors.ts
57136
57125
  var CliError = class extends Error {
57137
57126
  constructor(code, message, hint, retryable = false, details = {}) {
@@ -57154,7 +57143,346 @@ function errorOutput(error) {
57154
57143
  return { error: safe, code: "COMMAND_FAILED", retryable: false, hint: "Read the command help and run editorbrain doctor. Do not retry writes blindly." };
57155
57144
  }
57156
57145
 
57146
+ // packages/cli/src/production-output.ts
57147
+ import { constants as constants2 } from "node:fs";
57148
+ import { mkdir as mkdir2, open as open2 } from "node:fs/promises";
57149
+ import { dirname as dirname2 } from "node:path";
57150
+
57151
+ // packages/cli/src/io.ts
57152
+ import { constants } from "node:fs";
57153
+ import { lstat, mkdir, open, readdir, realpath, rename, unlink, link } from "node:fs/promises";
57154
+ import { dirname, join, relative, resolve, sep } from "node:path";
57155
+ import { randomUUID } from "node:crypto";
57156
+ async function ensureRoot(directory, create = false) {
57157
+ const root = resolve(directory);
57158
+ if (create) await mkdir(root, { recursive: true });
57159
+ const info = await lstat(root);
57160
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("Workspace root must be a real directory, not a symlink");
57161
+ return realpath(root);
57162
+ }
57163
+ async function checkedPath(root, path, document2 = true) {
57164
+ if (document2) safeDocumentPath(path);
57165
+ const target = resolve(root, path);
57166
+ const rel = relative(root, target);
57167
+ if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || resolve(path) === path) throw new Error("Path escapes workspace");
57168
+ let cursor = root;
57169
+ for (const segment of rel.split(sep)) {
57170
+ cursor = join(cursor, segment);
57171
+ try {
57172
+ const info = await lstat(cursor);
57173
+ if (info.isSymbolicLink()) throw new Error(`Symlinks are not allowed: ${path}`);
57174
+ } catch (error) {
57175
+ if (error.code !== "ENOENT") throw error;
57176
+ }
57177
+ }
57178
+ return target;
57179
+ }
57180
+ async function readText(root, path, document2 = true) {
57181
+ const target = await checkedPath(root, path, document2);
57182
+ const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
57183
+ try {
57184
+ const info = await handle.stat();
57185
+ if (!info.isFile()) throw new Error(`Not a regular file: ${path}`);
57186
+ if (document2 && info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document exceeds 128 KiB: ${path}`);
57187
+ return new TextDecoder("utf-8", { fatal: true }).decode(await handle.readFile());
57188
+ } finally {
57189
+ await handle.close();
57190
+ }
57191
+ }
57192
+ async function atomicWrite(root, path, content, options = {}) {
57193
+ const target = await checkedPath(root, path, options.document ?? true);
57194
+ await mkdir(dirname(target), { recursive: true });
57195
+ await checkedPath(root, path, options.document ?? true);
57196
+ const temporary = join(dirname(target), `.eh-${randomUUID()}.tmp`);
57197
+ const handle = await open(temporary, "wx", options.mode ?? 420);
57198
+ try {
57199
+ await handle.writeFile(content, "utf8");
57200
+ await handle.close();
57201
+ await checkedPath(root, path, options.document ?? true);
57202
+ if (options.overwrite === false) {
57203
+ await link(temporary, target);
57204
+ await unlink(temporary);
57205
+ } else await rename(temporary, target);
57206
+ } catch (error) {
57207
+ await handle.close().catch(() => {
57208
+ });
57209
+ await unlink(temporary).catch(() => {
57210
+ });
57211
+ throw error;
57212
+ }
57213
+ }
57214
+ async function readWorkspace(root) {
57215
+ const files = /* @__PURE__ */ Object.create(null);
57216
+ async function walk(path) {
57217
+ const directory = await checkedPath(root, path, false);
57218
+ let entries;
57219
+ try {
57220
+ entries = await readdir(directory, { withFileTypes: true });
57221
+ } catch (error) {
57222
+ if (error.code === "ENOENT") return;
57223
+ throw error;
57224
+ }
57225
+ for (const entry of entries) {
57226
+ const child = `${path}/${entry.name}`;
57227
+ if (entry.isSymbolicLink()) throw new Error(`Symlinks are not allowed: ${child}`);
57228
+ if (entry.name.startsWith(".")) continue;
57229
+ if (entry.isDirectory()) {
57230
+ if (path.startsWith("videos/") && ["assets", "renders"].includes(entry.name)) continue;
57231
+ await walk(child);
57232
+ } else if (entry.isFile() && /\.(json|md)$/.test(entry.name)) {
57233
+ safeDocumentPath(child);
57234
+ files[child] = await readText(root, child);
57235
+ }
57236
+ }
57237
+ }
57238
+ await walk(".editorharness");
57239
+ await walk(".editorbrain");
57240
+ await walk("videos");
57241
+ return canonicalFiles(files);
57242
+ }
57243
+ async function readProjectWorkspace(root, projectId) {
57244
+ assertIdentifier(projectId);
57245
+ const projectPath = `videos/${projectId}/project.json`;
57246
+ const projectContent = await readText(root, projectPath);
57247
+ const checked = validateDocument(projectPath, projectContent);
57248
+ if (!checked.valid) throw new ValidationError(checked.issues);
57249
+ const project = JSON.parse(projectContent);
57250
+ const files = { [projectPath]: projectContent };
57251
+ for (const path of /* @__PURE__ */ new Set([...projectSources(project), ...optionalProjectSources(project)])) {
57252
+ if (path === projectPath) continue;
57253
+ const aliases2 = path.startsWith(".editorbrain/") ? [path, path.replace(/^\.editorbrain\//, ".editorharness/")] : [path];
57254
+ for (const alias of aliases2) {
57255
+ try {
57256
+ files[alias] = await readText(root, alias);
57257
+ } catch (error) {
57258
+ if (error.code !== "ENOENT") throw error;
57259
+ }
57260
+ }
57261
+ }
57262
+ if (await readText(root, projectPath) !== projectContent) throw new Error("The selected project changed while reading its context. Retry before editing.");
57263
+ return canonicalFiles(files);
57264
+ }
57265
+
57266
+ // packages/cli/src/production-output.ts
57267
+ async function productionOutputPath(root, relativePath) {
57268
+ if (!relativePath.startsWith(".editorbrain-local/") || relativePath.split(/[\\/]/).some((p) => !p || p === ".." || p === ".") || /[\\\x00-\x1f\x7f]/.test(relativePath)) {
57269
+ throw new CliError("LOCAL_OUTPUT_REQUIRED", "Production output must be inside .editorbrain-local/.", "Use --out .editorbrain-local/<project>/inventory.json, or omit --out for JSON stdout. These reports are not synced.");
57270
+ }
57271
+ return checkedPath(root, relativePath, false);
57272
+ }
57273
+ async function prepareSampleDirectory(root, path) {
57274
+ const target = await productionOutputPath(root, path);
57275
+ await mkdir2(dirname2(target), { recursive: true });
57276
+ await productionOutputPath(root, path);
57277
+ return target;
57278
+ }
57279
+ async function readProductionJson(root, path) {
57280
+ const target = await checkedPath(root, path, false);
57281
+ const handle = await open2(target, constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK);
57282
+ try {
57283
+ const stat = await handle.stat();
57284
+ if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new CliError("INVALID_REPORT", "Expected a regular JSON file of at most 16 MiB.", "Select a local inventory, plan or check profile.");
57285
+ return JSON.parse(await handle.readFile("utf8"));
57286
+ } finally {
57287
+ await handle.close();
57288
+ }
57289
+ }
57290
+ async function saveProductionReport(root, path, report) {
57291
+ const target = await productionOutputPath(root, path);
57292
+ if (!path.endsWith(".json")) throw new CliError("INVALID_ARGUMENT", "Report output needs a .json extension.", "Use a new path under .editorbrain-local/.");
57293
+ await atomicWrite(root, path, stableJson(report), { document: false, overwrite: false, mode: 384 });
57294
+ return target;
57295
+ }
57296
+ function summarizeProductionReport(value) {
57297
+ const r = value;
57298
+ const base = { schemaVersion: r.schemaVersion, kind: r.kind, summary: true, mediaUploaded: false, quality: "not_assessed" };
57299
+ if (r.kind === "editorbrain.media-inventory") return {
57300
+ ...base,
57301
+ complete: r.complete,
57302
+ contentReviewed: false,
57303
+ counts: { assets: r.assets.length, failures: r.failures.length, skipped: r.skipped.length },
57304
+ assets: r.assets.slice(0, 8),
57305
+ failures: r.failures.slice(0, 5),
57306
+ omitted: { assets: Math.max(0, r.assets.length - 8), failures: Math.max(0, r.failures.length - 5), skipped: r.skipped.length },
57307
+ next: "Inspect selected source content. Use media sample for specific frames. Read the saved report or rerun with --details for omitted metadata; no content was reviewed automatically."
57308
+ };
57309
+ if (r.kind === "editorbrain.media-samples") return { ...base, sourceSha256: r.source.sha256, frames: r.frames, contentReviewed: false, next: "View these frames to inspect composition. They do not prove motion or sound." };
57310
+ if (r.kind === "editorbrain.production-plan-check") return {
57311
+ ...base,
57312
+ valid: r.valid,
57313
+ inventoryComplete: r.inventoryComplete,
57314
+ observations: r.observations,
57315
+ sourceFilesReverified: false,
57316
+ counts: { sources: r.coverage.length, findings: r.findings.length, repeatedPairs: r.repeatedPairs ?? 0 },
57317
+ findings: [...r.findings].sort((a, b) => ({ error: 0, warning: 1, info: 2 })[a.severity] - { error: 0, warning: 1, info: 2 }[b.severity]).slice(0, 8),
57318
+ omittedFindings: Math.max(0, r.findings.length - 8),
57319
+ next: "Review source reuse and inspection gaps in the saved report. Observations are agent-declared, not independently certified."
57320
+ };
57321
+ if (r.kind === "editorbrain.render-check") return {
57322
+ ...base,
57323
+ complete: r.complete,
57324
+ technicalStatus: r.technicalStatus,
57325
+ file: r.file,
57326
+ checks: r.checks,
57327
+ findings: r.findings.slice(0, 8),
57328
+ findingsSummary: r.findingsSummary,
57329
+ omittedFindings: Math.max(0, r.findings.length - 8),
57330
+ next: "Reconcile technical candidates with the brief; inspect the actual video/audio. Technical checks do not assess narrative, aesthetics or SFX audibility."
57331
+ };
57332
+ if (r.kind === "editorbrain.media-source-search") return {
57333
+ ...base,
57334
+ complete: r.complete,
57335
+ results: r.results.slice(0, 5).map((item) => ({ provider: item.provider, id: item.id.slice(0, 180), title: item.title.slice(0, 180), sourceUrl: item.sourceUrl.length <= 1500 ? item.sourceUrl : null, author: item.author?.slice(0, 120) ?? null, license: { id: item.license.id, reason: item.license.reason, url: item.license.url && item.license.url.length <= 500 ? item.license.url : null }, contentReviewed: false, rightsVerified: false })),
57336
+ metadataAbbreviated: true,
57337
+ failures: r.failures.slice(0, 5),
57338
+ counts: r.counts,
57339
+ omitted: { results: Math.max(0, r.results.length - 5), failures: Math.max(0, r.failures.length - 5) },
57340
+ searchExhaustive: false,
57341
+ rightsVerified: false,
57342
+ next: "Inspect relevant source pages and media. These archives supplement web image/video search; empty results do not prove absence on the internet."
57343
+ };
57344
+ if (r.kind === "editorbrain.evidence-check") return {
57345
+ ...base,
57346
+ valid: r.valid,
57347
+ inventoryComplete: r.inventoryComplete,
57348
+ observations: r.observations,
57349
+ sourceFilesReverified: false,
57350
+ factualTruth: "not_assessed",
57351
+ reuseRights: "not_assessed",
57352
+ findings: r.findings.slice(0, 8),
57353
+ omitted: { findings: Math.max(0, r.findings.length - 8) }
57354
+ };
57355
+ if (r.kind === "editorbrain.audio-check") return {
57356
+ ...base,
57357
+ complete: r.complete,
57358
+ technicalStatus: r.technicalStatus,
57359
+ file: r.file,
57360
+ fileStable: r.fileStable,
57361
+ coverage: r.coverage,
57362
+ measurements: r.measurements.slice(0, 8),
57363
+ errorCode: r.errorCode,
57364
+ creativeQuality: r.creativeQuality,
57365
+ omitted: { measurements: Math.max(0, r.measurements.length - 8) },
57366
+ next: "Measurements describe this exact file. Listen for intelligibility, pronunciation, SFX and sync when the modality is available."
57367
+ };
57368
+ if (r.kind === "editorbrain.motion-review") return {
57369
+ ...base,
57370
+ samples: r.samples ? { frameCount: r.samples.frameCount, fps: r.samples.fps, radiusFrames: r.samples.radiusFrames, samples: r.samples.samples.slice(0, 12), omittedSamples: Math.max(0, r.samples.samples.length - 12) } : null,
57371
+ geometryReviews: r.geometryReviews.slice(0, 3).map((g) => ({ scope: g.scope, status: g.status, candidates: g.candidates.slice(0, 8), omittedCandidates: Math.max(0, g.candidates.length - 8), checks: g.checks })),
57372
+ omittedGeometryReviews: Math.max(0, r.geometryReviews.length - 3),
57373
+ next: "This plans samples and checks declared boxes only. Render/view those frames; read saved details for omitted candidates and intentional overlaps."
57374
+ };
57375
+ throw new CliError("INVALID_REPORT", "Unknown production report kind.", "Use media inventory/sample or production check/plan-check.");
57376
+ }
57377
+
57378
+ // packages/cli/src/editing-batches.ts
57379
+ async function batchCommand(client, root, command, argument, flags) {
57380
+ const fail2 = () => {
57381
+ throw new CliError(
57382
+ "INVALID_ARGUMENT",
57383
+ "Use batches list/get/create/pause/resume/cancel/retry/detach/claim/renew/release/attention.",
57384
+ "Create with --file batch.json; updates need --expected-revision; leases need --project and --lease-token UUID."
57385
+ );
57386
+ };
57387
+ const lease = ["claim", "renew", "release", "attention"].includes(command), update = ["pause", "resume", "cancel", "retry", "detach"].includes(command);
57388
+ if (!["list", "get", "create"].includes(command) && !lease && !update) fail2();
57389
+ const allowed = [
57390
+ "dir",
57391
+ "url",
57392
+ ...command === "create" ? ["file"] : command === "list" ? ["after"] : lease ? ["project", "lease-token"] : update ? [
57393
+ "expected-revision",
57394
+ ...["retry", "detach"].includes(command) ? ["project"] : []
57395
+ ] : []
57396
+ ];
57397
+ for (const key of Object.keys(flags)) if (!allowed.includes(key)) fail2();
57398
+ if (command === "list") {
57399
+ if (argument) fail2();
57400
+ return client.request(
57401
+ "/v1/batches" + (typeof flags.after === "string" ? "?after=" + encodeURIComponent(flags.after) : "")
57402
+ );
57403
+ }
57404
+ if (command === "create") {
57405
+ if (argument || typeof flags.file !== "string") fail2();
57406
+ const body = await readProductionJson(root, flags.file);
57407
+ return client.request("/v1/batches", { method: "POST", body });
57408
+ }
57409
+ if (!argument || !/^[a-f0-9-]{36}$/i.test(argument)) fail2();
57410
+ const path = "/v1/batches/" + argument;
57411
+ if (command === "get") return client.request(path);
57412
+ if (update) {
57413
+ const revision = Number(flags["expected-revision"]);
57414
+ if (!Number.isSafeInteger(revision) || revision < 1 || ["retry", "detach"].includes(command) && typeof flags.project !== "string")
57415
+ fail2();
57416
+ return client.request(path, {
57417
+ method: "POST",
57418
+ body: {
57419
+ action: command,
57420
+ expectedRevision: revision,
57421
+ ...["retry", "detach"].includes(command) ? { projectId: flags.project } : {}
57422
+ }
57423
+ });
57424
+ }
57425
+ if (typeof flags.project !== "string" || !/^[a-z0-9][a-z0-9_-]{0,79}$/.test(flags.project) || typeof flags["lease-token"] !== "string" || !/^[a-f0-9-]{36}$/i.test(flags["lease-token"]))
57426
+ fail2();
57427
+ return client.request(path + "/lease", {
57428
+ method: "POST",
57429
+ body: {
57430
+ action: command,
57431
+ projectId: flags.project,
57432
+ leaseToken: flags["lease-token"]
57433
+ }
57434
+ });
57435
+ }
57436
+
57437
+ // packages/cli/src/project-progress-client.ts
57438
+ async function projectProgressCommand(client, command, flags) {
57439
+ const fail2 = (message) => {
57440
+ throw new CliError(
57441
+ "INVALID_ARGUMENT",
57442
+ message,
57443
+ "Use projects progress/start/queue/complete --project ID. Read progress first; pass --expected-version HASH for changes."
57444
+ );
57445
+ };
57446
+ if (!["progress", "start", "queue", "complete"].includes(command))
57447
+ fail2("Unknown project command.");
57448
+ const allowed = [
57449
+ "project",
57450
+ "url",
57451
+ "dir",
57452
+ ...command === "progress" ? [] : ["expected-version"],
57453
+ ...command === "complete" ? ["revision", "user-confirmed"] : []
57454
+ ];
57455
+ for (const name of Object.keys(flags))
57456
+ if (!allowed.includes(name)) fail2(`Unsupported flag --${name}.`);
57457
+ if (typeof flags.project !== "string") fail2("--project is required.");
57458
+ const project = assertIdentifier(flags.project), path = `/v1/projects/${encodeURIComponent(project)}/progress`;
57459
+ if (command === "progress") return client.request(path);
57460
+ if (typeof flags["expected-version"] !== "string" || !/^[a-f0-9]{64}$/.test(flags["expected-version"]))
57461
+ fail2("--expected-version must be the current progress version.");
57462
+ if (command === "complete" && (typeof flags.revision !== "string" || !/^[a-f0-9-]{36}$/.test(flags.revision) || flags["user-confirmed"] !== true))
57463
+ fail2(
57464
+ "Completion requires --revision UUID and --user-confirmed after explicit final-video approval."
57465
+ );
57466
+ return client.request(path, {
57467
+ method: "POST",
57468
+ body: {
57469
+ action: command,
57470
+ expectedVersion: flags["expected-version"],
57471
+ ...command === "complete" ? { revisionId: flags.revision, userConfirmed: true } : {}
57472
+ }
57473
+ });
57474
+ }
57475
+
57476
+ // packages/cli/src/sfx.ts
57477
+ import { readdir as readdir2, lstat as lstat3 } from "node:fs/promises";
57478
+ import { join as join2, extname as extname2, basename } from "node:path";
57479
+
57157
57480
  // packages/cli/src/media-runtime.ts
57481
+ import { spawn } from "node:child_process";
57482
+ import { createHash } from "node:crypto";
57483
+ import { constants as constants3 } from "node:fs";
57484
+ import { lstat as lstat2, open as open3, realpath as realpath2 } from "node:fs/promises";
57485
+ import { dirname as dirname3, extname, parse, resolve as resolve2 } from "node:path";
57158
57486
  var MediaToolError = class extends CliError {
57159
57487
  constructor(code, message) {
57160
57488
  super(code, message, code === "MEDIA_TOOL_NOT_FOUND" ? "Install FFmpeg/FFprobe separately and make them available on PATH; no account or model is needed." : "Review the local input and reported limit. An incomplete check does not validate the media.");
@@ -57206,24 +57534,24 @@ function resolveLocalMediaPath(value) {
57206
57534
  if (!value || value.includes("\0") || /[\r\n]/.test(value) || /^[a-z][a-z\d+.-]*:/i.test(value) && !(process.platform === "win32" && /^[a-z]:[\\/]/i.test(value)) || value.startsWith("\\\\")) {
57207
57535
  throw new MediaToolError("MEDIA_LOCAL_PATH_REQUIRED", "Provide a local filesystem path, not a URL, protocol or network share.");
57208
57536
  }
57209
- return resolve(value);
57537
+ return resolve2(value);
57210
57538
  }
57211
57539
  async function assertNoMediaSymlinks(value) {
57212
57540
  const path = resolveLocalMediaPath(value);
57213
57541
  let cursor = path;
57214
57542
  while (cursor !== parse(cursor).root) {
57215
- const info = await lstat(cursor);
57543
+ const info = await lstat2(cursor);
57216
57544
  if (info.isSymbolicLink()) {
57217
- const systemAlias = process.platform === "darwin" && (cursor === "/tmp" || cursor === "/var") && await realpath(cursor) === `/private${cursor}`;
57545
+ const systemAlias = process.platform === "darwin" && (cursor === "/tmp" || cursor === "/var") && await realpath2(cursor) === `/private${cursor}`;
57218
57546
  if (!systemAlias) throw new MediaToolError("MEDIA_SYMLINK", "Symlink media paths are not followed. Use the original local path.");
57219
57547
  }
57220
- cursor = dirname(cursor);
57548
+ cursor = dirname3(cursor);
57221
57549
  }
57222
57550
  return path;
57223
57551
  }
57224
57552
  async function assertLocalMediaFile(value) {
57225
57553
  const path = await assertNoMediaSymlinks(value);
57226
- const info = await lstat(path);
57554
+ const info = await lstat2(path);
57227
57555
  if (!info.isFile()) throw new MediaToolError("MEDIA_NOT_REGULAR", "The media input must be a regular local file.");
57228
57556
  if (!SUPPORTED_MEDIA_EXTENSIONS.has(extname(path).toLowerCase())) throw new MediaToolError("MEDIA_FORMAT_UNSUPPORTED", "This file extension is not supported for local media inspection.");
57229
57557
  return path;
@@ -57308,7 +57636,7 @@ var rate2 = (value) => {
57308
57636
  var sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
57309
57637
  async function probeLocalMedia(value) {
57310
57638
  const path = await assertLocalMediaFile(value);
57311
- const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
57639
+ const handle = await open3(path, constants3.O_RDONLY | (constants3.O_NOFOLLOW ?? 0));
57312
57640
  try {
57313
57641
  const before = await handle.stat();
57314
57642
  if (!before.isFile()) throw new MediaToolError("MEDIA_NOT_REGULAR", "The media input must be a regular local file.");
@@ -57318,7 +57646,7 @@ async function probeLocalMedia(value) {
57318
57646
  for await (const chunk of handle.createReadStream({ autoClose: false })) hash6.update(chunk);
57319
57647
  const after = await handle.stat();
57320
57648
  await assertNoMediaSymlinks(path);
57321
- const current2 = await lstat(path);
57649
+ const current2 = await lstat2(path);
57322
57650
  if (!sameFile(before, after) || !sameFile(after, current2)) throw new MediaToolError("MEDIA_FILE_CHANGED", "The media file changed while it was being inspected. Retry with a stable source.");
57323
57651
  let raw;
57324
57652
  try {
@@ -57346,121 +57674,6 @@ async function probeLocalMedia(value) {
57346
57674
  }
57347
57675
  }
57348
57676
 
57349
- // packages/cli/src/io.ts
57350
- import { constants as constants2 } from "node:fs";
57351
- import { lstat as lstat2, mkdir, open as open2, readdir, realpath as realpath2, rename, unlink, link } from "node:fs/promises";
57352
- import { dirname as dirname2, join, relative, resolve as resolve2, sep } from "node:path";
57353
- import { randomUUID } from "node:crypto";
57354
- async function ensureRoot(directory, create = false) {
57355
- const root = resolve2(directory);
57356
- if (create) await mkdir(root, { recursive: true });
57357
- const info = await lstat2(root);
57358
- if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("Workspace root must be a real directory, not a symlink");
57359
- return realpath2(root);
57360
- }
57361
- async function checkedPath(root, path, document2 = true) {
57362
- if (document2) safeDocumentPath(path);
57363
- const target = resolve2(root, path);
57364
- const rel = relative(root, target);
57365
- if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || resolve2(path) === path) throw new Error("Path escapes workspace");
57366
- let cursor = root;
57367
- for (const segment of rel.split(sep)) {
57368
- cursor = join(cursor, segment);
57369
- try {
57370
- const info = await lstat2(cursor);
57371
- if (info.isSymbolicLink()) throw new Error(`Symlinks are not allowed: ${path}`);
57372
- } catch (error) {
57373
- if (error.code !== "ENOENT") throw error;
57374
- }
57375
- }
57376
- return target;
57377
- }
57378
- async function readText(root, path, document2 = true) {
57379
- const target = await checkedPath(root, path, document2);
57380
- const handle = await open2(target, constants2.O_RDONLY | constants2.O_NOFOLLOW);
57381
- try {
57382
- const info = await handle.stat();
57383
- if (!info.isFile()) throw new Error(`Not a regular file: ${path}`);
57384
- if (document2 && info.size > MAX_DOCUMENT_BYTES) throw new Error(`Document exceeds 128 KiB: ${path}`);
57385
- return new TextDecoder("utf-8", { fatal: true }).decode(await handle.readFile());
57386
- } finally {
57387
- await handle.close();
57388
- }
57389
- }
57390
- async function atomicWrite(root, path, content, options = {}) {
57391
- const target = await checkedPath(root, path, options.document ?? true);
57392
- await mkdir(dirname2(target), { recursive: true });
57393
- await checkedPath(root, path, options.document ?? true);
57394
- const temporary = join(dirname2(target), `.eh-${randomUUID()}.tmp`);
57395
- const handle = await open2(temporary, "wx", options.mode ?? 420);
57396
- try {
57397
- await handle.writeFile(content, "utf8");
57398
- await handle.close();
57399
- await checkedPath(root, path, options.document ?? true);
57400
- if (options.overwrite === false) {
57401
- await link(temporary, target);
57402
- await unlink(temporary);
57403
- } else await rename(temporary, target);
57404
- } catch (error) {
57405
- await handle.close().catch(() => {
57406
- });
57407
- await unlink(temporary).catch(() => {
57408
- });
57409
- throw error;
57410
- }
57411
- }
57412
- async function readWorkspace(root) {
57413
- const files = /* @__PURE__ */ Object.create(null);
57414
- async function walk(path) {
57415
- const directory = await checkedPath(root, path, false);
57416
- let entries;
57417
- try {
57418
- entries = await readdir(directory, { withFileTypes: true });
57419
- } catch (error) {
57420
- if (error.code === "ENOENT") return;
57421
- throw error;
57422
- }
57423
- for (const entry of entries) {
57424
- const child = `${path}/${entry.name}`;
57425
- if (entry.isSymbolicLink()) throw new Error(`Symlinks are not allowed: ${child}`);
57426
- if (entry.name.startsWith(".")) continue;
57427
- if (entry.isDirectory()) {
57428
- if (path.startsWith("videos/") && ["assets", "renders"].includes(entry.name)) continue;
57429
- await walk(child);
57430
- } else if (entry.isFile() && /\.(json|md)$/.test(entry.name)) {
57431
- safeDocumentPath(child);
57432
- files[child] = await readText(root, child);
57433
- }
57434
- }
57435
- }
57436
- await walk(".editorharness");
57437
- await walk(".editorbrain");
57438
- await walk("videos");
57439
- return canonicalFiles(files);
57440
- }
57441
- async function readProjectWorkspace(root, projectId) {
57442
- assertIdentifier(projectId);
57443
- const projectPath = `videos/${projectId}/project.json`;
57444
- const projectContent = await readText(root, projectPath);
57445
- const checked = validateDocument(projectPath, projectContent);
57446
- if (!checked.valid) throw new ValidationError(checked.issues);
57447
- const project = JSON.parse(projectContent);
57448
- const files = { [projectPath]: projectContent };
57449
- for (const path of /* @__PURE__ */ new Set([...projectSources(project), ...optionalProjectSources(project)])) {
57450
- if (path === projectPath) continue;
57451
- const aliases2 = path.startsWith(".editorbrain/") ? [path, path.replace(/^\.editorbrain\//, ".editorharness/")] : [path];
57452
- for (const alias of aliases2) {
57453
- try {
57454
- files[alias] = await readText(root, alias);
57455
- } catch (error) {
57456
- if (error.code !== "ENOENT") throw error;
57457
- }
57458
- }
57459
- }
57460
- if (await readText(root, projectPath) !== projectContent) throw new Error("The selected project changed while reading its context. Retry before editing.");
57461
- return canonicalFiles(files);
57462
- }
57463
-
57464
57677
  // packages/cli/src/production-context.ts
57465
57678
  async function prepareProductionContext(root, projectId, client) {
57466
57679
  const resolved = await resolveProject(await readProjectWorkspace(root, projectId), projectId);
@@ -57561,123 +57774,6 @@ async function remoteSfx(client, subcommand, params) {
57561
57774
  // packages/cli/src/media-preview.ts
57562
57775
  import { chmod, link as link2, mkdtemp, rm } from "node:fs/promises";
57563
57776
  import { dirname as dirname4, join as join3 } from "node:path";
57564
-
57565
- // packages/cli/src/production-output.ts
57566
- import { constants as constants3 } from "node:fs";
57567
- import { mkdir as mkdir2, open as open3 } from "node:fs/promises";
57568
- import { dirname as dirname3 } from "node:path";
57569
- async function productionOutputPath(root, relativePath) {
57570
- if (!relativePath.startsWith(".editorbrain-local/") || relativePath.split(/[\\/]/).some((p) => !p || p === ".." || p === ".") || /[\\\x00-\x1f\x7f]/.test(relativePath)) {
57571
- throw new CliError("LOCAL_OUTPUT_REQUIRED", "Production output must be inside .editorbrain-local/.", "Use --out .editorbrain-local/<project>/inventory.json, or omit --out for JSON stdout. These reports are not synced.");
57572
- }
57573
- return checkedPath(root, relativePath, false);
57574
- }
57575
- async function prepareSampleDirectory(root, path) {
57576
- const target = await productionOutputPath(root, path);
57577
- await mkdir2(dirname3(target), { recursive: true });
57578
- await productionOutputPath(root, path);
57579
- return target;
57580
- }
57581
- async function readProductionJson(root, path) {
57582
- const target = await checkedPath(root, path, false);
57583
- const handle = await open3(target, constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK);
57584
- try {
57585
- const stat = await handle.stat();
57586
- if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new CliError("INVALID_REPORT", "Expected a regular JSON file of at most 16 MiB.", "Select a local inventory, plan or check profile.");
57587
- return JSON.parse(await handle.readFile("utf8"));
57588
- } finally {
57589
- await handle.close();
57590
- }
57591
- }
57592
- async function saveProductionReport(root, path, report) {
57593
- const target = await productionOutputPath(root, path);
57594
- if (!path.endsWith(".json")) throw new CliError("INVALID_ARGUMENT", "Report output needs a .json extension.", "Use a new path under .editorbrain-local/.");
57595
- await atomicWrite(root, path, stableJson(report), { document: false, overwrite: false, mode: 384 });
57596
- return target;
57597
- }
57598
- function summarizeProductionReport(value) {
57599
- const r = value;
57600
- const base = { schemaVersion: r.schemaVersion, kind: r.kind, summary: true, mediaUploaded: false, quality: "not_assessed" };
57601
- if (r.kind === "editorbrain.media-inventory") return {
57602
- ...base,
57603
- complete: r.complete,
57604
- contentReviewed: false,
57605
- counts: { assets: r.assets.length, failures: r.failures.length, skipped: r.skipped.length },
57606
- assets: r.assets.slice(0, 8),
57607
- failures: r.failures.slice(0, 5),
57608
- omitted: { assets: Math.max(0, r.assets.length - 8), failures: Math.max(0, r.failures.length - 5), skipped: r.skipped.length },
57609
- next: "Inspect selected source content. Use media sample for specific frames. Read the saved report or rerun with --details for omitted metadata; no content was reviewed automatically."
57610
- };
57611
- if (r.kind === "editorbrain.media-samples") return { ...base, sourceSha256: r.source.sha256, frames: r.frames, contentReviewed: false, next: "View these frames to inspect composition. They do not prove motion or sound." };
57612
- if (r.kind === "editorbrain.production-plan-check") return {
57613
- ...base,
57614
- valid: r.valid,
57615
- inventoryComplete: r.inventoryComplete,
57616
- observations: r.observations,
57617
- sourceFilesReverified: false,
57618
- counts: { sources: r.coverage.length, findings: r.findings.length, repeatedPairs: r.repeatedPairs ?? 0 },
57619
- findings: [...r.findings].sort((a, b) => ({ error: 0, warning: 1, info: 2 })[a.severity] - { error: 0, warning: 1, info: 2 }[b.severity]).slice(0, 8),
57620
- omittedFindings: Math.max(0, r.findings.length - 8),
57621
- next: "Review source reuse and inspection gaps in the saved report. Observations are agent-declared, not independently certified."
57622
- };
57623
- if (r.kind === "editorbrain.render-check") return {
57624
- ...base,
57625
- complete: r.complete,
57626
- technicalStatus: r.technicalStatus,
57627
- file: r.file,
57628
- checks: r.checks,
57629
- findings: r.findings.slice(0, 8),
57630
- findingsSummary: r.findingsSummary,
57631
- omittedFindings: Math.max(0, r.findings.length - 8),
57632
- next: "Reconcile technical candidates with the brief; inspect the actual video/audio. Technical checks do not assess narrative, aesthetics or SFX audibility."
57633
- };
57634
- if (r.kind === "editorbrain.media-source-search") return {
57635
- ...base,
57636
- complete: r.complete,
57637
- results: r.results.slice(0, 5).map((item) => ({ provider: item.provider, id: item.id.slice(0, 180), title: item.title.slice(0, 180), sourceUrl: item.sourceUrl.length <= 1500 ? item.sourceUrl : null, author: item.author?.slice(0, 120) ?? null, license: { id: item.license.id, reason: item.license.reason, url: item.license.url && item.license.url.length <= 500 ? item.license.url : null }, contentReviewed: false, rightsVerified: false })),
57638
- metadataAbbreviated: true,
57639
- failures: r.failures.slice(0, 5),
57640
- counts: r.counts,
57641
- omitted: { results: Math.max(0, r.results.length - 5), failures: Math.max(0, r.failures.length - 5) },
57642
- searchExhaustive: false,
57643
- rightsVerified: false,
57644
- next: "Inspect relevant source pages and media. These archives supplement web image/video search; empty results do not prove absence on the internet."
57645
- };
57646
- if (r.kind === "editorbrain.evidence-check") return {
57647
- ...base,
57648
- valid: r.valid,
57649
- inventoryComplete: r.inventoryComplete,
57650
- observations: r.observations,
57651
- sourceFilesReverified: false,
57652
- factualTruth: "not_assessed",
57653
- reuseRights: "not_assessed",
57654
- findings: r.findings.slice(0, 8),
57655
- omitted: { findings: Math.max(0, r.findings.length - 8) }
57656
- };
57657
- if (r.kind === "editorbrain.audio-check") return {
57658
- ...base,
57659
- complete: r.complete,
57660
- technicalStatus: r.technicalStatus,
57661
- file: r.file,
57662
- fileStable: r.fileStable,
57663
- coverage: r.coverage,
57664
- measurements: r.measurements.slice(0, 8),
57665
- errorCode: r.errorCode,
57666
- creativeQuality: r.creativeQuality,
57667
- omitted: { measurements: Math.max(0, r.measurements.length - 8) },
57668
- next: "Measurements describe this exact file. Listen for intelligibility, pronunciation, SFX and sync when the modality is available."
57669
- };
57670
- if (r.kind === "editorbrain.motion-review") return {
57671
- ...base,
57672
- samples: r.samples ? { frameCount: r.samples.frameCount, fps: r.samples.fps, radiusFrames: r.samples.radiusFrames, samples: r.samples.samples.slice(0, 12), omittedSamples: Math.max(0, r.samples.samples.length - 12) } : null,
57673
- geometryReviews: r.geometryReviews.slice(0, 3).map((g) => ({ scope: g.scope, status: g.status, candidates: g.candidates.slice(0, 8), omittedCandidates: Math.max(0, g.candidates.length - 8), checks: g.checks })),
57674
- omittedGeometryReviews: Math.max(0, r.geometryReviews.length - 3),
57675
- next: "This plans samples and checks declared boxes only. Render/view those frames; read saved details for omitted candidates and intentional overlaps."
57676
- };
57677
- throw new CliError("INVALID_REPORT", "Unknown production report kind.", "Use media inventory/sample or production check/plan-check.");
57678
- }
57679
-
57680
- // packages/cli/src/media-preview.ts
57681
57777
  async function prepareMediaPreview(root, file, out, quality = 720, audioStream) {
57682
57778
  if (![720, 1080].includes(quality) || !out.endsWith(".mp4")) throw new CliError("INVALID_ARGUMENT", "Use --quality 720 or 1080 and a new .mp4 output.", "Previews belong under .editorbrain-local/.");
57683
57779
  const source = await probeLocalMedia(await assertLocalMediaFile(file));
@@ -57736,6 +57832,16 @@ async function prepareStudioAudio(root, file, out, streamIndex) {
57736
57832
  // packages/cli/src/client.ts
57737
57833
  import { setTimeout as delay } from "node:timers/promises";
57738
57834
  var searchErrors = {
57835
+ batch_changed: { status: 409, code: "BATCH_CHANGED", hint: "Read batches get before retrying; preserve new changes." },
57836
+ batch_claim_blocked: { status: 409, code: "BATCH_CLAIM_BLOCKED", hint: "Read the batch. Wait for an available slot or reconcile an expired turn; do not start work without a claim." },
57837
+ batch_budget_exceeded: { status: 409, code: "BATCH_BUDGET_EXCEEDED", hint: "Stop generation. The batch or video budget was reached. Do not increase the budget yourself." },
57838
+ batch_not_running: { status: 409, code: "BATCH_NOT_RUNNING", hint: "The batch is paused/cancelled or the turn expired. Read the batch before continuing." },
57839
+ batch_retry_blocked: { status: 409, code: "BATCH_RETRY_BLOCKED", hint: "Inspect existing jobs and outputs before retrying. Do not duplicate a paid request." },
57840
+ batch_project_busy: { status: 409, code: "BATCH_PROJECT_BUSY", hint: "Wait for existing Studio jobs before grouping these projects." },
57841
+ batch_project_taken: { status: 409, code: "BATCH_PROJECT_TAKEN", hint: "Use the project existing batch or create a separate video project." },
57842
+ studio_context_or_lease_changed: { status: 409, code: "STUDIO_CONTEXT_OR_LEASE_CHANGED", hint: "Refresh the workflow and batch lease before generating." },
57843
+ progress_changed: { status: 409, code: "PROGRESS_CHANGED", hint: "Read projects progress --project ID again. Preserve the latest state; never force an old progress version." },
57844
+ final_not_approved: { status: 409, code: "FINAL_NOT_APPROVED", hint: "Review and approve the latest complete video, resolve pending changes, then record explicit final-delivery acceptance." },
57739
57845
  invalid_search_brief: { status: 400, code: "INVALID_SEARCH_BRIEF", hint: "Check references/search-brief.md and the service contract. Older services may reject eventQueries objects with channels. Do not silently remove constraints or retry a changed query as if filtering succeeded." },
57740
57846
  source_inspection_required: { status: 422, code: "SOURCE_INSPECTION_REQUIRED", hint: "Inspect the supplied source and declare only modalities actually reviewed. A script requires script inspection; video requires frames, motion or audio." },
57741
57847
  search_index_not_ready: { status: 503, code: "SEARCH_INDEX_NOT_READY", hint: "No current authorized evidence is indexed. Report the gap; literal library lookup is available but is not semantic retrieval or proof of fit." }
@@ -57835,7 +57941,7 @@ var Client = class {
57835
57941
  const retryAfterMs = retryDelay(response.headers.get("Retry-After"), this.runtime.now());
57836
57942
  if (!response.ok) {
57837
57943
  let errorBody;
57838
- if (path === "/v1/search/brief" && [400, 422, 503].includes(response.status)) {
57944
+ if ((path.startsWith("/v1/batches") || path === "/v1/studio/jobs") && response.status === 409 || path === "/v1/search/brief" && [400, 422, 503].includes(response.status) || /^\/v1\/projects\/[^/]+\/progress$/.test(path) && response.status === 409) {
57839
57945
  try {
57840
57946
  const diagnostic = searchDiagnostic(response.status, await boundedJson(response, 16 * 1024));
57841
57947
  errorBody = diagnostic ? { error: { code: diagnostic.serviceCode } } : void 0;
@@ -58017,7 +58123,7 @@ ${conflicts.join("\n")}`, "Run pull --dry-run and reconcile the reported paths."
58017
58123
 
58018
58124
  // packages/cli/src/studio.ts
58019
58125
  async function studioCommand(client, root, command, argument, flags) {
58020
- const allowed = /* @__PURE__ */ new Set(["dir", "url", ...command === "quote" ? ["file"] : command === "run" ? ["max-credits", "user-confirmed"] : []]);
58126
+ const allowed = /* @__PURE__ */ new Set(["dir", "url", ...command === "quote" ? ["file"] : command === "run" ? ["max-credits", "user-confirmed", "batch-lease-token"] : []]);
58021
58127
  for (const flag of Object.keys(flags)) if (!allowed.has(flag)) throw new CliError("INVALID_ARGUMENT", `--${flag} is not supported for studio ${command}.`, "Read editorbrain help.");
58022
58128
  try {
58023
58129
  if (command === "catalog" || command === "jobs") {
@@ -58035,7 +58141,7 @@ async function studioCommand(client, root, command, argument, flags) {
58035
58141
  if (command === "run") {
58036
58142
  const maxCredits = Number(flags["max-credits"]);
58037
58143
  if (flags["user-confirmed"] !== true || !Number.isFinite(maxCredits) || maxCredits <= 0 || maxCredits > 1e4) throw new CliError("SPENDING_APPROVAL_REQUIRED", "Supply --user-confirmed and --max-credits only after the user authorized this quote or budget.", "Preferences and direct editing mode do not authorize spending.");
58038
- return await client.request("/v1/studio/jobs", { method: "POST", body: { quoteId: argument, maxCredits, userConfirmed: true } });
58144
+ return await client.request("/v1/studio/jobs", { method: "POST", body: { quoteId: argument, maxCredits, userConfirmed: true, ...flags["batch-lease-token"] ? { batchLeaseToken: flags["batch-lease-token"] } : {} } });
58039
58145
  }
58040
58146
  throw new CliError("INVALID_ARGUMENT", "Use studio catalog, quote, run, get or jobs.", "Read editorbrain help.");
58041
58147
  } catch (error) {
@@ -58381,6 +58487,41 @@ var VERIFIED_GUIDANCE_RELEASES = [
58381
58487
  "editorbrain/references/style-creator.md": "1b1bc289a5bc308cbbf7f4d699de2933b8934733a2b69b3e98fb1b7420d986f2",
58382
58488
  "editorbrain/references/workspace-files.md": "18462e7b66ba6204770c654e4a2f7e505c343319671a0447aa55c59f0b9096ec"
58383
58489
  }
58490
+ },
58491
+ {
58492
+ "version": "0.32.0",
58493
+ "archiveSha256": "30c6c6d97da7de16bb620c0975c2d3f2a4b7a6ea4a56f2eb3c5b0f5f53914f45",
58494
+ "files": {
58495
+ "editorbrain-formats/SKILL.md": "b8b03266a9b7cde8337846144d3032b63b6d9cfbfc4530ae1b06f3dd43074ff2",
58496
+ "editorbrain-formats/references/numbered-tutorial.md": "95754ae7dbfaa678e11ddf1a2b84710dba83a16e2fa4055051bd4f5f4f868a5f",
58497
+ "editorbrain-motion/SKILL.md": "86617d39341d7325d613b877838258f40f05b4286301dacf8e1ec85ccd502aab",
58498
+ "editorbrain-motion/references/authored-scenes.md": "11109dcd35ab7138fdc18e54605885bb26d9e948f12221e18dea4038987dede2",
58499
+ "editorbrain-motion/references/code-motion.md": "50382a797adc1e1596086e2408e81ee75ceb6730ca409be9c884646db643a7cd",
58500
+ "editorbrain-motion/references/component-library.md": "e4593622ebe371bba1b1c62f698c6709c36a8d01ddce4a07d40534fca44b797e",
58501
+ "editorbrain-motion/references/generated-motion.md": "a8d214b53c15f742658abd84f03b768fa2821f8cde94f50aab82a3759a30b36b",
58502
+ "editorbrain-motion/references/motion-craft.md": "adba1b0cbb3c6327a7665857b0a6d144f97dc843464ccf154d3f76ee3ada905d",
58503
+ "editorbrain/AGENTS.md": "2361aaa3202d3f4ea9e941684fbda1a84d9711389cca89f7f6cd3ae03b7ab6d0",
58504
+ "editorbrain/SKILL.md": "b15eb4e2438c0a3e6ec5c3c3170f11c973676d5c6a850cf5d2e9cb3bc7c44e42",
58505
+ "editorbrain/references/audio-production.md": "038504d738db98939aeed14407b15561e4f82c650aace7eb545e77cdffd8dadb",
58506
+ "editorbrain/references/cli-mcp.md": "9febfcac6c1dc763b79ab26ffb07e53bce703cbe4c2332ddcd250e10f2fa75dd",
58507
+ "editorbrain/references/editorial-evidence.md": "caf72365b3e7492a62d96b39cd17031fa7c961671ce32bc4649420d8a353a026",
58508
+ "editorbrain/references/first-edit.md": "d71271ab32c6432cce01316917b4504f15411ea59511566a1f6169ac6d09723c",
58509
+ "editorbrain/references/integrations.md": "a3500dd79c71ab3ee80a865bb5bf8c94287efb933e07d577738891e276a819a0",
58510
+ "editorbrain/references/local-production.md": "2d170bb217854eda7581f64d8342fffe3b56234bbe56b2715fa93eb3210e6354",
58511
+ "editorbrain/references/media-preferences.md": "d1b92b181fbc226c8d3fda762726dd413944175768651fbfb8200eae44fb2ec8",
58512
+ "editorbrain/references/memory-and-review.md": "2a89d6fc4737b750a380c69630d6fd4f4132aed27656b92e8f0c5748887b70ae",
58513
+ "editorbrain/references/motion-review.md": "0fb09a2d61ecd13114ced5cca5daa4580a922cf7d7b3f8698077ceb0e96da6f0",
58514
+ "editorbrain/references/onboarding.md": "875d1b9049c4663780f993c2843528c531a51d6b51380b08ed77d82f618185fb",
58515
+ "editorbrain/references/production-and-review.md": "50f10a98a4f3709a5a585f58e4154f33d6d883b218cd25f3abd72eccc43b15a7",
58516
+ "editorbrain/references/production-continuity.md": "c4b346c9634f3d1ef63b24a97d178e0253c2e45d53f6934076a5417e66e34d9b",
58517
+ "editorbrain/references/real-media.md": "106bee5a34641d53f021ead5d41630d473c2bfc98e4f7ee5500dae837e2e45bb",
58518
+ "editorbrain/references/search-brief.md": "ea2a9c2d5a5cf685c1e5918366901ef2589c4d79aaa78a0dd31e97d6fed42b7b",
58519
+ "editorbrain/references/sfx-library.md": "fa0b02c8765988257e46e16298ecca9cdb0e338a863915e29f831162e237eac4",
58520
+ "editorbrain/references/storage-and-previews.md": "8a3057f1c1a2a1e1cd32028059dc897a8461da38c36e812fbd24943d3740b4d5",
58521
+ "editorbrain/references/studio.md": "7a84e43bd72f8590d1a91c7d1bc5f6b60678c1c8eba6523b51b99243e22a1322",
58522
+ "editorbrain/references/style-creator.md": "1b1bc289a5bc308cbbf7f4d699de2933b8934733a2b69b3e98fb1b7420d986f2",
58523
+ "editorbrain/references/workspace-files.md": "c16fe08b209c65baf8f57054f7cc01f514292fc073b7072298b86c0054da3e19"
58524
+ }
58384
58525
  }
58385
58526
  ];
58386
58527
  function isVerifiedHistoricalGuidance(skillName, relativePath, sha2562) {
@@ -63531,7 +63672,16 @@ editorbrain register --url https://server --name workspace --invite-stdin
63531
63672
  editorbrain status
63532
63673
  editorbrain pull [--project id | --all] [--dir folder] [--dry-run]
63533
63674
  editorbrain push [--dir folder] [--dry-run]
63675
+ editorbrain batches list [--after UUID]
63676
+ editorbrain batches create --file batch.json
63677
+ editorbrain batches get BATCH_ID
63678
+ editorbrain batches pause|resume|cancel BATCH_ID --expected-revision N
63679
+ editorbrain batches retry|detach BATCH_ID --project ID --expected-revision N
63680
+ editorbrain batches claim|renew|release|attention BATCH_ID --project ID --lease-token UUID
63534
63681
  editorbrain projects list
63682
+ editorbrain projects progress --project ID
63683
+ editorbrain projects start|queue --project ID --expected-version HASH
63684
+ editorbrain projects complete --project ID --expected-version HASH --revision UUID --user-confirmed
63535
63685
  editorbrain documents list [--prefix path]
63536
63686
  editorbrain documents get <path> [--revision N]
63537
63687
  editorbrain documents history <path> [--before N]
@@ -63550,7 +63700,7 @@ editorbrain studio prepare --file source.mp4 --out .editorbrain-local/studio/aud
63550
63700
  editorbrain studio catalog
63551
63701
  editorbrain billing status
63552
63702
  editorbrain studio quote --file request.json [--dir folder]
63553
- editorbrain studio run QUOTE_ID --max-credits N --user-confirmed
63703
+ editorbrain studio run QUOTE_ID --max-credits N --user-confirmed [--batch-lease-token UUID]
63554
63704
  editorbrain studio get JOB_ID
63555
63705
  editorbrain studio jobs
63556
63706
  editorbrain integrations catalog
@@ -63636,6 +63786,9 @@ function parseArgs(argv) {
63636
63786
  known.add("preview-kind");
63637
63787
  known.add("preset");
63638
63788
  known.add("context-version");
63789
+ known.add("expected-version");
63790
+ known.add("lease-token");
63791
+ known.add("batch-lease-token");
63639
63792
  for (let i = 0; i < argv.length; i++) {
63640
63793
  const arg = argv[i];
63641
63794
  if (!arg.startsWith("--")) {
@@ -64221,6 +64374,14 @@ async function main() {
64221
64374
  }
64222
64375
  throw new Error("Use keys list, keys create --label name, or keys revoke <key-id>");
64223
64376
  }
64377
+ if (command === "batches") {
64378
+ output(await batchCommand(new Client(await config(args)), await ensureRoot(str(args, "dir") ?? process.cwd()), subcommand, argument, args.flags));
64379
+ return;
64380
+ }
64381
+ if (command === "projects" && ["progress", "start", "queue", "complete"].includes(subcommand)) {
64382
+ output(await projectProgressCommand(new Client(await config(args)), subcommand, args.flags));
64383
+ return;
64384
+ }
64224
64385
  if (command === "projects" && subcommand === "list") {
64225
64386
  output({ projects: await listProjects(new Client(await config(args))) });
64226
64387
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "editorbrain",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "EditorBrain editorial knowledge, portable workspaces and skills for AI video agents",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -24,12 +24,14 @@ For factual subjects or real demonstrations, inspect authentic footage/images fr
24
24
  5. Ground each new edit or materially changed direction in the **edited-video reference library**, including when a favorite style is selected. Search with `search_from_brief` (CLI `editorbrain search brief --file <brief.json>`) and the project ID after inspecting the material. Read [search brief](references/search-brief.md) for the schema and [editorial evidence](references/editorial-evidence.md) for selection/adaptation. Search communicative needs, not a guessed template. A scoped revision may reuse still-valid inspected evidence and approval. The optional `search_for_edit` server-analysis path is never an automatic fallback.
25
25
  6. Survey candidates across the video's needs, then inspect selected documents and actual frames/intervals, plus audio when sound matters. Build a coherent scene-level palette of techniques: opening, imagery, type, explanation and transitions can come from different references. A shortlist is not a single template for the whole video. Use preferences to retain, adapt or reject mechanisms; save evidence, limitations and beat-level adaptations in `direction.md`. A motion-catalog lookup does not complete this step. If evidence is inaccessible or unsuitable, disclose the gap. For factual beats, compare allowed user footage and authentic web media with authored explanation. The engine does not dictate the imagery or entire layout.
26
26
  7. Follow the effective review mode: `direct` → implement and review; `directions` → propose suitable alternatives with labelled reference evidence; `sample` → show a frame/short clip made with customer material before the full edit. Respect existing approval. Check the preference version before proposals, costly generation and full rendering; a web update cannot interrupt an active tool.
27
- 8. Carry the same `editorialSnapshot` into implementation and any specialist handoff. For a sample or localized correction, use [production continuity](references/production-continuity.md): register the exact version, retain unaffected work and retrieve pending feedback. Before delivery, use `production verify --project ID --file <render>` when available, inspect its real samples and review the final motion/audio. An editing request needs the edit, not only a plan. Use [production and review](references/production-and-review.md) to compare editorial quality with the chosen references and any applicable user-approved sample; a technical pass alone cannot satisfy that comparison. Report unverified dimensions separately from technical results.
27
+ 8. When available, read `check_workflow.projectProgress` on resuming (or `get_project_progress` / `editorbrain projects progress --project ID`). Register actual work with `update_project_progress` (`action:start`, current `expectedVersion`) / `projects start`. This keeps the web board current; it is not a heartbeat. See [production continuity](references/production-continuity.md) for final delivery and conflict handling. Carry the same `editorialSnapshot` into implementation and any specialist handoff. For a sample or localized correction, use [production continuity](references/production-continuity.md): register the exact version, retain unaffected work and retrieve pending feedback. Before delivery, use `production verify --project ID --file <render>` when available, inspect its real samples and review the final motion/audio. An editing request needs the edit, not only a plan. Use [production and review](references/production-and-review.md) to compare editorial quality with the chosen references and any applicable user-approved sample; a technical pass alone cannot satisfy that comparison. Report unverified dimensions separately from technical results.
28
28
 
29
29
  ## Specialized work, loaded when needed
30
30
 
31
31
  Use the decision ownership in [the shared contract](AGENTS.md#decision-ownership): montage, media, motion, sound and review share one direction. For animated scenes, read [editorbrain-motion](../editorbrain-motion/SKILL.md) with the active project and resolved context. Ordinary cuts, static graphics or sync/install tasks need no motion skill. When the edit follows a recognizable format (for example a numbered tutorial, or a reference whose structure the user wants replicated), read [editorbrain-formats](../editorbrain-formats/SKILL.md) for its structure and sync rules; identity still comes from the resolved brand and style. User memory belongs in scoped brand/style/project files; shared guides describe methods and examples supply evidence.
32
32
 
33
+ For multiple final videos, read the batch section of [production continuity](references/production-continuity.md): confirm clip grouping, isolate projects, acquire turns and respect batch budgets. `check_workflow.batch` carries coordination; it does not replace project preferences.
34
+
33
35
  For built-in transcription, image generation or narration, read [EditorBrain Studio](references/studio.md) only when needed. Discover availability first; quote and obtain an authorized budget before spending. Local MP4 preparation, durable job recovery and private result retrieval are covered there.
34
36
 
35
37
  For cloud uploads or storage cleanup, read [storage and previews](references/storage-and-previews.md) only then; keep originals and masters distinct from lightweight review copies.
@@ -100,3 +100,96 @@ allowed lists. `MEDIA_USE_REVIEW` stays pending even after a technical pass: com
100
100
  the plan and actual visible resources, including useful omissions, using
101
101
  [production review](production-and-review.md). The tool does not inspect or count
102
102
  imagery automatically, generate assets or authorize any provider.
103
+
104
+
105
+ ## Keep the project board current (discover availability)
106
+
107
+ Web, CLI and MCP use the same server state; do not store an independent status in
108
+ `project.json`. First discover these tools/commands with the installed CLI help or
109
+ MCP tools list. Older clients/services remain usable; report unavailable progress
110
+ tracking without claiming that the board was updated.
111
+
112
+ - `get_project_progress` / `editorbrain projects progress --project ID` returns
113
+ `stage`, `version`, latest review and pending count. Read on resume and before writes.
114
+ - `update_project_progress` with `action:start`, `expectedVersion` / `projects start
115
+ --project ID --expected-version HASH`: mark actual editing, including a correction.
116
+ It does not run a render, wake an agent or authorize paid tools.
117
+ - New registered samples automatically appear **Para revisar**. Requested changes
118
+ return to **En edición**. Sample approval alone never finishes a project.
119
+ - When the user accepts the exact complete video as the final delivery, use existing
120
+ production approval for that revision, read progress again, then `projects complete
121
+ --project ID --revision UUID --expected-version HASH --user-confirmed` or MCP
122
+ `update_project_progress` with `action:complete`, `revisionId`, `expectedVersion`,
123
+ `userConfirmed:true`. Do not request the same approval twice; record an existing
124
+ explicit final acceptance. A frame, preview, successful render or technical pass
125
+ is insufficient. Only the latest current, available, approved video with zero
126
+ pending project feedback can finish. Direct editing mode still skips proposal
127
+ gates; final acceptance is not inferred from that preference.
128
+ - `action:queue` / `projects queue` returns work to **Por editar** when the user
129
+ defers it and no corrections are pending. `start` can reopen a completed video.
130
+ - On `progress_changed`, refresh and reconcile; never automatically replay stale
131
+ intent. If a write response is lost, read progress before retrying. No push/pull
132
+ is needed for this operational state. No live-agent presence is implied.
133
+
134
+ Archived samples and approvals remain historical evidence. Context changes,
135
+ new samples, removed media and requested corrections can invalidate completion.
136
+ Report unavailable/unknown state honestly; do not assign a stage from filenames.
137
+
138
+ ## Multiple videos: one batch, isolated projects
139
+
140
+ Load this section only for multiple deliverables. Discover `batches` / editing-batch
141
+ MCP tools first. One project means one final video, not necessarily one source file.
142
+ Inspect the supplied inventory; if grouping is ambiguous, show a short visual list
143
+ of proposed titles and assigned clips for confirmation. For videos from scratch,
144
+ create one project/brief per idea through the existing project workflow. Never
145
+ silently turn ten clips of one story into ten videos. Keep originals intact.
146
+
147
+ Create the projects normally, then `batches create --file batch.json` (MCP
148
+ `create_editing_batch`). Retain a UUID batchId for retry-safe creation:
149
+
150
+ ```json
151
+ {"batchId":"UUID","name":"This week","styleMode":"series","maxConcurrency":1,"maxCredits":0,"items":[{"projectId":"first-video","maxCredits":0}],"userConfirmed":true}
152
+ ```
153
+
154
+ Confirm the grouping and ceilings with the user; start paused. Zero forbids paid
155
+ Studio generation, not local editing. `series` means a shared direction, not copied
156
+ scenes. `individual` chooses references for each story. Neither overwrites brand,
157
+ language, exclusions, review preference, project exceptions or integration access.
158
+ Reuse relevant shared context but search edited-video references for each brief;
159
+ carry the evidence into each project. Never copy a previous video's factual assets
160
+ or assume its approval transfers. Keep directories, renders and feedback separate.
161
+
162
+ Read `batches get ID` / `get_editing_batch`. Resume with `batches resume ID
163
+ --expected-revision N` / `update_editing_batch`. This enables scheduling; it does
164
+ not start a renderer or authorize spending. Work serially by default; use at most
165
+ the configured slots if the host supports independent workers and enough disk,
166
+ RAM and render capacity. Do not load every project's full context into one task.
167
+
168
+ Before work, claim ONE project using `batches claim ID --project PROJECT
169
+ --lease-token UUID` / `editing_batch_lease`. Keep that UUID for retries; another
170
+ session cannot acquire the same item. Read `check_workflow.batch` and the project's
171
+ snapshot; mark actual editing through project progress. Renew within 20 minutes
172
+ and recheck before each expensive step. Stop scheduling on pause, cancellation,
173
+ expiration or conflict. Local processes already running cannot be remotely killed
174
+ by the queue. On host shutdown work waits for another authorized session.
175
+
176
+ Studio still needs exact quotes and authorized spending. Pass `--batch-lease-token`
177
+ to `studio run` (MCP `studio_run.batchLeaseToken`). The server enforces the shared
178
+ lot ceiling, per-video ceiling and credit balance atomically. External services
179
+ have separate budgets: these limits cannot control a user's own API/MCP charges.
180
+ Read existing job IDs after unknown outcomes; never replace them with new quotes.
181
+
182
+ After registering a fresh project sample, `batches release` / lease action release
183
+ frees the turn for the next video. This does NOT approve the sample or finish the
184
+ video. On failure use `attention`, preserve outputs and continue unaffected items.
185
+ Expired turns require inspection of local outputs and paid jobs before explicit
186
+ `batches retry ID --project PROJECT --expected-revision N`. Retry resets only that
187
+ queue item; it does not regenerate, delete files or waive existing approvals.
188
+ A requested correction similarly requeues its project after pending jobs settle.
189
+ Final acceptance remains per exact video; batch completion is derived from those
190
+ individual acceptances, never inferred from all renders finishing.
191
+
192
+ Pause stops new turns/paid jobs. Cancel also prevents future resumption of that
193
+ batch; in-flight jobs may still finish and be charged. To work separately afterward,
194
+ `batches detach ID --project PROJECT --expected-revision N` removes active membership
195
+ only after the cancelled project's jobs and turn have ended. History/costs remain.
@@ -3,9 +3,9 @@ name: editorbrain-formats
3
3
  description: >-
4
4
  Replicate a proven video format's structure in an EditorBrain edit, such as a
5
5
  numbered tutorial or demonstration (hook promise, numbered steps, a visible result
6
- for each step, presenter returns, CTA). Use when the user names a format or
7
- editorial family, asks to follow a reference's structure, or the material is a
8
- step-by-step list. Formats define structure, sync and pacing only; identity comes
6
+ for each step, presenter returns, CTA) or an aspirational list that turns personal
7
+ and ends in a demonstration. Use when the user names a format or editorial family,
8
+ asks to follow a reference's structure, or the material is a list of steps or items. Formats define structure, sync and pacing only; identity comes
9
9
  from the user's resolved brand and style.
10
10
  ---
11
11
 
@@ -42,6 +42,7 @@ The number of steps, durations, demo mechanisms and the headline's wording come
42
42
  | Format | Use when | Guide |
43
43
  |---|---|---|
44
44
  | Numbered tutorial / demonstration | The voice teaches N discrete steps, tips or instructions and each one can be shown working | [numbered tutorial](references/numbered-tutorial.md) |
45
+ | Aspirational list → personal experience → demonstration | The voice lists N people, tools or habits, each illustrated by strong images, then turns personal and shows how to act on it with real material | [aspirational list](references/aspirational-list-demo.md) |
45
46
 
46
47
  Read only the guide you will use. For animated beats, also use the sibling [motion skill](../editorbrain-motion/SKILL.md); for authentic results, follow [real media](../editorbrain/references/real-media.md).
47
48
 
@@ -0,0 +1,140 @@
1
+ # Format: aspirational list → personal experience → demonstration
2
+
3
+ A narrator promises a list ("N things, people or tools that…"). Each item opens its own short block of images carrying one lesson. The list then turns personal, toward the narrator's own practice or the viewer's future self, and lands on a concrete demonstration of how to act on it. The narrator stays present as a small element over full-frame imagery instead of filling the frame.
4
+
5
+ Evidence status: one implemented, user-approved pilot (talking head, a list of five practices, Spanish, vertical, about 130 s). Its reference was a library profile of an English aspirational list of about 85 s ending in a product demo. Transfer to other niches and to voice-over-only material is untested; record the first adaptation's review.
6
+
7
+ ## Authority and adaptation
8
+
9
+ Reuse the coordinator's resolved context. This guide offers reference grammar, not a mandatory layout or an override of presenter, media, motion or accessibility preferences. In direct mode, make compatible decisions and record them; ask for a sample approval only when the resolved review mode or an unresolved user requirement calls for one.
10
+
11
+ Match visual changes to meaning; do not cut the audio to force a picture change onto a timestamp. ASR word starts are candidates to verify against playback. Short phrases may need a shared image and longer explanations may need several states.
12
+
13
+ ## Fit
14
+
15
+ Use when:
16
+
17
+ - the script enumerates items that can each be illustrated by strong images: people, tools, places, habits, products;
18
+ - the voice carries continuously (voice-over or talking head), and reducing the presenter to a small element does not lose meaning;
19
+ - the list ends in something actionable that can be shown with real material: the narrator's own practice, a configured tool, a product, a first step.
20
+
21
+ Niche examples: people to study in any field, tools for a craft, habits for a goal, places to visit, investments to understand, routines of known performers.
22
+
23
+ Avoid or adapt when:
24
+
25
+ - the items cannot be illustrated without fabricating evidence (biographical claims, results, quotes);
26
+ - the presenter's face is the content (reaction, emotion, testimony). Keep them full frame instead;
27
+ - the demonstration would promote something that does not deliver what is said. Omit the demo and keep the list.
28
+
29
+ ## Skeleton
30
+
31
+ | Beat | Function | What must be on screen |
32
+ |---|---|---|
33
+ | Hook | Promise the list and who it is for | A collage of the list's strongest visuals (one per cell), a short headline lockup over it and the presenter small over the collage |
34
+ | Item k (repeat N times) | Name the item and its lesson | Item marker (k of N plus name) and one full-frame image per idea, cut on the word that introduces it |
35
+ | Personal turn | Move from examples to the narrator or the viewer | The narrator's real material: their folders, screens, products, recordings |
36
+ | Demonstration | Show how to act on the list | Real interface or product: page scroll, configured screens, steps; the presenter may shrink, move or leave |
37
+ | CTA | The real ask | The presenter returns (full frame or small), optional recap of the N items, the CTA object |
38
+
39
+ The personal turn can be a final block or woven into every item, with each item ending on the narrator's own tool or result. The pilot wove it into every item. Choose by the script; do not force a twist the voice does not make.
40
+
41
+ ## Voice and image
42
+
43
+ - **One idea, one image.** Cut when the voice introduces a new idea, name or example (on that word), not on a timer.
44
+ - **The image answers the sentence:**
45
+ - a named tool or product: its real mark or interface at the moment it is named;
46
+ - an abstract claim: an illustrative image, labelled when it could be mistaken for evidence;
47
+ - a number: the number on screen;
48
+ - a spoken series in one sentence ("apps, documents, tables, projects"): one demo that advances on each word (scroll steps, highlighted rows).
49
+ - **Authentic material first** for anything factual: the user's own product screens, folders, repositories, dashboards and recordings. Clean captures of public pages for named products are authentic support. Hide consent overlays without accepting them, and use logged-in or private screens only when the user provided or explicitly authorized them. Retain source attribution and exclude private details unnecessary to the edit.
50
+ - **Illustrative stills** (generated or licensed) only within the media preferences and an authorized budget. Keep one coherent look across them (lighting, grade, depth) so the list reads as one piece.
51
+ - **Brand characters:** when generating a brand character or mascot, always attach the real asset as a reference; an off-model character undermines trust.
52
+ - **Do not reuse an image, capture or clip** for a different idea. A deliberate callback is the exception and must read as one.
53
+ - **The hook headline** sits directly on the collage in the reference. Keep it legible with the resolved type treatment (outline, shadow, weight) before adding a backing band; follow accessibility and the user's caption/text preferences.
54
+
55
+ ## Presenter treatment
56
+
57
+ - **Reference grammar:** the presenter is a small floating cut-out (person matte, no background, no frame) placed over each image and relocated per shot.
58
+ - Placement keeps faces, headlines, controls and the demo's key interface visible.
59
+ - Adapt to the user's presenter/layout preferences: a split layout, full-frame return, framed picture-in-picture or no presenter may fit better. A clean matte is not a requirement for using the narrative structure.
60
+ - **Matte:** generate it for the whole cut before designing. The pilot used on-device person segmentation on a static camera; other cameras and motions need verification. Apple Vision is an optional macOS implementation, not a client dependency. On other platforms use an available, authorized masking tool or an agreed layout without a matte. Do not install tools or upload private footage merely because this guide mentions segmentation.
61
+ - Check hair, hands and anything held near the body.
62
+ - If edges fail, compare the available alternatives within the user's tools, budget and data permissions; a simpler layout is acceptable.
63
+ - **Size and crop:** head to mid-torso, hands included, at about a third of the frame width in the pilot.
64
+ - **Edge treatment:** a clean crop, fades, outline or shadow is an identity choice; resolve it from preferences or ask. The pilot shipped a clean crop with a soft drop shadow.
65
+ - **Relocation:** when the presenter moves with the cut, a small settle (scale or position) can help the eye find them again; follow the resolved motion intensity.
66
+
67
+ ## Rhythm
68
+
69
+ - Measured pilot holds ranged from about 0.7 s to 6 s (median about 3 s) per image; item blocks lasted 15–31 s. These are evidence, not targets: holds follow the idea and the copy.
70
+ - A longer hold can use a slow push, page scroll or a later overlay when it helps meaning. A deliberate static hold is valid for reading or calm motion preferences.
71
+ - Cuts can land with a short push-in to give each new image energy, within the resolved motion preferences.
72
+ - Captions stay in one stable band, never under the presenter. Short phrases suit the fast image rhythm.
73
+
74
+ ## Filling the frame
75
+
76
+ Authored scenes (numbers, code or file windows, badges, logo grids) should use the chosen output frame deliberately, including square and landscape adaptations. Options include:
77
+
78
+ - a full-bleed image behind them;
79
+ - windows that span the width;
80
+ - overlays sized to be read on a phone.
81
+
82
+ A small centered element on an empty stage reads as a placeholder next to full-bleed images. Crop real captures to their most legible region (one card, one heading, one logo) instead of shrinking a whole page into a cell.
83
+
84
+ ## Identity roles to resolve
85
+
86
+ Map these roles to the user's tokens and `design.md`, never to a reference's or pilot's look:
87
+
88
+ | Role | Notes |
89
+ |---|---|
90
+ | Hook headline lockup | Dominant and accent styles, legibility treatment |
91
+ | Item marker | Numeral and item name; persistent or entering per item |
92
+ | Captions | The brand's caption rules |
93
+ | Tags on real captures | Source or explanation labels |
94
+ | Overlay cards | Numbers, badges, stamps, logo tiles |
95
+ | Grade of illustrative stills | Consistent across the video |
96
+ | Presenter edge treatment and shadow | |
97
+
98
+ ## Failure modes seen in practice
99
+
100
+ - **A framed picture-in-picture** where the reference floats a cut-out: it changes the format's look. Confirm the presenter treatment before rendering.
101
+ - **Opacity fades or backing bands** added for legibility without being asked for. Prefer type treatment, and confirm visible changes to the presenter or headline.
102
+ - **Authored scenes as small elements on an empty dark stage.**
103
+ - **The same image used in the hook, an item and a recap.**
104
+ - **Collage cells with unreadable page text.**
105
+ - **Generic stand-ins** (an unbranded laptop app) where the voice names a product: show its real mark or interface.
106
+ - **A brand character generated without its reference image**, coming out off-model.
107
+ - **The presenter covering the key element** of a real capture.
108
+ - **Consent banners left visible** on page captures.
109
+
110
+ ## Review checklist
111
+
112
+ 1. **Contact sheet:** one still per shot at its key word.
113
+ 2. **Named products:** every named product, person or tool shows its real mark or interface when named.
114
+ 3. **Reuse:** list every visual; confirm none repeats except a deliberate callback.
115
+ 4. **Presenter:** check placement per shot (nothing important covered) and matte edges on hair and hands in motion.
116
+ 5. **Frame use:** authored scenes fill the frame; overlays are readable at phone size.
117
+ 6. **Captions:** the band is stable and safe areas are respected for every element.
118
+ 7. **Labelling:** report what is authentic, illustrative and generated.
119
+ 8. **Audio:** follows the user's choices; report what was not heard.
120
+
121
+ ## Pilot evidence (example values, not defaults)
122
+
123
+ | Measure | Pilot value |
124
+ |---|---|
125
+ | Total | ≈ 130 s; hook 4.2 s; CTA 10.5 s |
126
+ | Images | 37 shots, none repeated |
127
+ | Public page captures and the narrator's own screen recordings | 14 shots |
128
+ | The narrator's own brand assets (product mascot) | 3 |
129
+ | Authored scenes built from the narrator's real data (folders, files) | 3 |
130
+ | Illustrative generated stills | 15 |
131
+ | Hook collage and full-frame presenter CTA | 2 |
132
+ | Presenter | Floating cut-out, relocated on almost every cut |
133
+
134
+ What the user approved:
135
+ - the floating cut-out;
136
+ - real product pages and the narrator's own tools as the personal experience;
137
+ - one image per idea;
138
+ - the headline set directly on a collage that shows the real brand marks of the list.
139
+
140
+ What the user corrected: a framed presenter, an opacity fade on the cut-out, a backing band behind the headline, repeated images, empty authored scenes and generic stand-ins for named products.