editorbrain 0.11.0 → 0.12.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 +6 -2
- package/bin/editorbrain.mjs +924 -65
- package/package.json +1 -1
- package/skill/editorbrain/AGENTS.md +23 -62
- package/skill/editorbrain/SKILL.md +2 -2
- package/skill/editorbrain/references/cli-mcp.md +7 -3
- package/skill/editorbrain/references/first-edit.md +2 -2
- package/skill/editorbrain/references/local-production.md +72 -0
- package/skill/editorbrain/references/production-and-review.md +6 -2
- package/skill/editorbrain/references/search-brief.md +1 -1
- package/skill/editorbrain/references/workspace-files.md +5 -1
package/bin/editorbrain.mjs
CHANGED
|
@@ -52,7 +52,7 @@ function validateStyleResources(value, expectedId) {
|
|
|
52
52
|
const fields = (entry, allowed, label) => {
|
|
53
53
|
if (Object.keys(entry).some((key) => !allowed.includes(key))) add(`${label} has unsupported fields; do not include URLs, credentials or hidden resource sources`);
|
|
54
54
|
};
|
|
55
|
-
const
|
|
55
|
+
const text2 = (value2, label) => {
|
|
56
56
|
if (!nonempty(value2)) add(`${label} must be nonempty text`);
|
|
57
57
|
else if (forbiddenText.test(value2) || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value2)) add(`${label} cannot contain URLs, credentials or control characters`);
|
|
58
58
|
};
|
|
@@ -74,7 +74,7 @@ function validateStyleResources(value, expectedId) {
|
|
|
74
74
|
else if (ids.has(resource.id)) add(`Duplicate resource id: ${resource.id}`);
|
|
75
75
|
else ids.add(resource.id);
|
|
76
76
|
if (!kinds.includes(resource.kind)) add(`${label} has an invalid kind`);
|
|
77
|
-
|
|
77
|
+
text2(resource.purpose, `${label} purpose`);
|
|
78
78
|
if (typeof resource.required !== "boolean") add(`${label} required must be boolean`);
|
|
79
79
|
const source = resource.source;
|
|
80
80
|
if (!object(source)) {
|
|
@@ -94,7 +94,7 @@ function validateStyleResources(value, expectedId) {
|
|
|
94
94
|
if (typeof source.assetId !== "string" || !digest.test(source.assetId)) add(`${label} assetId must be a 64-character lowercase hexadecimal digest`);
|
|
95
95
|
} else if (source.type === "requirement") {
|
|
96
96
|
fields(source, ["type", "description"], `${label} requirement source`);
|
|
97
|
-
|
|
97
|
+
text2(source.description, `${label} requirement description`);
|
|
98
98
|
} else add(`${label} source must be local, asset or requirement`);
|
|
99
99
|
}
|
|
100
100
|
}
|
|
@@ -151,21 +151,21 @@ function checkStyle(files, id) {
|
|
|
151
151
|
"Preview the implemented edit: verify timing anchors, movement, legibility, audio and when each technique should be omitted.",
|
|
152
152
|
"Verify resource availability, local bytes, remote workspace authorization, provenance and reuse rights. Structural validation does not certify these."
|
|
153
153
|
];
|
|
154
|
-
const
|
|
154
|
+
const finish2 = (documents2) => ({ valid: issues.length === 0, issues, warnings, documents: documents2, manualChecks });
|
|
155
155
|
const base = `.editorbrain/styles/${id}/`;
|
|
156
156
|
try {
|
|
157
157
|
assertIdentifier(id);
|
|
158
158
|
safeDocumentPath(`${base}style.json`);
|
|
159
159
|
} catch (error) {
|
|
160
160
|
issues.push({ path: base, message: error.message });
|
|
161
|
-
return
|
|
161
|
+
return finish2([]);
|
|
162
162
|
}
|
|
163
163
|
let selected;
|
|
164
164
|
try {
|
|
165
165
|
selected = canonicalFiles(Object.fromEntries(Object.entries(files).filter(([path]) => canonicalDocumentPath(path).startsWith(base))));
|
|
166
166
|
} catch (error) {
|
|
167
167
|
issues.push({ path: base, message: error.message });
|
|
168
|
-
return
|
|
168
|
+
return finish2([]);
|
|
169
169
|
}
|
|
170
170
|
const documents = Object.keys(selected).sort();
|
|
171
171
|
for (const name of ["style.json", "style.md", "examples.json"]) {
|
|
@@ -197,7 +197,138 @@ function checkStyle(files, id) {
|
|
|
197
197
|
for (const resource of resources.resources) if (resource.source.type === "requirement") warnings.push(`${path}: Resource ${resource.id} is a requirement; availability has not been verified.`);
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
|
-
return
|
|
200
|
+
return finish2(documents);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// packages/core/src/production-plan.ts
|
|
204
|
+
var record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
205
|
+
var positive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
206
|
+
var nonnegative = (v) => v === 0 || positive(v);
|
|
207
|
+
var hash = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v);
|
|
208
|
+
var text = (v) => typeof v === "string" && v.trim().length > 0 && v.length <= 2e3;
|
|
209
|
+
var range = (v) => record(v) && nonnegative(v.startSeconds) && positive(v.endSeconds) && v.endSeconds > v.startSeconds;
|
|
210
|
+
var MAX_ENTRIES = 2e3;
|
|
211
|
+
function validateProductionPlan(value) {
|
|
212
|
+
const issues = [];
|
|
213
|
+
if (!record(value) || value.schemaVersion !== 1 || value.kind !== "editorbrain.production-plan") return { valid: false, issues: ["Expected schemaVersion 1 and kind editorbrain.production-plan."] };
|
|
214
|
+
if (Object.keys(value).some((key) => !["schemaVersion", "kind", "inspections", "uses"].includes(key))) issues.push("Unknown plan fields; keep editorial notes in direction.md and use the documented plan schema.");
|
|
215
|
+
if (!Array.isArray(value.inspections) || value.inspections.length > MAX_ENTRIES) issues.push("inspections must be an array of at most 2000 observations.");
|
|
216
|
+
else value.inspections.forEach((item, index) => {
|
|
217
|
+
const label = `inspections[${index}]`;
|
|
218
|
+
if (!record(item) || !hash(item.sourceSha256) || !["visual", "motion", "audio"].includes(String(item.modality)) || !["sampled_frames", "playback", "listening"].includes(String(item.method))) {
|
|
219
|
+
issues.push(`${label}: invalid source hash, modality or method.`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (Object.keys(item).some((key) => !["sourceSha256", "modality", "method", "intervals", "sampledSeconds", "note"].includes(key))) issues.push(`${label}: unsupported field.`);
|
|
223
|
+
if (item.note !== void 0 && !text(item.note)) issues.push(`${label}: note must be nonempty text of at most 2000 characters.`);
|
|
224
|
+
if (item.method === "sampled_frames") {
|
|
225
|
+
if (item.modality !== "visual" || item.intervals !== void 0 || !Array.isArray(item.sampledSeconds) || !item.sampledSeconds.length || item.sampledSeconds.length > MAX_ENTRIES || !item.sampledSeconds.every(nonnegative)) issues.push(`${label}: sampled_frames requires visual modality and sampledSeconds; snapshots never establish continuous interval coverage.`);
|
|
226
|
+
} else {
|
|
227
|
+
if (item.sampledSeconds !== void 0 || !Array.isArray(item.intervals) || !item.intervals.length || item.intervals.length > MAX_ENTRIES || !item.intervals.every(range)) issues.push(`${label}: playback/listening requires valid intervals, not sampledSeconds.`);
|
|
228
|
+
if (item.method === "listening" && item.modality !== "audio" || item.method === "playback" && item.modality === "audio") issues.push(`${label}: listening covers audio; playback covers visual or motion. Record separately what was actually inspected.`);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
if (!Array.isArray(value.uses) || value.uses.length > MAX_ENTRIES) issues.push("uses must be an array of at most 2000 source uses.");
|
|
232
|
+
else {
|
|
233
|
+
const ids = /* @__PURE__ */ new Set();
|
|
234
|
+
value.uses.forEach((item, index) => {
|
|
235
|
+
const label = `uses[${index}]`;
|
|
236
|
+
if (!record(item) || typeof item.id !== "string" || !/^[a-zA-Z0-9_-]{1,80}$/.test(item.id) || !hash(item.sourceSha256) || !range(item.source) || !range(item.timeline)) {
|
|
237
|
+
issues.push(`${label}: requires ID, source hash, source and timeline intervals.`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (ids.has(item.id)) issues.push(`${label}: duplicate use ID ${item.id}.`);
|
|
241
|
+
ids.add(item.id);
|
|
242
|
+
if (Object.keys(item).some((key) => !["id", "sourceSha256", "source", "timeline", "purpose", "reuseReason"].includes(key))) issues.push(`${label}: unsupported field.`);
|
|
243
|
+
for (const key of ["purpose", "reuseReason"]) if (item[key] !== void 0 && !text(item[key])) issues.push(`${label}: ${key} must be nonempty text of at most 2000 characters.`);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return { valid: !issues.length, issues };
|
|
247
|
+
}
|
|
248
|
+
function mergeProductionIntervals(intervals) {
|
|
249
|
+
const merged = [];
|
|
250
|
+
for (const interval of [...intervals].sort((a, b) => a.startSeconds - b.startSeconds)) {
|
|
251
|
+
const last = merged.at(-1);
|
|
252
|
+
if (last && interval.startSeconds <= last.endSeconds) last.endSeconds = Math.max(last.endSeconds, interval.endSeconds);
|
|
253
|
+
else merged.push({ ...interval });
|
|
254
|
+
}
|
|
255
|
+
return merged;
|
|
256
|
+
}
|
|
257
|
+
function checkProductionPlan(inventory, value) {
|
|
258
|
+
const checked = validateProductionPlan(value);
|
|
259
|
+
const findings = checked.issues.map((message) => ({ code: "INVALID_PLAN", severity: "error", message }));
|
|
260
|
+
const sources = /* @__PURE__ */ new Map();
|
|
261
|
+
let inventoryComplete = true;
|
|
262
|
+
if (!record(inventory) || inventory.schemaVersion !== 1 || inventory.kind !== "editorbrain.media-inventory" || inventory.summary === true || typeof inventory.complete !== "boolean" || !Array.isArray(inventory.assets) || inventory.assets.length > 1e4) {
|
|
263
|
+
inventoryComplete = false;
|
|
264
|
+
findings.push({ code: "INVALID_INVENTORY", severity: "error", message: "Use the full schemaVersion 1 media inventory saved with --out, not compact stdout. It must declare complete." });
|
|
265
|
+
} else {
|
|
266
|
+
if (inventory.complete === false || Array.isArray(inventory.failures) && inventory.failures.length) {
|
|
267
|
+
inventoryComplete = false;
|
|
268
|
+
findings.push({ code: "INVENTORY_INCOMPLETE", severity: "warning", message: "Inventory traversal or probing was incomplete. This plan check covers only recorded assets." });
|
|
269
|
+
}
|
|
270
|
+
for (const asset of inventory.assets) {
|
|
271
|
+
if (!record(asset) || !hash(asset.sha256) || !(asset.durationSeconds === null || nonnegative(asset.durationSeconds)) || asset.streams !== void 0 && (!Array.isArray(asset.streams) || asset.streams.some((s) => !record(s) || typeof s.type !== "string"))) {
|
|
272
|
+
findings.push({ code: "INVALID_INVENTORY", severity: "error", message: "Inventory assets need a source hash, nonnegative duration or null, and valid optional streams." });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const existing = sources.get(asset.sha256);
|
|
276
|
+
if (existing && existing.durationSeconds !== asset.durationSeconds) findings.push({ code: "INVENTORY_CONFLICT", severity: "error", message: "The same content hash has conflicting durations.", sourceSha256: asset.sha256 });
|
|
277
|
+
sources.set(asset.sha256, asset);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (findings.some((f) => f.severity === "error")) return { schemaVersion: 1, kind: "editorbrain.production-plan-check", valid: false, inventoryComplete, findings, coverage: [], sourceFilesReverified: false, quality: "not_assessed", observations: "agent_declared" };
|
|
281
|
+
const plan = value;
|
|
282
|
+
const sourceFor = (sourceSha256) => {
|
|
283
|
+
const source = sources.get(sourceSha256);
|
|
284
|
+
if (!source) findings.push({ code: "UNKNOWN_SOURCE", severity: "error", message: "A source hash is absent from the selected inventory. Refresh the inventory after changing media.", sourceSha256 });
|
|
285
|
+
return source;
|
|
286
|
+
};
|
|
287
|
+
const inBounds = (end, source) => source.durationSeconds === null || end <= source.durationSeconds + 1e-3;
|
|
288
|
+
for (const inspection of plan.inspections) {
|
|
289
|
+
const source = sourceFor(inspection.sourceSha256);
|
|
290
|
+
if (!source) continue;
|
|
291
|
+
if (source.streams && !source.streams.some((s) => s.type === (inspection.modality === "audio" ? "audio" : "video") && (inspection.modality !== "motion" || !s.attachedPicture))) findings.push({ code: "MODALITY_ABSENT", severity: "error", message: `Declared ${inspection.modality} inspection has no matching stream.`, sourceSha256: source.sha256 });
|
|
292
|
+
if ([...inspection.sampledSeconds ?? [], ...(inspection.intervals ?? []).map((r) => r.endSeconds)].some((end) => !inBounds(end, source))) findings.push({ code: "INSPECTION_OUT_OF_BOUNDS", severity: "error", message: "Inspection extends past the source duration.", sourceSha256: source.sha256 });
|
|
293
|
+
}
|
|
294
|
+
const validUses = [];
|
|
295
|
+
for (const use of plan.uses) {
|
|
296
|
+
const source = sourceFor(use.sourceSha256);
|
|
297
|
+
if (!source) continue;
|
|
298
|
+
if (!inBounds(use.source.endSeconds, source)) findings.push({ code: "USE_OUT_OF_BOUNDS", severity: "error", message: "Source interval extends past the source duration.", sourceSha256: source.sha256, useIds: [use.id], interval: use.source });
|
|
299
|
+
else validUses.push(use);
|
|
300
|
+
}
|
|
301
|
+
for (const use of validUses) {
|
|
302
|
+
const source = sources.get(use.sourceSha256);
|
|
303
|
+
const audioOnly = source.streams?.some((s) => s.type === "audio") && !source.streams.some((s) => s.type === "video" && !s.attachedPicture);
|
|
304
|
+
const inspected = mergeProductionIntervals(plan.inspections.filter((i) => i.sourceSha256 === source.sha256 && (audioOnly ? i.modality === "audio" : i.modality === "visual" || i.modality === "motion")).flatMap((i) => i.intervals ?? []).filter((r) => inBounds(r.endSeconds, source)));
|
|
305
|
+
const covered = inspected.reduce((sum, r) => sum + Math.max(0, Math.min(use.source.endSeconds, r.endSeconds) - Math.max(use.source.startSeconds, r.startSeconds)), 0);
|
|
306
|
+
const missing = use.source.endSeconds - use.source.startSeconds - covered;
|
|
307
|
+
if (missing > 1e-3) findings.push({ code: "USED_RANGE_COVERAGE_GAP", severity: source.durationSeconds === null ? "info" : "warning", message: `${Math.round(missing * 1e3) / 1e3}s of this source use lack declared continuous ${audioOnly ? "audio" : "visual/motion"} inspection. Review relevant content when needed; snapshots do not establish interval coverage. This is not a requirement to inspect every unused source.`, sourceSha256: source.sha256, useIds: [use.id], interval: use.source });
|
|
308
|
+
}
|
|
309
|
+
let repeatedPairs = 0;
|
|
310
|
+
for (let i = 0; i < validUses.length; i++) for (let j = i + 1; j < validUses.length; j++) {
|
|
311
|
+
const a = validUses[i], b = validUses[j];
|
|
312
|
+
if (a.sourceSha256 !== b.sourceSha256) continue;
|
|
313
|
+
const startSeconds = Math.max(a.source.startSeconds, b.source.startSeconds), endSeconds = Math.min(a.source.endSeconds, b.source.endSeconds);
|
|
314
|
+
if (endSeconds - startSeconds <= 1e-3) continue;
|
|
315
|
+
repeatedPairs++;
|
|
316
|
+
if (repeatedPairs <= 200) findings.push({ code: b.reuseReason ? "SOURCE_REUSE_EXPLAINED" : "SOURCE_REUSE_REVIEW", severity: b.reuseReason ? "info" : "warning", message: b.reuseReason ? `Declared reuse: ${b.reuseReason}` : "These uses overlap the same source interval. Review whether the callback, comparison or repeated action serves the edit.", sourceSha256: a.sourceSha256, useIds: [a.id, b.id], interval: { startSeconds, endSeconds } });
|
|
317
|
+
}
|
|
318
|
+
if (repeatedPairs > 200) findings.push({ code: "REUSE_DETAILS_LIMIT", severity: "info", message: `${repeatedPairs} repeated pairs detected; only the first 200 are detailed.` });
|
|
319
|
+
const coverage = [...sources.values()].map((source) => {
|
|
320
|
+
const byModality = ["visual", "motion", "audio"].map((modality) => {
|
|
321
|
+
const observations = plan.inspections.filter((i) => i.sourceSha256 === source.sha256 && i.modality === modality);
|
|
322
|
+
const intervals = mergeProductionIntervals(observations.flatMap((i) => i.intervals ?? []).filter((r) => inBounds(r.endSeconds, source)));
|
|
323
|
+
const seconds = intervals.reduce((sum, i) => sum + i.endSeconds - i.startSeconds, 0);
|
|
324
|
+
return { modality, intervals, seconds, fraction: !source.durationSeconds ? null : seconds / source.durationSeconds, sampledSeconds: [...new Set(observations.flatMap((i) => i.sampledSeconds ?? []))].sort((a, b) => a - b) };
|
|
325
|
+
});
|
|
326
|
+
const sourceUses = validUses.filter((use) => use.sourceSha256 === source.sha256);
|
|
327
|
+
if (!plan.inspections.some((i) => i.sourceSha256 === source.sha256)) findings.push({ code: sourceUses.length ? "USED_SOURCE_UNINSPECTED" : "SOURCE_UNINSPECTED", severity: sourceUses.length ? "warning" : "info", message: sourceUses.length ? "Used source has no declared content inspection; metadata is not content review." : "Source has no declared content inspection. Using every source is not required.", sourceSha256: source.sha256 });
|
|
328
|
+
if (source.durationSeconds === null) findings.push({ code: "DURATION_UNKNOWN", severity: "info", message: "Duration unknown; interval bounds and coverage fraction cannot be verified for this source.", sourceSha256: source.sha256 });
|
|
329
|
+
return { sourceSha256: source.sha256, durationSeconds: source.durationSeconds, uses: sourceUses.length, byModality };
|
|
330
|
+
});
|
|
331
|
+
return { schemaVersion: 1, kind: "editorbrain.production-plan-check", valid: !findings.some((f) => f.severity === "error"), inventoryComplete, findings, coverage, repeatedPairs, sourceFilesReverified: false, quality: "not_assessed", observations: "agent_declared" };
|
|
201
332
|
}
|
|
202
333
|
|
|
203
334
|
// packages/core/src/index.ts
|
|
@@ -985,7 +1116,7 @@ import { createHash } from "node:crypto";
|
|
|
985
1116
|
import { constants as constants2 } from "node:fs";
|
|
986
1117
|
import { lstat as lstat2, open as open2, readdir as readdir2, rename as rename2 } from "node:fs/promises";
|
|
987
1118
|
import { fileURLToPath } from "node:url";
|
|
988
|
-
var previousSkillHashes = { "SKILL.md": ["9df2c89deeec30ddf556c359027efe1d90e6b4e9c08717cd8827e210b38a187f", "67b157cb45cae3621e6f2c43235c1d69907ad6abd45ab6b52a3c65f236732db9", "1e2fff65a1b303b18b2d65156bef6eb2439a19d77ba2a25b37bcb1819ff009e3", "dacce1c4127f4281df6f7e1681e243444bb6f8a8e74b829e52ee0108c6672348", "09740ab4b275eada65b5040ae6000104ff39094f534a2784e67feb2afc2a59b3"], "AGENTS.md": ["4f3ef860ade3a31aa7c6a6aab5cf6ec2a700850bf19ea2320324c12c2e0fbf5b", "1b07de01698a0040b47ea0eddb1b017f70a69e81acd0bf9710647ca5e02f5125", "b5ee3439ff974c668755816149fab76899d7ad9266f63978b30fbc9b7d2cd640", "bacedb7bdedab5abe75c9601717cfdb05a96208fa2e2a42af878e2dfd2ae4251", "ea3b473179857ccc07e9e4f21a6ede9e0906a09e495f4a051069d15aee1556cd"], "references/cli-mcp.md": ["5d3a79343fbd76cdae5bdbe12fb9c944d5dff4f489ac6534fd96e0cb8630b988", "8e011c31368cef2117a28ee972b39aab9535eeb0f601a4a0f9d6fc019b22c1c2", "666082653efc000f8a7cc5f1836c80bbdda5f0f57cdbbdcb393f2b84aeb93a01", "78b737796a8685b1135e0acb91b620c51845b62e31dba04eb51dd9dd38b41008", "8b2e01bfd937237919f5ade794eed01dc6e32b556ede9de3a0ccc28cbdd988d1", "ca88b2816a795a5ebe834074b178469367634b80ad837e3697182ea585af1c05"], "references/production-and-review.md": ["055c6df9e0f167be148b0a5c0bba5b43e108eb1f58f478d88b3536c6589ac7b3"], "references/workspace-files.md": ["e72af2871f42cd776be1bfff8009ed5562780e7f8a65b12c27887f3880631f81", "00780c64bd0244c6afb18d786b7512b43e315c376e8f88c67d6ba53d91275ccf", "97053b092e0ff36e3cfb764095fcb4a4fca94e722567e774b53bc118279f40e5", "70fc660e7ed3d70c16c33c62c8e0399b99c3251808f093ef3d4ea5e8d39e4c8a", "28c62c1e28842142607f5168ee79b18fd797900dd66dd9ac5eb3cfcf8a09f781"] };
|
|
1119
|
+
var previousSkillHashes = { "SKILL.md": ["9df2c89deeec30ddf556c359027efe1d90e6b4e9c08717cd8827e210b38a187f", "67b157cb45cae3621e6f2c43235c1d69907ad6abd45ab6b52a3c65f236732db9", "1e2fff65a1b303b18b2d65156bef6eb2439a19d77ba2a25b37bcb1819ff009e3", "dacce1c4127f4281df6f7e1681e243444bb6f8a8e74b829e52ee0108c6672348", "09740ab4b275eada65b5040ae6000104ff39094f534a2784e67feb2afc2a59b3", "f18269ad5504fff8a4fc522d463483261f5041e56cb7f1081f9614bc67e0d96f"], "AGENTS.md": ["4f3ef860ade3a31aa7c6a6aab5cf6ec2a700850bf19ea2320324c12c2e0fbf5b", "1b07de01698a0040b47ea0eddb1b017f70a69e81acd0bf9710647ca5e02f5125", "b5ee3439ff974c668755816149fab76899d7ad9266f63978b30fbc9b7d2cd640", "bacedb7bdedab5abe75c9601717cfdb05a96208fa2e2a42af878e2dfd2ae4251", "ea3b473179857ccc07e9e4f21a6ede9e0906a09e495f4a051069d15aee1556cd", "faa2bba99ae3023be7f588d02177a4abf583d3a8a65f617b8cff7a3ceed09dfc"], "references/cli-mcp.md": ["5d3a79343fbd76cdae5bdbe12fb9c944d5dff4f489ac6534fd96e0cb8630b988", "8e011c31368cef2117a28ee972b39aab9535eeb0f601a4a0f9d6fc019b22c1c2", "666082653efc000f8a7cc5f1836c80bbdda5f0f57cdbbdcb393f2b84aeb93a01", "78b737796a8685b1135e0acb91b620c51845b62e31dba04eb51dd9dd38b41008", "8b2e01bfd937237919f5ade794eed01dc6e32b556ede9de3a0ccc28cbdd988d1", "ca88b2816a795a5ebe834074b178469367634b80ad837e3697182ea585af1c05", "490daf154b428b536d0a6f590030c854b2fc8ca6adb995c6ecd5a162f85e471e"], "references/production-and-review.md": ["055c6df9e0f167be148b0a5c0bba5b43e108eb1f58f478d88b3536c6589ac7b3"], "references/workspace-files.md": ["e72af2871f42cd776be1bfff8009ed5562780e7f8a65b12c27887f3880631f81", "00780c64bd0244c6afb18d786b7512b43e315c376e8f88c67d6ba53d91275ccf", "97053b092e0ff36e3cfb764095fcb4a4fca94e722567e774b53bc118279f40e5", "70fc660e7ed3d70c16c33c62c8e0399b99c3251808f093ef3d4ea5e8d39e4c8a", "28c62c1e28842142607f5168ee79b18fd797900dd66dd9ac5eb3cfcf8a09f781", "b86eae85cf86dbf4409b10f4d8df1f1649fc408ce1a240f7cf26d5bb69aabfd1"], "references/first-edit.md": ["cb1e9d3272b2b22ae095aed02e33067c646da48e7b64cf71ff81197f6e67b673"], "references/memory-and-review.md": ["51a274e522270f1fc295d0c0b9a1c23672ce01abea097c2499c9a8db56c0c289"], "references/search-brief.md": ["137203383a7873593136428520f5f9cd6ce1ab6cfeab365c277175786eba3ab3"], "references/style-creator.md": ["1b1bc289a5bc308cbbf7f4d699de2933b8934733a2b69b3e98fb1b7420d986f2"] };
|
|
989
1120
|
var destinations = {
|
|
990
1121
|
codex: { directory: ".agents/skills/editorbrain", entryPoint: "AGENTS.md" },
|
|
991
1122
|
claude: { directory: ".claude/skills/editorbrain", entryPoint: "CLAUDE.md" }
|
|
@@ -1034,6 +1165,9 @@ async function loadSkill(directory) {
|
|
|
1034
1165
|
if (!files.has("SKILL.md")) throw new Error("Bundled EditorBrain skill is missing SKILL.md. Reinstall the CLI package.");
|
|
1035
1166
|
return files;
|
|
1036
1167
|
}
|
|
1168
|
+
async function bundledGuidanceFiles() {
|
|
1169
|
+
return [...(await loadSkill(bundledSkillDirectory())).keys()];
|
|
1170
|
+
}
|
|
1037
1171
|
async function prepareAgentGuidance(root, agent = "both", sourceDirectory = bundledSkillDirectory()) {
|
|
1038
1172
|
root = await ensureRoot(root);
|
|
1039
1173
|
parseAgentTarget(agent);
|
|
@@ -1175,7 +1309,6 @@ async function cliVersion() {
|
|
|
1175
1309
|
const metadata = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
1176
1310
|
return metadata.version;
|
|
1177
1311
|
}
|
|
1178
|
-
var guidanceFiles = ["SKILL.md", "AGENTS.md", "references/cli-mcp.md", "references/production-and-review.md", "references/workspace-files.md"];
|
|
1179
1312
|
async function diagnose(options, fetcher = fetch) {
|
|
1180
1313
|
const checks = [];
|
|
1181
1314
|
let root;
|
|
@@ -1203,7 +1336,7 @@ async function diagnose(options, fetcher = fetch) {
|
|
|
1203
1336
|
const directory = agent === "codex" ? ".agents/skills/editorbrain" : ".claude/skills/editorbrain";
|
|
1204
1337
|
let different = false;
|
|
1205
1338
|
try {
|
|
1206
|
-
for (const file of
|
|
1339
|
+
for (const file of await bundledGuidanceFiles()) {
|
|
1207
1340
|
const current = await readText(root, `${directory}/${file}`, false);
|
|
1208
1341
|
const bundled = await readFile(resolve2(bundledSkillDirectory(), file), "utf8");
|
|
1209
1342
|
if (current !== bundled) different = true;
|
|
@@ -1345,10 +1478,10 @@ async function setup(options, runner = runHost) {
|
|
|
1345
1478
|
}
|
|
1346
1479
|
|
|
1347
1480
|
// packages/cli/src/index.ts
|
|
1348
|
-
import { chmod, lstat as
|
|
1349
|
-
import { constants as
|
|
1481
|
+
import { chmod, lstat as lstat7, mkdir as mkdir5, open as open7 } from "node:fs/promises";
|
|
1482
|
+
import { constants as constants7 } from "node:fs";
|
|
1350
1483
|
import { homedir } from "node:os";
|
|
1351
|
-
import { basename, dirname as
|
|
1484
|
+
import { basename as basename2, dirname as dirname6, extname as extname3, join as join3, resolve as resolve6 } from "node:path";
|
|
1352
1485
|
|
|
1353
1486
|
// packages/cli/src/device-auth.ts
|
|
1354
1487
|
import { spawn as spawn2 } from "node:child_process";
|
|
@@ -1369,12 +1502,12 @@ function browserCommand(url, platform = process.platform) {
|
|
|
1369
1502
|
}
|
|
1370
1503
|
async function openSystemBrowser(url) {
|
|
1371
1504
|
const { command, args } = browserCommand(url);
|
|
1372
|
-
await new Promise((
|
|
1505
|
+
await new Promise((resolve7, reject) => {
|
|
1373
1506
|
const child = spawn2(command, args, { shell: false, stdio: "ignore", detached: true, windowsHide: true });
|
|
1374
1507
|
child.once("error", reject);
|
|
1375
1508
|
child.once("spawn", () => {
|
|
1376
1509
|
child.unref();
|
|
1377
|
-
|
|
1510
|
+
resolve7();
|
|
1378
1511
|
});
|
|
1379
1512
|
});
|
|
1380
1513
|
}
|
|
@@ -1387,7 +1520,7 @@ function expired() {
|
|
|
1387
1520
|
function invalid() {
|
|
1388
1521
|
return new DeviceLoginError("invalid_response", "The authorization server returned an invalid response. No credential was saved.");
|
|
1389
1522
|
}
|
|
1390
|
-
function
|
|
1523
|
+
function record2(value) {
|
|
1391
1524
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1392
1525
|
}
|
|
1393
1526
|
function abortCheck(signal) {
|
|
@@ -1435,11 +1568,11 @@ async function responseJson(response) {
|
|
|
1435
1568
|
}
|
|
1436
1569
|
}
|
|
1437
1570
|
function sessionFrom(value) {
|
|
1438
|
-
if (!
|
|
1571
|
+
if (!record2(value) || typeof value.deviceCode !== "string" || !/^[a-zA-Z0-9._~-]{16,1024}$/.test(value.deviceCode) || typeof value.userCode !== "string" || !/^[A-Z0-9-]{4,32}$/.test(value.userCode) || typeof value.verificationUri !== "string" || !Number.isInteger(value.expiresIn) || Number(value.expiresIn) < 1 || Number(value.expiresIn) > 3600 || !Number.isInteger(value.interval) || Number(value.interval) < 1 || Number(value.interval) > 60) throw invalid();
|
|
1439
1572
|
return { deviceCode: value.deviceCode, userCode: value.userCode, verificationUri: verificationUrl(value.verificationUri, value.userCode, value.deviceCode), expiresIn: Number(value.expiresIn), interval: Number(value.interval) };
|
|
1440
1573
|
}
|
|
1441
1574
|
function authorizationFrom(value) {
|
|
1442
|
-
if (!
|
|
1575
|
+
if (!record2(value) || typeof value.apiKey !== "string" || !/^eh_[a-f0-9]{64}$/.test(value.apiKey) || value.role !== "editor" || !record2(value.workspace) || typeof value.workspace.id !== "string" || !/^[a-zA-Z0-9_-]{1,100}$/.test(value.workspace.id) || typeof value.workspace.name !== "string" || !value.workspace.name.trim() || value.workspace.name.length > 100) throw invalid();
|
|
1443
1576
|
return { apiKey: value.apiKey, workspace: { id: value.workspace.id, name: value.workspace.name }, role: "editor" };
|
|
1444
1577
|
}
|
|
1445
1578
|
async function loginWithDevice(options, overrides = {}) {
|
|
@@ -1463,7 +1596,7 @@ async function loginWithDevice(options, overrides = {}) {
|
|
|
1463
1596
|
throw new DeviceLoginError("network_error", "The authorization server could not be reached. No credential was saved.");
|
|
1464
1597
|
}
|
|
1465
1598
|
};
|
|
1466
|
-
const
|
|
1599
|
+
const parse2 = async (response) => {
|
|
1467
1600
|
try {
|
|
1468
1601
|
return await responseJson(response);
|
|
1469
1602
|
} catch (error) {
|
|
@@ -1475,7 +1608,7 @@ async function loginWithDevice(options, overrides = {}) {
|
|
|
1475
1608
|
};
|
|
1476
1609
|
const started = await post("/v1/auth/device", {});
|
|
1477
1610
|
if (started.status !== 200 && started.status !== 201) throw new DeviceLoginError("server_error", `Could not start browser authorization (HTTP ${started.status}).`);
|
|
1478
|
-
const session = sessionFrom(await
|
|
1611
|
+
const session = sessionFrom(await parse2(started));
|
|
1479
1612
|
deadline = Math.min(deadline, runtime.now() + session.expiresIn * 1e3);
|
|
1480
1613
|
const { deviceCode, ...challenge } = session;
|
|
1481
1614
|
options.onChallenge?.(challenge);
|
|
@@ -1502,7 +1635,7 @@ async function loginWithDevice(options, overrides = {}) {
|
|
|
1502
1635
|
if (runtime.now() >= deadline) throw expired();
|
|
1503
1636
|
const response = await post("/v1/auth/device/token", { deviceCode });
|
|
1504
1637
|
if (response.status === 200) {
|
|
1505
|
-
const result = authorizationFrom(await
|
|
1638
|
+
const result = authorizationFrom(await parse2(response));
|
|
1506
1639
|
abortCheck(options.signal);
|
|
1507
1640
|
return result;
|
|
1508
1641
|
}
|
|
@@ -1697,14 +1830,14 @@ async function checkLocalResource(root, path, expected) {
|
|
|
1697
1830
|
const before = await handle.stat();
|
|
1698
1831
|
if (!before.isFile()) throw new Error("Resource is not a regular file.");
|
|
1699
1832
|
if (!expected) return {};
|
|
1700
|
-
const
|
|
1701
|
-
for await (const chunk of handle.createReadStream({ autoClose: false }))
|
|
1833
|
+
const hash2 = createHash2("sha256");
|
|
1834
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) hash2.update(chunk);
|
|
1702
1835
|
const after = await handle.stat();
|
|
1703
1836
|
const current = await lstat4(await checkedPath(root, path, false));
|
|
1704
1837
|
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || current.dev !== before.dev || current.ino !== before.ino) {
|
|
1705
1838
|
throw new Error("Resource changed while its hash was being checked; check it again.");
|
|
1706
1839
|
}
|
|
1707
|
-
return { actualSha256:
|
|
1840
|
+
return { actualSha256: hash2.digest("hex") };
|
|
1708
1841
|
} finally {
|
|
1709
1842
|
await handle.close();
|
|
1710
1843
|
}
|
|
@@ -1769,6 +1902,683 @@ async function checkLocalStyle(options) {
|
|
|
1769
1902
|
};
|
|
1770
1903
|
}
|
|
1771
1904
|
|
|
1905
|
+
// packages/cli/src/media-inventory.ts
|
|
1906
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
1907
|
+
import { link, lstat as lstat6, mkdir as mkdir3, mkdtemp, opendir, readFile as readFile2, rm } from "node:fs/promises";
|
|
1908
|
+
import { basename, dirname as dirname4, extname as extname2, join as join2 } from "node:path";
|
|
1909
|
+
|
|
1910
|
+
// packages/cli/src/media-runtime.ts
|
|
1911
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
1912
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
1913
|
+
import { constants as constants5 } from "node:fs";
|
|
1914
|
+
import { lstat as lstat5, open as open5, realpath as realpath2 } from "node:fs/promises";
|
|
1915
|
+
import { dirname as dirname3, extname, parse, resolve as resolve4 } from "node:path";
|
|
1916
|
+
var MediaToolError = class extends CliError {
|
|
1917
|
+
constructor(code, message) {
|
|
1918
|
+
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.");
|
|
1919
|
+
this.name = "MediaToolError";
|
|
1920
|
+
}
|
|
1921
|
+
};
|
|
1922
|
+
var SUPPORTED_MEDIA_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1923
|
+
".mp4",
|
|
1924
|
+
".mov",
|
|
1925
|
+
".m4v",
|
|
1926
|
+
".mkv",
|
|
1927
|
+
".webm",
|
|
1928
|
+
".avi",
|
|
1929
|
+
".mpg",
|
|
1930
|
+
".mpeg",
|
|
1931
|
+
".mts",
|
|
1932
|
+
".m2ts",
|
|
1933
|
+
".ts",
|
|
1934
|
+
".ogv",
|
|
1935
|
+
".wmv",
|
|
1936
|
+
".wav",
|
|
1937
|
+
".mp3",
|
|
1938
|
+
".m4a",
|
|
1939
|
+
".aac",
|
|
1940
|
+
".flac",
|
|
1941
|
+
".ogg",
|
|
1942
|
+
".opus",
|
|
1943
|
+
".aif",
|
|
1944
|
+
".aiff",
|
|
1945
|
+
".ac3",
|
|
1946
|
+
".eac3",
|
|
1947
|
+
".jpg",
|
|
1948
|
+
".jpeg",
|
|
1949
|
+
".png",
|
|
1950
|
+
".webp",
|
|
1951
|
+
".bmp",
|
|
1952
|
+
".tif",
|
|
1953
|
+
".tiff",
|
|
1954
|
+
".gif",
|
|
1955
|
+
".avif",
|
|
1956
|
+
".heic",
|
|
1957
|
+
".heif"
|
|
1958
|
+
]);
|
|
1959
|
+
var PROTOCOLS = "file,pipe";
|
|
1960
|
+
var FORMATS = "mov,matroska,webm,avi,mpeg,mpegts,ogg,wav,flac,mp3,aac,aiff,ac3,eac3,asf,jpeg_pipe,png_pipe,webp_pipe,bmp_pipe,tiff_pipe,gif,heif,avif";
|
|
1961
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
1962
|
+
var DEFAULT_OUTPUT_LIMIT = 2 * 1024 * 1024;
|
|
1963
|
+
function resolveLocalMediaPath(value) {
|
|
1964
|
+
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("\\\\")) {
|
|
1965
|
+
throw new MediaToolError("MEDIA_LOCAL_PATH_REQUIRED", "Provide a local filesystem path, not a URL, protocol or network share.");
|
|
1966
|
+
}
|
|
1967
|
+
return resolve4(value);
|
|
1968
|
+
}
|
|
1969
|
+
async function assertNoMediaSymlinks(value) {
|
|
1970
|
+
const path = resolveLocalMediaPath(value);
|
|
1971
|
+
let cursor = path;
|
|
1972
|
+
while (cursor !== parse(cursor).root) {
|
|
1973
|
+
const info = await lstat5(cursor);
|
|
1974
|
+
if (info.isSymbolicLink()) {
|
|
1975
|
+
const systemAlias = process.platform === "darwin" && (cursor === "/tmp" || cursor === "/var") && await realpath2(cursor) === `/private${cursor}`;
|
|
1976
|
+
if (!systemAlias) throw new MediaToolError("MEDIA_SYMLINK", "Symlink media paths are not followed. Use the original local path.");
|
|
1977
|
+
}
|
|
1978
|
+
cursor = dirname3(cursor);
|
|
1979
|
+
}
|
|
1980
|
+
return path;
|
|
1981
|
+
}
|
|
1982
|
+
async function assertLocalMediaFile(value) {
|
|
1983
|
+
const path = await assertNoMediaSymlinks(value);
|
|
1984
|
+
const info = await lstat5(path);
|
|
1985
|
+
if (!info.isFile()) throw new MediaToolError("MEDIA_NOT_REGULAR", "The media input must be a regular local file.");
|
|
1986
|
+
if (!SUPPORTED_MEDIA_EXTENSIONS.has(extname(path).toLowerCase())) throw new MediaToolError("MEDIA_FORMAT_UNSUPPORTED", "This file extension is not supported for local media inspection.");
|
|
1987
|
+
return path;
|
|
1988
|
+
}
|
|
1989
|
+
function localInputArgs(path) {
|
|
1990
|
+
return ["-protocol_whitelist", PROTOCOLS, "-format_whitelist", FORMATS, "-i", resolveLocalMediaPath(path)];
|
|
1991
|
+
}
|
|
1992
|
+
async function runMediaTool(tool, args, options = {}) {
|
|
1993
|
+
if (tool !== "ffmpeg" && tool !== "ffprobe") throw new MediaToolError("MEDIA_TOOL_INVALID", "Unsupported media executable.");
|
|
1994
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
1995
|
+
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_OUTPUT_LIMIT;
|
|
1996
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10 * 6e4 || !Number.isInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 16 * 1024 * 1024) {
|
|
1997
|
+
throw new MediaToolError("MEDIA_TOOL_LIMITS_INVALID", "Media runtime limits are outside the supported range.");
|
|
1998
|
+
}
|
|
1999
|
+
if (!Array.isArray(args) || args.length > 200 || args.some((arg) => typeof arg !== "string" || arg.length > 8192 || arg.includes("\0"))) {
|
|
2000
|
+
throw new MediaToolError("MEDIA_TOOL_ARGUMENTS_INVALID", "Invalid media tool arguments.");
|
|
2001
|
+
}
|
|
2002
|
+
for (let i = 0; i < args.length; i++) {
|
|
2003
|
+
const arg = args[i];
|
|
2004
|
+
if (/(?:^|[=,;\s])(?:https?|ftp|sftp|tcp|udp|rtsp|rtmp|smb|data|concat|subfile|crypto):/i.test(arg) || arg === "-safe" && args[i + 1] === "0" || arg === "-enable_drefs" && args[i + 1] !== "0") {
|
|
2005
|
+
throw new MediaToolError("MEDIA_REMOTE_INPUT_FORBIDDEN", "Only self-contained local media inputs are allowed.");
|
|
2006
|
+
}
|
|
2007
|
+
if (arg === "-protocol_whitelist" && args[i + 1] !== PROTOCOLS || arg === "-format_whitelist" && args[i + 1] !== FORMATS) {
|
|
2008
|
+
throw new MediaToolError("MEDIA_TOOL_ARGUMENTS_INVALID", "The local media allowlists cannot be widened.");
|
|
2009
|
+
}
|
|
2010
|
+
if (arg === "-i") {
|
|
2011
|
+
await assertLocalMediaFile(args[i + 1] ?? "");
|
|
2012
|
+
if (args[i - 4] !== "-protocol_whitelist" || args[i - 3] !== PROTOCOLS || args[i - 2] !== "-format_whitelist" || args[i - 1] !== FORMATS) {
|
|
2013
|
+
throw new MediaToolError("MEDIA_TOOL_ARGUMENTS_INVALID", "Every media input must use localInputArgs.");
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
return new Promise((resolveResult, reject) => {
|
|
2018
|
+
const stdout = [], stderr = [];
|
|
2019
|
+
let size = 0, done = false;
|
|
2020
|
+
const child = spawn3(tool, ["-hide_banner", ...tool === "ffmpeg" ? ["-nostdin"] : [], ...args], { shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
2021
|
+
const fail = (error) => {
|
|
2022
|
+
if (done) return;
|
|
2023
|
+
done = true;
|
|
2024
|
+
clearTimeout(timer);
|
|
2025
|
+
child.kill("SIGKILL");
|
|
2026
|
+
reject(error);
|
|
2027
|
+
};
|
|
2028
|
+
const timer = setTimeout(() => fail(new MediaToolError("MEDIA_TOOL_TIMEOUT", "The local media check exceeded its time limit. No complete result is available.")), timeoutMs);
|
|
2029
|
+
const collect = (target) => (chunk) => {
|
|
2030
|
+
if (done) return;
|
|
2031
|
+
size += chunk.length;
|
|
2032
|
+
if (size > maxOutputBytes) return fail(new MediaToolError("MEDIA_TOOL_OUTPUT_LIMIT", "The local media check exceeded its output limit. No complete result is available."));
|
|
2033
|
+
target.push(chunk);
|
|
2034
|
+
};
|
|
2035
|
+
child.stdout.on("data", collect(stdout));
|
|
2036
|
+
child.stderr.on("data", collect(stderr));
|
|
2037
|
+
child.on("error", (error) => fail(new MediaToolError(error.code === "ENOENT" ? "MEDIA_TOOL_NOT_FOUND" : "MEDIA_TOOL_FAILED", error.code === "ENOENT" ? `${tool} is not installed or available on PATH. Install it separately to use local media checks.` : "The local media executable could not run.")));
|
|
2038
|
+
child.on("close", (code) => {
|
|
2039
|
+
if (done) return;
|
|
2040
|
+
done = true;
|
|
2041
|
+
clearTimeout(timer);
|
|
2042
|
+
if (code !== 0) return reject(new MediaToolError("MEDIA_TOOL_FAILED", "The local media tool could not inspect or decode this input. No complete result is available."));
|
|
2043
|
+
resolveResult({ stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
|
|
2044
|
+
});
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
var finite2 = (value) => {
|
|
2048
|
+
if (typeof value !== "number" && typeof value !== "string" || value === "") return null;
|
|
2049
|
+
const n = Number(value);
|
|
2050
|
+
return Number.isFinite(n) ? n : null;
|
|
2051
|
+
};
|
|
2052
|
+
var nonnegative2 = (value) => {
|
|
2053
|
+
const n = finite2(value);
|
|
2054
|
+
return n !== null && n >= 0 ? n : null;
|
|
2055
|
+
};
|
|
2056
|
+
var positiveInteger = (value) => {
|
|
2057
|
+
const n = finite2(value);
|
|
2058
|
+
return n !== null && Number.isInteger(n) && n > 0 ? n : void 0;
|
|
2059
|
+
};
|
|
2060
|
+
var rate = (value) => {
|
|
2061
|
+
if (typeof value !== "string" || !/^\d+(?:\/\d+)?$/.test(value)) return null;
|
|
2062
|
+
const [n, d = "1"] = value.split("/");
|
|
2063
|
+
const result = Number(n) / Number(d);
|
|
2064
|
+
return Number.isFinite(result) && result > 0 ? result : null;
|
|
2065
|
+
};
|
|
2066
|
+
var sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
2067
|
+
async function probeLocalMedia(value) {
|
|
2068
|
+
const path = await assertLocalMediaFile(value);
|
|
2069
|
+
const handle = await open5(path, constants5.O_RDONLY | (constants5.O_NOFOLLOW ?? 0));
|
|
2070
|
+
try {
|
|
2071
|
+
const before = await handle.stat();
|
|
2072
|
+
if (!before.isFile()) throw new MediaToolError("MEDIA_NOT_REGULAR", "The media input must be a regular local file.");
|
|
2073
|
+
const entries = "format=duration,start_time:stream=index,codec_type,codec_name,width,height,avg_frame_rate,sample_rate,channels,duration:stream_disposition=attached_pic";
|
|
2074
|
+
const { stdout } = await runMediaTool("ffprobe", ["-v", "error", "-show_entries", entries, "-of", "json", ...localInputArgs(path)]);
|
|
2075
|
+
const hash2 = createHash3("sha256");
|
|
2076
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) hash2.update(chunk);
|
|
2077
|
+
const after = await handle.stat();
|
|
2078
|
+
await assertNoMediaSymlinks(path);
|
|
2079
|
+
const current = await lstat5(path);
|
|
2080
|
+
if (!sameFile(before, after) || !sameFile(after, current)) throw new MediaToolError("MEDIA_FILE_CHANGED", "The media file changed while it was being inspected. Retry with a stable source.");
|
|
2081
|
+
let raw;
|
|
2082
|
+
try {
|
|
2083
|
+
raw = JSON.parse(stdout);
|
|
2084
|
+
} catch {
|
|
2085
|
+
throw new MediaToolError("MEDIA_PROBE_INVALID", "The media probe returned invalid metadata.");
|
|
2086
|
+
}
|
|
2087
|
+
if (!raw || !Array.isArray(raw.streams) || raw.streams.length > 256 || raw.streams.some((stream) => !stream || typeof stream !== "object" || Array.isArray(stream))) throw new MediaToolError("MEDIA_PROBE_INVALID", "The media probe returned invalid or excessive stream metadata.");
|
|
2088
|
+
const streams = raw.streams.map((stream, offset) => {
|
|
2089
|
+
const type = typeof stream.codec_type === "string" && /^(video|audio|subtitle|data|attachment|unknown)$/.test(stream.codec_type) ? stream.codec_type : "unknown";
|
|
2090
|
+
const codec = typeof stream.codec_name === "string" && /^[a-zA-Z0-9_]{1,64}$/.test(stream.codec_name) ? stream.codec_name : null;
|
|
2091
|
+
const index = finite2(stream.index);
|
|
2092
|
+
return {
|
|
2093
|
+
index: index !== null && Number.isInteger(index) && index >= 0 ? index : offset,
|
|
2094
|
+
type,
|
|
2095
|
+
codec,
|
|
2096
|
+
durationSeconds: nonnegative2(stream.duration),
|
|
2097
|
+
...type === "video" ? { width: positiveInteger(stream.width), height: positiveInteger(stream.height), fps: rate(stream.avg_frame_rate), attachedPicture: stream.disposition?.attached_pic === 1 } : {},
|
|
2098
|
+
...type === "audio" ? { sampleRate: nonnegative2(stream.sample_rate), channels: positiveInteger(stream.channels) ?? null } : {}
|
|
2099
|
+
};
|
|
2100
|
+
});
|
|
2101
|
+
return { path, sha256: hash2.digest("hex"), bytes: after.size, durationSeconds: nonnegative2(raw.format?.duration), startTimeSeconds: finite2(raw.format?.start_time), streams };
|
|
2102
|
+
} finally {
|
|
2103
|
+
await handle.close();
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// packages/cli/src/media-inventory.ts
|
|
2108
|
+
var GENERATED_DIRECTORIES = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "output", "outputs", "renders", "render", "cache", "caches", "proxies", "proxy", "samples", "thumbnails", "coverage", "venv", "__pycache__"]);
|
|
2109
|
+
function mediaFailure(error) {
|
|
2110
|
+
if (error instanceof MediaToolError) return { code: error.code, message: error.message };
|
|
2111
|
+
const code = error?.code;
|
|
2112
|
+
if (code === "ENOENT") return { code: "MEDIA_FILE_MISSING", message: "The local path is missing or changed during inspection." };
|
|
2113
|
+
if (code === "EACCES" || code === "EPERM") return { code: "MEDIA_FILE_UNREADABLE", message: "The local path cannot be read with the current filesystem permissions." };
|
|
2114
|
+
return { code: "MEDIA_INSPECTION_FAILED", message: "Local media inspection failed. No complete result is available for this path." };
|
|
2115
|
+
}
|
|
2116
|
+
async function inventoryMedia(options) {
|
|
2117
|
+
const maxFiles = options.maxFiles ?? 200;
|
|
2118
|
+
if (!Number.isInteger(maxFiles) || maxFiles < 1 || maxFiles > 1e3) throw new MediaToolError("MEDIA_LIMIT_INVALID", "maxFiles must be an integer between 1 and 1000.");
|
|
2119
|
+
const input = resolveLocalMediaPath(options.input);
|
|
2120
|
+
const report = { schemaVersion: 1, kind: "editorbrain.media-inventory", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), assets: [], failures: [], skipped: [], complete: true, scannedEntries: 0, limits: { maxFiles, maxEntries: 1e4, maxDepth: 32 }, contentReviewed: false, mediaUploaded: false };
|
|
2121
|
+
const candidates = [];
|
|
2122
|
+
const skip = (path, reason, incomplete = false) => {
|
|
2123
|
+
report.skipped.push({ path, reason });
|
|
2124
|
+
if (incomplete) report.complete = false;
|
|
2125
|
+
};
|
|
2126
|
+
const visit = async (path, depth, explicit = false) => {
|
|
2127
|
+
if (report.scannedEntries >= report.limits.maxEntries) {
|
|
2128
|
+
skip(path, "entry_limit_reached", true);
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
report.scannedEntries++;
|
|
2132
|
+
try {
|
|
2133
|
+
const stat = await lstat6(path);
|
|
2134
|
+
if (stat.isSymbolicLink()) {
|
|
2135
|
+
if (explicit) throw new MediaToolError("MEDIA_SYMLINK", "Symlink inputs are not followed. Use the original local path.");
|
|
2136
|
+
skip(path, "symlink_not_followed");
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
if (!explicit && basename(path).startsWith(".")) {
|
|
2140
|
+
skip(path, "hidden_entry");
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
await assertNoMediaSymlinks(path);
|
|
2144
|
+
if (stat.isDirectory()) {
|
|
2145
|
+
if (!explicit && GENERATED_DIRECTORIES.has(basename(path).toLowerCase())) {
|
|
2146
|
+
skip(path, "generated_directory");
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
if (depth >= report.limits.maxDepth) {
|
|
2150
|
+
skip(path, "depth_limit_reached", true);
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
const directory = await opendir(path);
|
|
2154
|
+
for await (const entry of directory) {
|
|
2155
|
+
if (report.scannedEntries >= report.limits.maxEntries) {
|
|
2156
|
+
skip(path, "remaining_entries_not_scanned", true);
|
|
2157
|
+
break;
|
|
2158
|
+
}
|
|
2159
|
+
await visit(join2(path, entry.name), depth + 1);
|
|
2160
|
+
}
|
|
2161
|
+
} else if (!stat.isFile()) {
|
|
2162
|
+
if (explicit) throw new MediaToolError("MEDIA_NOT_REGULAR", "The explicit media input must be a regular local file or directory.");
|
|
2163
|
+
skip(path, "not_regular_file");
|
|
2164
|
+
} else if (!SUPPORTED_MEDIA_EXTENSIONS.has(extname2(path).toLowerCase())) {
|
|
2165
|
+
if (explicit) throw new MediaToolError("MEDIA_FORMAT_UNSUPPORTED", "This explicit input is not a supported media format.");
|
|
2166
|
+
skip(path, "unsupported_extension");
|
|
2167
|
+
} else if (candidates.length >= maxFiles) skip(path, "file_limit_reached", true);
|
|
2168
|
+
else candidates.push(path);
|
|
2169
|
+
} catch (error) {
|
|
2170
|
+
report.failures.push({ path, ...mediaFailure(error) });
|
|
2171
|
+
report.complete = false;
|
|
2172
|
+
}
|
|
2173
|
+
};
|
|
2174
|
+
await visit(input, 0, true);
|
|
2175
|
+
for (const path of candidates.sort()) {
|
|
2176
|
+
try {
|
|
2177
|
+
report.assets.push(await probeLocalMedia(path));
|
|
2178
|
+
} catch (error) {
|
|
2179
|
+
report.failures.push({ path, ...mediaFailure(error) });
|
|
2180
|
+
report.complete = false;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
return report;
|
|
2184
|
+
}
|
|
2185
|
+
async function sampleMedia(options) {
|
|
2186
|
+
if (!Array.isArray(options.times) || options.times.length < 1 || options.times.length > 12 || options.times.some((time) => typeof time !== "number" || !Number.isFinite(time) || time < 0 || time > 86400)) {
|
|
2187
|
+
throw new MediaToolError("MEDIA_SAMPLE_TIMES_INVALID", "Provide 1\u201312 sample times in seconds, between 0 and 86400.");
|
|
2188
|
+
}
|
|
2189
|
+
const file = await assertLocalMediaFile(options.file);
|
|
2190
|
+
const source = await probeLocalMedia(file);
|
|
2191
|
+
if (!source.streams.some((stream) => stream.type === "video")) throw new MediaToolError("MEDIA_VIDEO_REQUIRED", "Frame sampling requires a video or image stream.");
|
|
2192
|
+
if (source.durationSeconds !== null && options.times.some((time) => time >= source.durationSeconds)) throw new MediaToolError("MEDIA_SAMPLE_OUT_OF_RANGE", "A requested sample is outside the known media duration.");
|
|
2193
|
+
const outputDirectory = resolveLocalMediaPath(options.outputDirectory);
|
|
2194
|
+
await assertNoMediaSymlinks(dirname4(outputDirectory));
|
|
2195
|
+
try {
|
|
2196
|
+
await mkdir3(outputDirectory);
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
if (error.code !== "EEXIST") throw error;
|
|
2199
|
+
}
|
|
2200
|
+
await assertNoMediaSymlinks(outputDirectory);
|
|
2201
|
+
if (!(await lstat6(outputDirectory)).isDirectory()) throw new MediaToolError("MEDIA_OUTPUT_DIRECTORY_INVALID", "Frame output must be a local directory.");
|
|
2202
|
+
const temp = await mkdtemp(join2(outputDirectory, ".editorbrain-samples-"));
|
|
2203
|
+
const tempStat = await lstat6(temp);
|
|
2204
|
+
const frames = [];
|
|
2205
|
+
const created = [];
|
|
2206
|
+
try {
|
|
2207
|
+
for (let index = 0; index < options.times.length; index++) {
|
|
2208
|
+
const time = options.times[index];
|
|
2209
|
+
const filename = `${source.sha256.slice(0, 12)}-${String(index + 1).padStart(2, "0")}-${Math.round(time * 1e3)}ms.jpg`;
|
|
2210
|
+
const temporary = join2(temp, filename), output2 = join2(outputDirectory, filename);
|
|
2211
|
+
await runMediaTool("ffmpeg", ["-v", "error", "-n", "-ss", String(time), ...localInputArgs(file), "-map", "0:v:0", "-frames:v", "1", "-an", "-sn", "-dn", "-vf", "scale=960:960:force_original_aspect_ratio=decrease", "-q:v", "3", "-update", "1", temporary], { timeoutMs: 6e4 });
|
|
2212
|
+
const bytes = await readFile2(temporary);
|
|
2213
|
+
if (!bytes.length) throw new MediaToolError("MEDIA_SAMPLE_EMPTY", "The requested frame did not produce a usable image.");
|
|
2214
|
+
try {
|
|
2215
|
+
await link(temporary, output2);
|
|
2216
|
+
} catch (error) {
|
|
2217
|
+
if (error.code === "EEXIST") throw new MediaToolError("MEDIA_OUTPUT_EXISTS", "A sample output already exists. Choose a new output directory; existing files are never overwritten.");
|
|
2218
|
+
throw error;
|
|
2219
|
+
}
|
|
2220
|
+
const outputStat = await lstat6(output2);
|
|
2221
|
+
created.push({ path: output2, ino: outputStat.ino, dev: outputStat.dev, size: outputStat.size, mtimeMs: outputStat.mtimeMs, ctimeMs: outputStat.ctimeMs });
|
|
2222
|
+
frames.push({ path: output2, sha256: createHash4("sha256").update(bytes).digest("hex"), bytes: bytes.length, requestedTimeSeconds: time, actualPresentationTimeSeconds: null });
|
|
2223
|
+
}
|
|
2224
|
+
const after = await probeLocalMedia(file);
|
|
2225
|
+
if (source.sha256 !== after.sha256) throw new MediaToolError("MEDIA_FILE_CHANGED", "The source changed while frames were being sampled. Retry with a stable source.");
|
|
2226
|
+
return { schemaVersion: 1, kind: "editorbrain.media-samples", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), source, frames, metadataOnly: false, contentReviewed: false, mediaUploaded: false };
|
|
2227
|
+
} catch (error) {
|
|
2228
|
+
for (const file2 of created) {
|
|
2229
|
+
try {
|
|
2230
|
+
await assertNoMediaSymlinks(file2.path);
|
|
2231
|
+
const current = await lstat6(file2.path);
|
|
2232
|
+
if (current.ino === file2.ino && current.dev === file2.dev && current.size === file2.size && current.mtimeMs === file2.mtimeMs && current.ctimeMs === file2.ctimeMs) await rm(file2.path, { force: true });
|
|
2233
|
+
} catch {
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
throw error;
|
|
2237
|
+
} finally {
|
|
2238
|
+
try {
|
|
2239
|
+
await assertNoMediaSymlinks(temp);
|
|
2240
|
+
const current = await lstat6(temp);
|
|
2241
|
+
if (current.ino === tempStat.ino && current.dev === tempStat.dev) await rm(temp, { recursive: true, force: true });
|
|
2242
|
+
} catch {
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// packages/cli/src/render-check.ts
|
|
2248
|
+
import { resolve as resolve5 } from "node:path";
|
|
2249
|
+
function invalidProfile(message) {
|
|
2250
|
+
throw new CliError("RENDER_PROFILE_INVALID", message, "Use a schemaVersion: 1 render profile with explicit technical requirements.");
|
|
2251
|
+
}
|
|
2252
|
+
function object3(value, path, keys) {
|
|
2253
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) invalidProfile(`${path} must be an object.`);
|
|
2254
|
+
const result = value;
|
|
2255
|
+
for (const key of Object.keys(result)) if (!keys.includes(key)) invalidProfile(`${path} contains unsupported field ${key}.`);
|
|
2256
|
+
return result;
|
|
2257
|
+
}
|
|
2258
|
+
function validateRenderCheckProfile(value) {
|
|
2259
|
+
const root = object3(value, "profile", ["schemaVersion", "expected", "tolerance", "audio", "silence", "black", "peak", "limits"]);
|
|
2260
|
+
if (root.schemaVersion !== 1) invalidProfile("profile.schemaVersion must be 1.");
|
|
2261
|
+
const expected = root.expected === void 0 ? {} : object3(root.expected, "expected", ["width", "height", "fps", "durationSeconds"]);
|
|
2262
|
+
const tolerance = root.tolerance === void 0 ? {} : object3(root.tolerance, "tolerance", ["fps", "durationSeconds"]);
|
|
2263
|
+
const silence = root.silence === void 0 ? {} : object3(root.silence, "silence", ["enabled", "minDurationSeconds", "thresholdDb"]);
|
|
2264
|
+
const black = root.black === void 0 ? {} : object3(root.black, "black", ["enabled", "minDurationSeconds", "pixelThreshold", "pictureBlackRatio"]);
|
|
2265
|
+
const peak = root.peak === void 0 ? {} : object3(root.peak, "peak", ["enabled", "thresholdDbFS"]);
|
|
2266
|
+
const limits = root.limits === void 0 ? {} : object3(root.limits, "limits", ["timeoutMs", "maxOutputBytes"]);
|
|
2267
|
+
const number = (v, name, min, max, fallback, integer = false) => {
|
|
2268
|
+
if (v === void 0 && fallback !== void 0) return fallback;
|
|
2269
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < min || v > max || integer && !Number.isInteger(v)) invalidProfile(`${name} must be ${integer ? "an integer" : "a finite number"} between ${min} and ${max}.`);
|
|
2270
|
+
return v;
|
|
2271
|
+
};
|
|
2272
|
+
const enabled = (v, name) => {
|
|
2273
|
+
if (v === void 0) return true;
|
|
2274
|
+
if (typeof v !== "boolean") invalidProfile(`${name} must be a boolean.`);
|
|
2275
|
+
return v;
|
|
2276
|
+
};
|
|
2277
|
+
if (root.audio !== void 0 && !["required", "optional", "forbidden"].includes(root.audio)) invalidProfile("audio must be required, optional or forbidden.");
|
|
2278
|
+
const validatedExpected = {};
|
|
2279
|
+
for (const key of ["width", "height"]) if (expected[key] !== void 0) validatedExpected[key] = number(expected[key], `expected.${key}`, 1, 32768, void 0, true);
|
|
2280
|
+
if (expected.fps !== void 0) validatedExpected.fps = number(expected.fps, "expected.fps", 1e-6, 1e3);
|
|
2281
|
+
if (expected.durationSeconds !== void 0) validatedExpected.durationSeconds = number(expected.durationSeconds, "expected.durationSeconds", 1e-6, 864e4);
|
|
2282
|
+
return {
|
|
2283
|
+
schemaVersion: 1,
|
|
2284
|
+
expected: validatedExpected,
|
|
2285
|
+
tolerance: { fps: number(tolerance.fps, "tolerance.fps", 0, 1e3, 0.01), durationSeconds: number(tolerance.durationSeconds, "tolerance.durationSeconds", 0, 86400, 0.1) },
|
|
2286
|
+
audio: root.audio ?? "optional",
|
|
2287
|
+
silence: { enabled: enabled(silence.enabled, "silence.enabled"), minDurationSeconds: number(silence.minDurationSeconds, "silence.minDurationSeconds", 1e-3, 86400, 0.75), thresholdDb: number(silence.thresholdDb, "silence.thresholdDb", -150, 0, -50) },
|
|
2288
|
+
black: { enabled: enabled(black.enabled, "black.enabled"), minDurationSeconds: number(black.minDurationSeconds, "black.minDurationSeconds", 1e-3, 86400, 0.15), pixelThreshold: number(black.pixelThreshold, "black.pixelThreshold", 0, 1, 0.1), pictureBlackRatio: number(black.pictureBlackRatio, "black.pictureBlackRatio", 0, 1, 0.98) },
|
|
2289
|
+
peak: { enabled: enabled(peak.enabled, "peak.enabled"), thresholdDbFS: number(peak.thresholdDbFS, "peak.thresholdDbFS", -150, 0, -0.1) },
|
|
2290
|
+
limits: { timeoutMs: number(limits.timeoutMs, "limits.timeoutMs", 1, 6e5, 6e5, true), maxOutputBytes: number(limits.maxOutputBytes, "limits.maxOutputBytes", 1024, 16777216, 4194304, true) }
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
function errorCode(error) {
|
|
2294
|
+
if (error && typeof error === "object" && "code" in error && typeof error.code === "string" && /^[A-Z_0-9]+$/.test(error.code)) return error.code;
|
|
2295
|
+
return "MEDIA_CHECK_FAILED";
|
|
2296
|
+
}
|
|
2297
|
+
function addCheck(report, check) {
|
|
2298
|
+
report.checks.push(check);
|
|
2299
|
+
}
|
|
2300
|
+
function addFinding(report, finding) {
|
|
2301
|
+
report.findingsSummary.total++;
|
|
2302
|
+
report.findingsSummary.byKind[finding.kind]++;
|
|
2303
|
+
if (report.findings.length < 1e3) report.findings.push(finding);
|
|
2304
|
+
else {
|
|
2305
|
+
report.findingsSummary.truncated = true;
|
|
2306
|
+
report.complete = false;
|
|
2307
|
+
}
|
|
2308
|
+
report.findingsSummary.returned = report.findings.length;
|
|
2309
|
+
}
|
|
2310
|
+
function finish(report) {
|
|
2311
|
+
report.technicalStatus = report.checks.some((check) => check.status === "fail") ? "fail" : !report.complete ? "incomplete" : report.checks.some((check) => check.status === "warning") ? "warning" : "pass";
|
|
2312
|
+
return report;
|
|
2313
|
+
}
|
|
2314
|
+
function checkExpected(report, probe, profile) {
|
|
2315
|
+
const expected = profile.expected;
|
|
2316
|
+
if (!Object.keys(expected).length) return addCheck(report, { id: "expected_format", status: "not_applicable", detail: "No target dimensions, frame rate or duration were specified." });
|
|
2317
|
+
const video = probe.streams.find((stream) => stream.type === "video" && !stream.attachedPicture);
|
|
2318
|
+
const mismatches = [];
|
|
2319
|
+
const unknown = [];
|
|
2320
|
+
for (const key of ["width", "height", "fps"]) {
|
|
2321
|
+
const target = expected[key];
|
|
2322
|
+
if (target === void 0) continue;
|
|
2323
|
+
if (!video) {
|
|
2324
|
+
mismatches.push(`Expected ${key}, but the file has no video stream.`);
|
|
2325
|
+
continue;
|
|
2326
|
+
}
|
|
2327
|
+
const actual = video[key];
|
|
2328
|
+
if (actual === void 0 || actual === null || actual <= 0) unknown.push(`Could not measure ${key}.`);
|
|
2329
|
+
else if (Math.abs(actual - target) > (key === "fps" ? profile.tolerance.fps : 0)) mismatches.push(`${key}: measured ${actual}, expected ${target}.`);
|
|
2330
|
+
}
|
|
2331
|
+
if (expected.durationSeconds !== void 0) {
|
|
2332
|
+
if (probe.durationSeconds === null) unknown.push("Could not measure container duration.");
|
|
2333
|
+
else if (Math.abs(probe.durationSeconds - expected.durationSeconds) > profile.tolerance.durationSeconds) mismatches.push(`durationSeconds: measured ${probe.durationSeconds}, expected ${expected.durationSeconds}.`);
|
|
2334
|
+
}
|
|
2335
|
+
if (unknown.length) report.complete = false;
|
|
2336
|
+
addCheck(report, { id: "expected_format", status: mismatches.length ? "fail" : unknown.length ? "not_checked" : "pass", detail: [...mismatches, ...unknown].join(" ") || "Explicit requirements matched; dimensions and reported average frame rate refer to the first video stream excluding attached cover art." });
|
|
2337
|
+
}
|
|
2338
|
+
async function toolVersion(tool) {
|
|
2339
|
+
try {
|
|
2340
|
+
const result = await runMediaTool(tool, ["-version"], { timeoutMs: 1e4, maxOutputBytes: 65536 });
|
|
2341
|
+
return { available: true, version: new RegExp(`^${tool} version ([A-Za-z0-9._+~-]+)`, "m").exec(result.stdout)?.[1] ?? null };
|
|
2342
|
+
} catch {
|
|
2343
|
+
return { available: false, version: null };
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
function readDetectorOutput(report, stderr, audioStreams) {
|
|
2347
|
+
const samples = /* @__PURE__ */ new Map();
|
|
2348
|
+
for (const stream of audioStreams) samples.set(stream.index, { streamIndex: stream.index, sampleCount: null, samplePeakDbFS: null, peakState: "unavailable" });
|
|
2349
|
+
const pendingSilence = /* @__PURE__ */ new Map();
|
|
2350
|
+
for (const line of stderr.split("\n")) {
|
|
2351
|
+
const tag = /\[(blackdetect@v|silencedetect@a|astats@a)(\d+)\s+@/.exec(line);
|
|
2352
|
+
if (!tag) continue;
|
|
2353
|
+
const streamIndex = Number(tag[2]);
|
|
2354
|
+
if (tag[1] === "blackdetect@v") {
|
|
2355
|
+
const match = /black_start:([\d.e+-]+)\s+black_end:([\d.e+-]+)\s+black_duration:([\d.e+-]+)/i.exec(line);
|
|
2356
|
+
if (match && match.slice(1).every((value) => Number.isFinite(Number(value)))) addFinding(report, { kind: "black_candidate", streamIndex, startSeconds: Number(match[1]), endSeconds: Number(match[2]), durationSeconds: Number(match[3]), requiresReview: true, message: "A mostly black interval met the configured detector threshold. It may be intentional; review in context." });
|
|
2357
|
+
} else if (tag[1] === "silencedetect@a") {
|
|
2358
|
+
const start = /silence_start:\s*([\d.e+-]+)/i.exec(line);
|
|
2359
|
+
if (start && Number.isFinite(Number(start[1]))) pendingSilence.set(streamIndex, Number(start[1]));
|
|
2360
|
+
const end = /silence_end:\s*([\d.e+-]+)\s*\|\s*silence_duration:\s*([\d.e+-]+)/i.exec(line);
|
|
2361
|
+
if (end && end.slice(1).every((value) => Number.isFinite(Number(value)))) {
|
|
2362
|
+
addFinding(report, { kind: "silence_candidate", streamIndex, startSeconds: pendingSilence.get(streamIndex) ?? Number(end[1]) - Number(end[2]), endSeconds: Number(end[1]), durationSeconds: Number(end[2]), requiresReview: true, message: "A quiet interval met the configured detector threshold. Pauses and silent edits may be intentional." });
|
|
2363
|
+
pendingSilence.delete(streamIndex);
|
|
2364
|
+
}
|
|
2365
|
+
} else {
|
|
2366
|
+
const sample = samples.get(streamIndex);
|
|
2367
|
+
if (!sample) continue;
|
|
2368
|
+
const peak = /Peak level dB:\s*(-inf|[\d.e+-]+)/i.exec(line);
|
|
2369
|
+
if (peak?.[1] === "-inf") sample.peakState = "negative_infinity";
|
|
2370
|
+
else if (peak && Number.isFinite(Number(peak[1]))) {
|
|
2371
|
+
sample.peakState = "measured";
|
|
2372
|
+
sample.samplePeakDbFS = Number(peak[1]);
|
|
2373
|
+
}
|
|
2374
|
+
const count = /Number of samples:\s*([\d.e+-]+)/i.exec(line);
|
|
2375
|
+
if (count && Number.isFinite(Number(count[1]))) sample.sampleCount = Number(count[1]);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
report.measurements.audio = [...samples.values()];
|
|
2379
|
+
if (pendingSilence.size) {
|
|
2380
|
+
report.complete = false;
|
|
2381
|
+
addCheck(report, { id: "silence", status: "not_checked", code: "DETECTOR_OUTPUT_INCOMPLETE", detail: "At least one detected silence interval had no closing timestamp; results are incomplete." });
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
async function checkRender(options) {
|
|
2385
|
+
const profile = validateRenderCheckProfile(options.profile ?? { schemaVersion: 1 });
|
|
2386
|
+
const report = {
|
|
2387
|
+
schemaVersion: 1,
|
|
2388
|
+
kind: "editorbrain.render-check",
|
|
2389
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2390
|
+
mediaUploaded: false,
|
|
2391
|
+
file: { path: resolve5(options.file), sha256: null, bytes: null },
|
|
2392
|
+
effectiveProfile: profile,
|
|
2393
|
+
complete: true,
|
|
2394
|
+
technicalStatus: "incomplete",
|
|
2395
|
+
measurements: { probe: null, decodedThroughSeconds: null, audio: [] },
|
|
2396
|
+
capabilities: { ffprobe: { available: false, version: null }, ffmpeg: { available: false, version: null }, decodeScope: "all_audio_video_streams", timestampBasis: "seconds_from_container_start", peakMethod: "sample_peak_whole_mix_per_audio_stream", truePeak: "not_assessed", loudness: "not_assessed", repeatedFootage: "not_assessed", subtitleStreams: "not_assessed" },
|
|
2397
|
+
checks: [],
|
|
2398
|
+
findings: [],
|
|
2399
|
+
findingsSummary: { total: 0, returned: 0, truncated: false, byKind: { silence_candidate: 0, black_candidate: 0, peak_candidate: 0 } },
|
|
2400
|
+
creativeQuality: { narrative: "not_assessed", readability: "not_assessed", sfxAudibility: "not_assessed", style: "not_assessed" },
|
|
2401
|
+
limitations: [
|
|
2402
|
+
"This report is a technical inspection of the identified file, not approval of its editing or creative quality.",
|
|
2403
|
+
"Default detector thresholds are sensitivities, not requirements for a platform, niche, style, music, pacing or frame rate.",
|
|
2404
|
+
"Silence, black and peak findings require contextual review; no automatic cuts or audio changes are performed.",
|
|
2405
|
+
"Whole-mix sample peaks cannot establish SFX audibility, true-peak clipping, speech clarity or perceived loudness.",
|
|
2406
|
+
"Frame rate is container-reported average; constant frame cadence, HDR, color appearance, rotation and subtitle rendering are not certified.",
|
|
2407
|
+
"Black interval boundaries follow decoded frame timestamps and may differ from presentation duration by a frame.",
|
|
2408
|
+
"Attached cover art is decoded but excluded from video requirements and black-interval detection.",
|
|
2409
|
+
"Reports retain at most 1,000 timestamped findings; exceeding this cap marks the result incomplete and preserves total counts."
|
|
2410
|
+
]
|
|
2411
|
+
};
|
|
2412
|
+
let probe;
|
|
2413
|
+
try {
|
|
2414
|
+
probe = await probeLocalMedia(options.file);
|
|
2415
|
+
report.file = { path: probe.path, sha256: probe.sha256, bytes: probe.bytes };
|
|
2416
|
+
report.measurements.probe = probe;
|
|
2417
|
+
report.capabilities.ffprobe.available = true;
|
|
2418
|
+
addCheck(report, { id: "probe", status: "pass", detail: "Read stream metadata and SHA-256 from the local file." });
|
|
2419
|
+
} catch (error) {
|
|
2420
|
+
report.complete = false;
|
|
2421
|
+
addCheck(report, { id: "probe", status: "not_checked", code: errorCode(error), detail: "The local file could not be probed. No successful inspection is implied." });
|
|
2422
|
+
for (const id of ["expected_format", "audio_requirement", "decode", "silence", "black", "peak", "file_stability"]) addCheck(report, { id, status: "not_checked", detail: "Requires a successful local media probe." });
|
|
2423
|
+
return finish(report);
|
|
2424
|
+
}
|
|
2425
|
+
const versions = await Promise.all([toolVersion("ffprobe"), toolVersion("ffmpeg")]);
|
|
2426
|
+
report.capabilities.ffprobe = { ...versions[0], available: true };
|
|
2427
|
+
report.capabilities.ffmpeg = versions[1];
|
|
2428
|
+
const video = probe.streams.filter((stream) => stream.type === "video");
|
|
2429
|
+
const motionVideo = video.filter((stream) => !stream.attachedPicture);
|
|
2430
|
+
const audio = probe.streams.filter((stream) => stream.type === "audio");
|
|
2431
|
+
checkExpected(report, probe, profile);
|
|
2432
|
+
const audioMismatch = profile.audio === "required" && !audio.length || profile.audio === "forbidden" && audio.length > 0;
|
|
2433
|
+
addCheck(report, { id: "audio_requirement", status: audioMismatch ? "fail" : profile.audio === "optional" ? "not_applicable" : "pass", detail: `Audio policy: ${profile.audio}; found ${audio.length} audio stream(s). This checks stream presence, not audibility.` });
|
|
2434
|
+
const filters = [];
|
|
2435
|
+
const maps = [];
|
|
2436
|
+
for (const stream of video) {
|
|
2437
|
+
filters.push(`[0:${stream.index}]${profile.black.enabled && !stream.attachedPicture ? `blackdetect@v${stream.index}=d=${profile.black.minDurationSeconds}:pix_th=${profile.black.pixelThreshold}:pic_th=${profile.black.pictureBlackRatio}` : "null"}[v${stream.index}]`);
|
|
2438
|
+
maps.push("-map", `[v${stream.index}]`);
|
|
2439
|
+
}
|
|
2440
|
+
for (const stream of audio) {
|
|
2441
|
+
filters.push(`[0:${stream.index}]${profile.silence.enabled ? `silencedetect@a${stream.index}=n=${profile.silence.thresholdDb}dB:d=${profile.silence.minDurationSeconds},` : ""}astats@a${stream.index}=metadata=0:reset=0:measure_perchannel=none:measure_overall=Peak_level+Number_of_samples[a${stream.index}]`);
|
|
2442
|
+
maps.push("-map", `[a${stream.index}]`);
|
|
2443
|
+
}
|
|
2444
|
+
let decoded = false;
|
|
2445
|
+
if (!filters.length) {
|
|
2446
|
+
addCheck(report, { id: "decode", status: "fail", code: "NO_PLAYABLE_STREAMS", detail: "No audio or video streams are available for inspection." });
|
|
2447
|
+
} else {
|
|
2448
|
+
try {
|
|
2449
|
+
const result = await runMediaTool("ffmpeg", ["-hide_banner", "-nostdin", "-nostats", "-loglevel", "info", "-xerror", "-copyts", "-start_at_zero", ...localInputArgs(probe.path), "-filter_complex", filters.join(";"), ...maps, "-progress", "pipe:1", "-stats_period", "60", "-f", "null", "-"], profile.limits);
|
|
2450
|
+
report.capabilities.ffmpeg.available = true;
|
|
2451
|
+
readDetectorOutput(report, result.stderr, audio);
|
|
2452
|
+
const progress = [...result.stdout.matchAll(/^out_time_us=(-?\d+)$/gm)].at(-1);
|
|
2453
|
+
report.measurements.decodedThroughSeconds = progress ? Number(progress[1]) / 1e6 : null;
|
|
2454
|
+
const frames = [...result.stdout.matchAll(/^frame=(\d+)$/gm)].at(-1);
|
|
2455
|
+
const empty = video.length > 0 && !!frames && Number(frames[1]) <= 0 || audio.length > 0 && report.measurements.audio.some((stream) => stream.sampleCount === 0);
|
|
2456
|
+
const missingEvidence = !/^progress=end$/m.test(result.stdout) || video.length > 0 && !frames || audio.length > 0 && report.measurements.audio.some((stream) => stream.sampleCount === null);
|
|
2457
|
+
if (empty) addCheck(report, { id: "decode", status: "fail", code: "EMPTY_DECODE", detail: "An expected stream produced no decoded frames or samples." });
|
|
2458
|
+
else if (missingEvidence) {
|
|
2459
|
+
report.complete = false;
|
|
2460
|
+
addCheck(report, { id: "decode", status: "not_checked", code: "DECODE_EVIDENCE_MISSING", detail: "The tool exited without complete decode evidence. Empty or incomplete logs are not a pass." });
|
|
2461
|
+
} else {
|
|
2462
|
+
decoded = true;
|
|
2463
|
+
addCheck(report, { id: "decode", status: "pass", detail: "FFmpeg decoded the complete file to end-of-input with explicit audio/video maps and no reported fatal decoding error." });
|
|
2464
|
+
}
|
|
2465
|
+
} catch (error) {
|
|
2466
|
+
const code = errorCode(error);
|
|
2467
|
+
const failed = code === "MEDIA_TOOL_FAILED";
|
|
2468
|
+
report.complete = false;
|
|
2469
|
+
addCheck(report, { id: "decode", status: failed ? "fail" : "not_checked", code, detail: failed ? "FFmpeg did not finish successfully. Inspect the file or local tool compatibility; this does not establish creative quality." : "Full-file decoding did not complete within the available tools or limits; detector results are incomplete." });
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
for (const id of ["silence", "black", "peak"]) {
|
|
2473
|
+
if (report.checks.some((check) => check.id === id)) continue;
|
|
2474
|
+
const applicable = id === "black" ? motionVideo.length > 0 : audio.length > 0;
|
|
2475
|
+
if (!applicable) {
|
|
2476
|
+
addCheck(report, { id, status: "not_applicable", detail: `No ${id === "black" ? "video streams excluding attached cover art" : "audio streams"}.` });
|
|
2477
|
+
continue;
|
|
2478
|
+
}
|
|
2479
|
+
if (!profile[id].enabled) {
|
|
2480
|
+
addCheck(report, { id, status: "not_checked", detail: "Explicitly disabled in the effective profile." });
|
|
2481
|
+
continue;
|
|
2482
|
+
}
|
|
2483
|
+
if (!decoded) {
|
|
2484
|
+
addCheck(report, { id, status: "not_checked", detail: "Requires successful full-file decoding." });
|
|
2485
|
+
continue;
|
|
2486
|
+
}
|
|
2487
|
+
if (id === "peak") {
|
|
2488
|
+
if (report.measurements.audio.some((stream) => stream.peakState === "unavailable")) {
|
|
2489
|
+
report.complete = false;
|
|
2490
|
+
addCheck(report, { id, status: "not_checked", code: "DETECTOR_OUTPUT_INCOMPLETE", detail: "A whole-mix sample peak could not be read for every audio stream." });
|
|
2491
|
+
continue;
|
|
2492
|
+
}
|
|
2493
|
+
for (const stream of report.measurements.audio) if (stream.samplePeakDbFS !== null && stream.samplePeakDbFS >= profile.peak.thresholdDbFS) addFinding(report, { kind: "peak_candidate", streamIndex: stream.streamIndex, measuredDbFS: stream.samplePeakDbFS, requiresReview: true, message: "Whole-mix sample peak reached the configured threshold. Check possible clipping or limiting; peak level alone is not proof of distortion." });
|
|
2494
|
+
}
|
|
2495
|
+
const count = report.findingsSummary.byKind[`${id}_candidate`];
|
|
2496
|
+
addCheck(report, { id, status: count ? "warning" : "pass", detail: count ? `${count} candidate(s) require contextual review; they are not automatic editing errors.` : "No candidates met the effective detector threshold during full-file decoding." });
|
|
2497
|
+
}
|
|
2498
|
+
try {
|
|
2499
|
+
const after = await probeLocalMedia(probe.path);
|
|
2500
|
+
const same = after.sha256 === probe.sha256 && after.bytes === probe.bytes;
|
|
2501
|
+
if (!same) report.complete = false;
|
|
2502
|
+
addCheck(report, { id: "file_stability", status: same ? "pass" : "fail", ...same ? {} : { code: "MEDIA_CHANGED_DURING_CHECK" }, detail: same ? "SHA-256 and byte length matched before and after inspection." : "The file changed during inspection; findings must not be treated as a verified report for the current file." });
|
|
2503
|
+
} catch (error) {
|
|
2504
|
+
report.complete = false;
|
|
2505
|
+
addCheck(report, { id: "file_stability", status: "not_checked", code: errorCode(error), detail: "The input fingerprint could not be verified after inspection." });
|
|
2506
|
+
}
|
|
2507
|
+
return finish(report);
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// packages/cli/src/production-output.ts
|
|
2511
|
+
import { constants as constants6 } from "node:fs";
|
|
2512
|
+
import { mkdir as mkdir4, open as open6 } from "node:fs/promises";
|
|
2513
|
+
import { dirname as dirname5 } from "node:path";
|
|
2514
|
+
async function productionOutputPath(root, relativePath) {
|
|
2515
|
+
if (!relativePath.startsWith(".editorbrain-local/") || relativePath.split(/[\\/]/).some((p) => !p || p === ".." || p === ".") || /[\\\x00-\x1f\x7f]/.test(relativePath)) {
|
|
2516
|
+
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.");
|
|
2517
|
+
}
|
|
2518
|
+
return checkedPath(root, relativePath, false);
|
|
2519
|
+
}
|
|
2520
|
+
async function prepareSampleDirectory(root, path) {
|
|
2521
|
+
const target = await productionOutputPath(root, path);
|
|
2522
|
+
await mkdir4(dirname5(target), { recursive: true });
|
|
2523
|
+
await productionOutputPath(root, path);
|
|
2524
|
+
return target;
|
|
2525
|
+
}
|
|
2526
|
+
async function readProductionJson(root, path) {
|
|
2527
|
+
const target = await checkedPath(root, path, false);
|
|
2528
|
+
const handle = await open6(target, constants6.O_RDONLY | constants6.O_NOFOLLOW | constants6.O_NONBLOCK);
|
|
2529
|
+
try {
|
|
2530
|
+
const stat = await handle.stat();
|
|
2531
|
+
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.");
|
|
2532
|
+
return JSON.parse(await handle.readFile("utf8"));
|
|
2533
|
+
} finally {
|
|
2534
|
+
await handle.close();
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
async function saveProductionReport(root, path, report) {
|
|
2538
|
+
const target = await productionOutputPath(root, path);
|
|
2539
|
+
if (!path.endsWith(".json")) throw new CliError("INVALID_ARGUMENT", "Report output needs a .json extension.", "Use a new path under .editorbrain-local/.");
|
|
2540
|
+
await atomicWrite(root, path, stableJson(report), { document: false, overwrite: false, mode: 384 });
|
|
2541
|
+
return target;
|
|
2542
|
+
}
|
|
2543
|
+
function summarizeProductionReport(value) {
|
|
2544
|
+
const r = value;
|
|
2545
|
+
const base = { schemaVersion: r.schemaVersion, kind: r.kind, summary: true, mediaUploaded: false, quality: "not_assessed" };
|
|
2546
|
+
if (r.kind === "editorbrain.media-inventory") return {
|
|
2547
|
+
...base,
|
|
2548
|
+
complete: r.complete,
|
|
2549
|
+
contentReviewed: false,
|
|
2550
|
+
counts: { assets: r.assets.length, failures: r.failures.length, skipped: r.skipped.length },
|
|
2551
|
+
assets: r.assets.slice(0, 8),
|
|
2552
|
+
failures: r.failures.slice(0, 5),
|
|
2553
|
+
omitted: { assets: Math.max(0, r.assets.length - 8), failures: Math.max(0, r.failures.length - 5), skipped: r.skipped.length },
|
|
2554
|
+
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."
|
|
2555
|
+
};
|
|
2556
|
+
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." };
|
|
2557
|
+
if (r.kind === "editorbrain.production-plan-check") return {
|
|
2558
|
+
...base,
|
|
2559
|
+
valid: r.valid,
|
|
2560
|
+
inventoryComplete: r.inventoryComplete,
|
|
2561
|
+
observations: r.observations,
|
|
2562
|
+
sourceFilesReverified: false,
|
|
2563
|
+
counts: { sources: r.coverage.length, findings: r.findings.length, repeatedPairs: r.repeatedPairs ?? 0 },
|
|
2564
|
+
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),
|
|
2565
|
+
omittedFindings: Math.max(0, r.findings.length - 8),
|
|
2566
|
+
next: "Review source reuse and inspection gaps in the saved report. Observations are agent-declared, not independently certified."
|
|
2567
|
+
};
|
|
2568
|
+
if (r.kind === "editorbrain.render-check") return {
|
|
2569
|
+
...base,
|
|
2570
|
+
complete: r.complete,
|
|
2571
|
+
technicalStatus: r.technicalStatus,
|
|
2572
|
+
file: r.file,
|
|
2573
|
+
checks: r.checks,
|
|
2574
|
+
findings: r.findings.slice(0, 8),
|
|
2575
|
+
findingsSummary: r.findingsSummary,
|
|
2576
|
+
omittedFindings: Math.max(0, r.findings.length - 8),
|
|
2577
|
+
next: "Reconcile technical candidates with the brief; inspect the actual video/audio. Technical checks do not assess narrative, aesthetics or SFX audibility."
|
|
2578
|
+
};
|
|
2579
|
+
throw new CliError("INVALID_REPORT", "Unknown production report kind.", "Use media inventory/sample or production check/plan-check.");
|
|
2580
|
+
}
|
|
2581
|
+
|
|
1772
2582
|
// packages/cli/src/index.ts
|
|
1773
2583
|
var HELP = `EditorBrain \u2014 portable editorial context for AI video agents
|
|
1774
2584
|
|
|
@@ -1811,6 +2621,10 @@ editorbrain narrative get <video-id> [--sha256 document-hash] [--event-offset 0]
|
|
|
1811
2621
|
editorbrain styles list
|
|
1812
2622
|
editorbrain styles create <style-id> [--name name] [--dir folder]
|
|
1813
2623
|
editorbrain styles check <style-id> [--dir folder]
|
|
2624
|
+
editorbrain media inventory --input file-or-folder [--max-files 200] [--dir folder] [--out .editorbrain-local/report.json] [--details]
|
|
2625
|
+
editorbrain media sample --file source --at 0,5,12 --out .editorbrain-local/frames [--dir folder] [--details]
|
|
2626
|
+
editorbrain production check --file render [--profile local-profile.json] [--dir folder] [--out .editorbrain-local/report.json] [--details]
|
|
2627
|
+
editorbrain production plan-check --inventory local-inventory.json --plan local-plan.json [--dir folder] [--out .editorbrain-local/report.json] [--details]
|
|
1814
2628
|
editorbrain styles recommend <project-id> [--assets tag,tag] [--capabilities tag,tag] [--style pack-id] [--duration seconds]
|
|
1815
2629
|
editorbrain direction draft <project-id> [--assets tag,tag] [--capabilities tag,tag] [--style pack-id] [--duration seconds] [--out new-folder]
|
|
1816
2630
|
editorbrain assets list [--limit 200] [--cursor opaque-cursor]
|
|
@@ -1826,14 +2640,16 @@ Resolve writes the source-hash lock. Export produces a handoff, not a rendered v
|
|
|
1826
2640
|
Push/pull preserve conflicting changes; media uploads use the separate assets command.
|
|
1827
2641
|
Skill install works offline, preserves existing instructions, and defaults to both agents.
|
|
1828
2642
|
Styles create/check work locally without login or media upload; checks do not certify render quality.
|
|
2643
|
+
Media/production commands run locally with ffprobe/ffmpeg, without automatic installation or media upload.
|
|
2644
|
+
Production output is compact by default; --details expands it. --out saves full private reports without overwriting.
|
|
1829
2645
|
Keys create stores the returned secret in the private user config; stdout has no key.
|
|
1830
2646
|
Output is JSON except help; login progress/code goes to stderr. --url overrides the configured endpoint.
|
|
1831
2647
|
`;
|
|
1832
2648
|
function parseArgs(argv) {
|
|
1833
2649
|
const positional = [];
|
|
1834
2650
|
const flags = {};
|
|
1835
|
-
const booleans = /* @__PURE__ */ new Set(["help", "version", "offline", "dry-run", "key-stdin", "invite-stdin", "no-browser", "skip-mcp", "all", "share-authorized"]);
|
|
1836
|
-
const known = /* @__PURE__ */ new Set(["dir", "brand", "style", "project", "topic", "goal", "name", "adapter", "out", "url", "document", "mime", "label", "role", "limit", "cursor", "timeout", "assets", "capabilities", "duration", "agent", "workspace", "prefix", "sha256", "kind", "channel", "epistemic-status", "event-offset", "relation-offset", "script", "video", "asset", "requirements", "mode", "review", "previous-version", "file", "revision", "expected-revision", "before", "scope", "id", "text", "conditions", "status", "index", "title", "days", ...booleans]);
|
|
2651
|
+
const booleans = /* @__PURE__ */ new Set(["help", "version", "offline", "dry-run", "key-stdin", "invite-stdin", "no-browser", "skip-mcp", "all", "share-authorized", "details"]);
|
|
2652
|
+
const known = /* @__PURE__ */ new Set(["input", "max-files", "at", "profile", "inventory", "plan", "dir", "brand", "style", "project", "topic", "goal", "name", "adapter", "out", "url", "document", "mime", "label", "role", "limit", "cursor", "timeout", "assets", "capabilities", "duration", "agent", "workspace", "prefix", "sha256", "kind", "channel", "epistemic-status", "event-offset", "relation-offset", "script", "video", "asset", "requirements", "mode", "review", "previous-version", "file", "revision", "expected-revision", "before", "scope", "id", "text", "conditions", "status", "index", "title", "days", ...booleans]);
|
|
1837
2653
|
for (let i = 0; i < argv.length; i++) {
|
|
1838
2654
|
const arg = argv[i];
|
|
1839
2655
|
if (!arg.startsWith("--")) {
|
|
@@ -1859,16 +2675,16 @@ function output(value) {
|
|
|
1859
2675
|
process.stdout.write(stableJson(value));
|
|
1860
2676
|
}
|
|
1861
2677
|
async function configRoot(create = false) {
|
|
1862
|
-
let path = process.env.EDITORBRAIN_CONFIG_DIR ?? process.env.EDITORHARNESS_CONFIG_DIR ??
|
|
2678
|
+
let path = process.env.EDITORBRAIN_CONFIG_DIR ?? process.env.EDITORHARNESS_CONFIG_DIR ?? join3(homedir(), ".config", "editorbrain");
|
|
1863
2679
|
if (!create && !process.env.EDITORBRAIN_CONFIG_DIR && !process.env.EDITORHARNESS_CONFIG_DIR) {
|
|
1864
2680
|
try {
|
|
1865
|
-
await
|
|
2681
|
+
await lstat7(path);
|
|
1866
2682
|
} catch (error) {
|
|
1867
2683
|
if (error.code !== "ENOENT") throw error;
|
|
1868
|
-
path =
|
|
2684
|
+
path = join3(homedir(), ".config", "editorharness");
|
|
1869
2685
|
}
|
|
1870
2686
|
}
|
|
1871
|
-
if (create) await
|
|
2687
|
+
if (create) await mkdir5(path, { recursive: true, mode: 448 });
|
|
1872
2688
|
return ensureRoot(path);
|
|
1873
2689
|
}
|
|
1874
2690
|
async function loadConfig() {
|
|
@@ -1885,7 +2701,7 @@ async function loadConfig() {
|
|
|
1885
2701
|
async function saveConfig(config2) {
|
|
1886
2702
|
const root = await configRoot(true);
|
|
1887
2703
|
await atomicWrite(root, "config.json", stableJson(config2), { document: false, mode: 384 });
|
|
1888
|
-
await chmod(
|
|
2704
|
+
await chmod(join3(root, "config.json"), 384);
|
|
1889
2705
|
}
|
|
1890
2706
|
async function config(args, requireKey = true) {
|
|
1891
2707
|
const stored = await loadConfig();
|
|
@@ -1913,7 +2729,7 @@ async function init(args) {
|
|
|
1913
2729
|
for (const path of Object.keys(files)) {
|
|
1914
2730
|
const target = await checkedPath(root, path);
|
|
1915
2731
|
try {
|
|
1916
|
-
await
|
|
2732
|
+
await lstat7(target);
|
|
1917
2733
|
throw new Error(`Refusing to replace existing file: ${path}`);
|
|
1918
2734
|
} catch (error) {
|
|
1919
2735
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -1925,13 +2741,13 @@ async function init(args) {
|
|
|
1925
2741
|
for (const path of directories) {
|
|
1926
2742
|
const target = await checkedPath(root, path, false);
|
|
1927
2743
|
try {
|
|
1928
|
-
if (!(await
|
|
2744
|
+
if (!(await lstat7(target)).isDirectory()) throw new Error(`Not a directory: ${path}`);
|
|
1929
2745
|
} catch (error) {
|
|
1930
2746
|
if (error.code !== "ENOENT") throw error;
|
|
1931
2747
|
}
|
|
1932
2748
|
}
|
|
1933
2749
|
for (const [path, content] of Object.entries(files)) await atomicWrite(root, path, content, { overwrite: false });
|
|
1934
|
-
for (const path of directories) await
|
|
2750
|
+
for (const path of directories) await mkdir5(await checkedPath(root, path, false), { recursive: true });
|
|
1935
2751
|
const guidance = await installGuidance();
|
|
1936
2752
|
const entryPoints = guidance.readInstructions.filter((item) => item.entryPointStatus === "created").map((item) => item.entryPoint);
|
|
1937
2753
|
output({ root, created: Object.keys(files), entryPoints, guidance, next: `Edit brand tokens and brief, then run editorbrain resolve ${project}` });
|
|
@@ -1991,6 +2807,49 @@ async function main() {
|
|
|
1991
2807
|
process.stdout.write(HELP);
|
|
1992
2808
|
return;
|
|
1993
2809
|
}
|
|
2810
|
+
if (args.flags.details && !["media", "production"].includes(command)) throw new CliError("INVALID_ARGUMENT", "--details is supported only by media and production.", "Read editorbrain help.");
|
|
2811
|
+
if (command === "media" || command === "production") {
|
|
2812
|
+
const required = (name) => {
|
|
2813
|
+
const value = str(args, name);
|
|
2814
|
+
if (!value) throw new CliError("INVALID_ARGUMENT", `--${name} is required.`, "Read editorbrain help.");
|
|
2815
|
+
return value;
|
|
2816
|
+
};
|
|
2817
|
+
if (argument) throw new CliError("INVALID_ARGUMENT", "Media/production inputs use named flags.", "Read editorbrain help.");
|
|
2818
|
+
const allowed = /* @__PURE__ */ new Set(["dir", "out", "details", ...command === "media" && subcommand === "inventory" ? ["input", "max-files"] : command === "media" && subcommand === "sample" ? ["file", "at"] : command === "production" && subcommand === "check" ? ["file", "profile"] : command === "production" && subcommand === "plan-check" ? ["inventory", "plan"] : []]);
|
|
2819
|
+
for (const flag of Object.keys(args.flags)) if (!allowed.has(flag)) throw new CliError("INVALID_ARGUMENT", `--${flag} is not supported for this local command.`, "Read editorbrain help.");
|
|
2820
|
+
const root2 = await ensureRoot(str(args, "dir") ?? process.cwd());
|
|
2821
|
+
const out = str(args, "out");
|
|
2822
|
+
if (out) await productionOutputPath(root2, out);
|
|
2823
|
+
const run2 = async () => {
|
|
2824
|
+
let report;
|
|
2825
|
+
if (command === "media" && subcommand === "inventory") {
|
|
2826
|
+
const limit = str(args, "max-files");
|
|
2827
|
+
report = await inventoryMedia({ input: resolve6(root2, required("input")), ...limit ? { maxFiles: Number(limit) } : {} });
|
|
2828
|
+
if (!report.complete) process.exitCode = 2;
|
|
2829
|
+
} else if (command === "media" && subcommand === "sample") {
|
|
2830
|
+
const times = required("at").split(",");
|
|
2831
|
+
if (times.some((t) => !t.trim() || !/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(t.trim()))) throw new CliError("INVALID_ARGUMENT", "--at requires comma-separated nonnegative seconds.", "For example: --at 0,5.5,12");
|
|
2832
|
+
const directory = await prepareSampleDirectory(root2, required("out"));
|
|
2833
|
+
report = await sampleMedia({ file: resolve6(root2, required("file")), times: times.map(Number), outputDirectory: directory });
|
|
2834
|
+
} else if (command === "production" && subcommand === "check") {
|
|
2835
|
+
const profilePath = str(args, "profile");
|
|
2836
|
+
const profile = profilePath ? validateRenderCheckProfile(await readProductionJson(root2, profilePath)) : void 0;
|
|
2837
|
+
report = await checkRender({ file: resolve6(root2, required("file")), profile });
|
|
2838
|
+
if (report.technicalStatus === "fail") process.exitCode = 1;
|
|
2839
|
+
else if (!report.complete) process.exitCode = 2;
|
|
2840
|
+
} else if (command === "production" && subcommand === "plan-check") {
|
|
2841
|
+
const [inventory, plan] = await Promise.all([readProductionJson(root2, required("inventory")), readProductionJson(root2, required("plan"))]);
|
|
2842
|
+
report = checkProductionPlan(inventory, plan);
|
|
2843
|
+
if (!report.valid) process.exitCode = 1;
|
|
2844
|
+
else if (!report.inventoryComplete) process.exitCode = 2;
|
|
2845
|
+
} else throw new CliError("INVALID_ARGUMENT", "Unknown media/production command.", "Use media inventory/sample or production check/plan-check.");
|
|
2846
|
+
const reportPath = out && subcommand !== "sample" ? await saveProductionReport(root2, out, report) : void 0;
|
|
2847
|
+
output({ ...args.flags.details ? report : summarizeProductionReport(report), ...reportPath ? { reportPath } : {} });
|
|
2848
|
+
};
|
|
2849
|
+
if (out) await withWorkspaceLock(root2, run2, { migrateLegacy: false });
|
|
2850
|
+
else await run2();
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
1994
2853
|
if (command === "doctor") {
|
|
1995
2854
|
let selected, configError;
|
|
1996
2855
|
if (!args.flags.offline) {
|
|
@@ -2073,10 +2932,10 @@ async function main() {
|
|
|
2073
2932
|
const destination = str(args, "out");
|
|
2074
2933
|
if (destination) {
|
|
2075
2934
|
if (command !== "direction" || !result.files) throw new Error("--out is supported only for direction draft.");
|
|
2076
|
-
const outRoot = await ensureRoot(
|
|
2935
|
+
const outRoot = await ensureRoot(resolve6(str(args, "dir") ?? process.cwd(), destination), true);
|
|
2077
2936
|
for (const path of Object.keys(result.files)) {
|
|
2078
2937
|
try {
|
|
2079
|
-
await
|
|
2938
|
+
await lstat7(await checkedPath(outRoot, path, false));
|
|
2080
2939
|
throw new Error(`Refusing to replace existing proposal: ${path}`);
|
|
2081
2940
|
} catch (error) {
|
|
2082
2941
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -2102,7 +2961,7 @@ async function main() {
|
|
|
2102
2961
|
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(result.id) || typeof result.apiKey !== "string") throw new Error("Invalid key response");
|
|
2103
2962
|
const file = `keys/${result.id}.json`;
|
|
2104
2963
|
await atomicWrite(root2, file, stableJson({ url: client.url, apiKey: result.apiKey, id: result.id, label, role }), { document: false, mode: 384, overwrite: false });
|
|
2105
|
-
output({ id: result.id, label, role, credentialFile:
|
|
2964
|
+
output({ id: result.id, label, role, credentialFile: join3(root2, file) });
|
|
2106
2965
|
return;
|
|
2107
2966
|
}
|
|
2108
2967
|
if (subcommand === "revoke" && argument) {
|
|
@@ -2189,8 +3048,8 @@ async function main() {
|
|
|
2189
3048
|
return;
|
|
2190
3049
|
}
|
|
2191
3050
|
if (subcommand === "upload" && argument) {
|
|
2192
|
-
const target =
|
|
2193
|
-
const handle = await
|
|
3051
|
+
const target = resolve6(str(args, "dir") ?? process.cwd(), argument);
|
|
3052
|
+
const handle = await open7(target, constants7.O_RDONLY | constants7.O_NOFOLLOW);
|
|
2194
3053
|
let bytes;
|
|
2195
3054
|
try {
|
|
2196
3055
|
const info = await handle.stat();
|
|
@@ -2200,24 +3059,24 @@ async function main() {
|
|
|
2200
3059
|
await handle.close();
|
|
2201
3060
|
}
|
|
2202
3061
|
const types = { ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4", ".ogg": "audio/ogg" };
|
|
2203
|
-
const mimeType = str(args, "mime") ?? types[
|
|
3062
|
+
const mimeType = str(args, "mime") ?? types[extname3(target).toLowerCase()];
|
|
2204
3063
|
if (!mimeType) throw new Error("Unsupported extension; specify --mime for a supported media format");
|
|
2205
|
-
const
|
|
2206
|
-
const result = await client.media(`/v1/assets/${
|
|
3064
|
+
const hash2 = await sha256(bytes);
|
|
3065
|
+
const result = await client.media(`/v1/assets/${hash2}?name=${encodeURIComponent(basename2(target))}`, { content: bytes, mimeType });
|
|
2207
3066
|
const body = await result.json();
|
|
2208
|
-
if (body.sha256 !==
|
|
3067
|
+
if (body.sha256 !== hash2) throw new Error("Upload response hash mismatch");
|
|
2209
3068
|
output(body);
|
|
2210
3069
|
return;
|
|
2211
3070
|
}
|
|
2212
3071
|
if (subcommand === "download" && argument && str(args, "out")) {
|
|
2213
3072
|
if (!/^[a-f0-9]{64}$/.test(argument)) throw new Error("Asset ID must be its SHA-256");
|
|
2214
|
-
const destination =
|
|
2215
|
-
const root2 = await ensureRoot(
|
|
2216
|
-
await checkedPath(root2,
|
|
3073
|
+
const destination = resolve6(str(args, "dir") ?? process.cwd(), str(args, "out"));
|
|
3074
|
+
const root2 = await ensureRoot(dirname6(destination), true);
|
|
3075
|
+
await checkedPath(root2, basename2(destination), false);
|
|
2217
3076
|
const response = await client.media(`/v1/assets/${argument}`);
|
|
2218
3077
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
2219
3078
|
if (await sha256(bytes) !== argument) throw new Error("Downloaded asset failed SHA-256 verification");
|
|
2220
|
-
await atomicWrite(root2,
|
|
3079
|
+
await atomicWrite(root2, basename2(destination), bytes, { document: false, overwrite: false });
|
|
2221
3080
|
output({ path: destination, sha256: argument, bytes: bytes.length });
|
|
2222
3081
|
return;
|
|
2223
3082
|
}
|
|
@@ -2247,7 +3106,7 @@ async function main() {
|
|
|
2247
3106
|
if (command === "search" && subcommand === "brief") {
|
|
2248
3107
|
const path = str(args, "file");
|
|
2249
3108
|
if (!path) throw new Error("Provide --file with the editorial brief JSON after inspecting the actual material.");
|
|
2250
|
-
const handle = await
|
|
3109
|
+
const handle = await open7(resolve6(path), constants7.O_RDONLY | constants7.O_NOFOLLOW);
|
|
2251
3110
|
let brief;
|
|
2252
3111
|
try {
|
|
2253
3112
|
const stat = await handle.stat();
|
|
@@ -2269,7 +3128,7 @@ async function main() {
|
|
|
2269
3128
|
const scriptPath = str(args, "script"), videoPath = str(args, "video");
|
|
2270
3129
|
let script, assetId = str(args, "asset");
|
|
2271
3130
|
const readInput = async (path, max) => {
|
|
2272
|
-
const handle = await
|
|
3131
|
+
const handle = await open7(resolve6(path), constants7.O_RDONLY | constants7.O_NOFOLLOW);
|
|
2273
3132
|
try {
|
|
2274
3133
|
const stat = await handle.stat();
|
|
2275
3134
|
if (!stat.isFile() || stat.size < 1 || stat.size > max) throw new Error(`Input must be a regular file of at most ${max} bytes.`);
|
|
@@ -2289,10 +3148,10 @@ async function main() {
|
|
|
2289
3148
|
if (assetId && !/^[a-f0-9]{64}$/.test(assetId)) throw new Error("--asset requires an owned production asset SHA-256.");
|
|
2290
3149
|
if (videoPath) {
|
|
2291
3150
|
const bytes = await readInput(videoPath, 12 * 1024 * 1024);
|
|
2292
|
-
const mimeType = { ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm" }[
|
|
3151
|
+
const mimeType = { ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm" }[extname3(videoPath).toLowerCase()];
|
|
2293
3152
|
if (!mimeType) throw new Error("Use MP4, MOV or WebM.");
|
|
2294
3153
|
assetId = await sha256(bytes);
|
|
2295
|
-
const uploaded = await client.media(`/v1/assets/${assetId}?name=${encodeURIComponent(
|
|
3154
|
+
const uploaded = await client.media(`/v1/assets/${assetId}?name=${encodeURIComponent(basename2(videoPath))}`, { content: bytes, mimeType });
|
|
2296
3155
|
if ((await uploaded.json()).sha256 !== assetId) throw new Error("Upload hash mismatch.");
|
|
2297
3156
|
}
|
|
2298
3157
|
if (!script && !assetId && !str(args, "project")) throw new Error("Provide --script, --video, --asset or --project with a saved script.");
|
|
@@ -2307,20 +3166,20 @@ async function main() {
|
|
|
2307
3166
|
}
|
|
2308
3167
|
if (subcommand === "get" && argument) {
|
|
2309
3168
|
const document = str(args, "document");
|
|
2310
|
-
const
|
|
2311
|
-
if (
|
|
2312
|
-
const result = await client.request(`/v1/library/${encodeURIComponent(argument)}${document ? `/documents?${new URLSearchParams({ path: document, ...
|
|
2313
|
-
if (document && (typeof result.content !== "string" || await sha256(result.content) !== result.sha256 ||
|
|
3169
|
+
const hash2 = str(args, "sha256");
|
|
3170
|
+
if (hash2 && !document) throw new Error("--sha256 requires --document for library get.");
|
|
3171
|
+
const result = await client.request(`/v1/library/${encodeURIComponent(argument)}${document ? `/documents?${new URLSearchParams({ path: document, ...hash2 ? { sha256: hash2 } : {} })}` : ""}`);
|
|
3172
|
+
if (document && (typeof result.content !== "string" || await sha256(result.content) !== result.sha256 || hash2 && result.sha256 !== hash2)) throw new CliError("HASH_MISMATCH", "Reference document verification failed.", "Do not use this evidence; retry the pinned version.");
|
|
2314
3173
|
output(result);
|
|
2315
3174
|
return;
|
|
2316
3175
|
}
|
|
2317
3176
|
if (subcommand === "media" && argument && str(args, "out")) {
|
|
2318
|
-
const
|
|
2319
|
-
if (!
|
|
2320
|
-
const destination =
|
|
2321
|
-
const root2 = await ensureRoot(
|
|
2322
|
-
await checkedPath(root2,
|
|
2323
|
-
const response = await client.media(`/v1/library/${encodeURIComponent(argument)}/media/${
|
|
3177
|
+
const hash2 = str(args, "sha256");
|
|
3178
|
+
if (!hash2 || !/^[a-f0-9]{64}$/.test(hash2)) throw new Error("--sha256 must be a media SHA-256 from library get.");
|
|
3179
|
+
const destination = resolve6(str(args, "dir") ?? process.cwd(), str(args, "out"));
|
|
3180
|
+
const root2 = await ensureRoot(dirname6(destination), true);
|
|
3181
|
+
await checkedPath(root2, basename2(destination), false);
|
|
3182
|
+
const response = await client.media(`/v1/library/${encodeURIComponent(argument)}/media/${hash2}`);
|
|
2324
3183
|
const reader = response.body?.getReader();
|
|
2325
3184
|
if (!reader) throw new Error("Media body missing.");
|
|
2326
3185
|
const chunks = [];
|
|
@@ -2336,9 +3195,9 @@ async function main() {
|
|
|
2336
3195
|
chunks.push(value);
|
|
2337
3196
|
}
|
|
2338
3197
|
const bytes = Buffer.concat(chunks);
|
|
2339
|
-
if (await sha256(bytes) !==
|
|
2340
|
-
await atomicWrite(root2,
|
|
2341
|
-
output({ videoId: argument, path: destination, sha256:
|
|
3198
|
+
if (await sha256(bytes) !== hash2) throw new CliError("HASH_MISMATCH", "Reference media verification failed.", "Do not use this file as evidence.");
|
|
3199
|
+
await atomicWrite(root2, basename2(destination), bytes, { document: false, overwrite: false });
|
|
3200
|
+
output({ videoId: argument, path: destination, sha256: hash2, bytes: size, mimeType: response.headers.get("Content-Type"), reviewed: false });
|
|
2342
3201
|
return;
|
|
2343
3202
|
}
|
|
2344
3203
|
throw new Error("Use library search, library get, or library media <video-id> --sha256 hash --out file.");
|
|
@@ -2373,7 +3232,7 @@ async function main() {
|
|
|
2373
3232
|
const adapter = str(args, "adapter") ?? "neutral";
|
|
2374
3233
|
if (!["neutral", "hyperframes", "remotion"].includes(adapter)) throw new Error("Adapter must be neutral, hyperframes or remotion");
|
|
2375
3234
|
const generated = exportProject(resolved, adapter);
|
|
2376
|
-
const destination =
|
|
3235
|
+
const destination = resolve6(root, str(args, "out") ?? `videos/${project}/plans/${adapter}`);
|
|
2377
3236
|
const outRoot = await ensureRoot(destination, true);
|
|
2378
3237
|
for (const [path, content] of Object.entries(generated)) await atomicWrite(outRoot, path, content, { document: false });
|
|
2379
3238
|
output({ adapter, kind: "editorial_handoff", rendered: false, directory: outRoot, files: Object.keys(generated), warnings: resolved.warnings });
|